feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

+1 -1
View File
@@ -285,7 +285,7 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ {
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
+1 -1
View File
@@ -265,7 +265,7 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ {
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
+4
View File
@@ -0,0 +1,4 @@
-- termix_identities, termix_identity_keys and termix_identity_ca move to the
-- termix-identity plugin's adopted tables at activation. Dropping them here
-- would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -386,6 +386,13 @@
"when": 1790298804983,
"tag": "0054_wakeful_misty_knight",
"breakpoints": true
},
{
"idx": 55,
"version": "5",
"when": 1790300572937,
"tag": "0055_icy_patch",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- termix_identities, termix_identity_keys and termix_identity_ca move to the
-- termix-identity plugin's adopted tables at activation. Dropping them here
-- would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -393,6 +393,13 @@
"when": 1790298804216,
"tag": "0055_jazzy_quasimodo",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1790300572231,
"tag": "0056_chilly_vindicator",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- termix_identities, termix_identity_keys and termix_identity_ca move to the
-- termix-identity plugin's adopted tables at activation. Dropping them here
-- would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -365,6 +365,13 @@
"when": 1790298803438,
"tag": "0051_conscious_blur",
"breakpoints": true
},
{
"idx": 52,
"version": "6",
"when": 1790300571504,
"tag": "0052_public_professor_monster",
"breakpoints": true
}
]
}
+11
View File
@@ -7048,6 +7048,10 @@
"resolved": "plugins/tailscale",
"link": true
},
"node_modules/@termix-plugin/termix-identity": {
"resolved": "plugins/termix-identity",
"link": true
},
"node_modules/@termix-plugin/tmux-monitor": {
"resolved": "plugins/tmux-monitor",
"link": true
@@ -18749,6 +18753,13 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/termix-identity": {
"name": "@termix-plugin/termix-identity",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/tmux-monitor": {
"name": "@termix-plugin/tmux-monitor",
"version": "1.0.0",
+74 -26
View File
@@ -441,29 +441,30 @@ when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense.
| Member | What it does |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| Member | What it does |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
@@ -484,8 +485,11 @@ components `send(type, data)` for the session socket and `connectPayload()`,
the data an `<interaction>_auth_completed` needs to reconnect, so a sign-in
flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and
`dashboard.secondaryView`. **B16** added the middle two, component slots in
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics`,
`credentials.badges` and `dashboard.secondaryView`. **C6** added
`credentials.badges`, a component slot after the key/password badge on each
saved credential row (`{ credentialId }`); termix-identity shows its "ID"
badge there, so the credential list no longer fetches Termix ID data. **B16** added the middle two, component slots in
the dashboard's host status card (`{ hostId, online }`, one per host row) and
the homepage's host status widget (`{ hostId, shownMetrics, online }`): core
draws the status and a plugin draws the numbers, so core no longer calls the
@@ -1190,6 +1194,32 @@ base types and branches on no plugin type name.
message names (`vault_auth_url`, `vault_completed`, `vault_error`) for
Termix-Mobile.
**C6** moved Termix Identity out. It never was an SSH auth type: issued
certificates are downloaded once and never stored, and no connect path reads
them, so `termix-identity` is a standalone feature plugin with no provider.
- It adopts `termix_identities`, `termix_identity_keys` and
`termix_identity_ca` as `p_termix_identity_identities`, `_keys` and `_ca`.
The migrations keep, by hand, the links from keys and CA to their identity
(cascade) and from a key to `ssh_credentials` (set null).
- The CA private key is sealed with `ctx.secrets.seal` (`secrets:own`), so
issuing a certificate no longer needs the owner's data key unlocked.
`termix-identity-ca-migration.ts` reseals 2.8 rows, which core encrypted
with the owner's data key; it runs from `runPluginDataMoves` right after
the adoption and again at boot for a row whose key was not open.
- The resolver, `/plugin-api/termix-identity/u/<handle>[/<ALGO>|/ca]`, is
public through `ctx.http.router({ public })`. The 2.8 URLs under
`/termix-id/u/` answer 308 to it (`registerTermixIdCompatRoutes` in
`host-compat-routes.ts`; nginx proxies only `/termix-id/u/` now), because
servers fetch them from provisioning scripts. The management routes moved
and are gated on the new `termix-identity.use` (admins and users).
- Publishing a saved key and saving a generated pair go through
`ctx.credentials.listSshKeys` and `createSshKey` (**C6**, see the ctx
surface): the plugin never sees a stored private key.
- The rail item keeps its id `termix-id`, so saved layouts, hidden-rail
preferences and presets carry over, and uses `app.desktop` to stay hidden
in a standalone desktop app, as the shell did before.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1693,6 +1723,7 @@ deciding, not the mechanism.
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `credentials:write` | medium | Save new credentials for the acting user |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `network:broadcast` | medium | Send network broadcast packets |
@@ -1763,6 +1794,8 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
@@ -1924,6 +1957,21 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**C6** added two members for Termix Identity, both for the acting user's own
saved SSH keys and both audited:
- `listSshKeys()` returns `{ id, name, username, publicKey }` for every key
credential the actor owns. Core derives the public key from the private
key when none was stored, so the plugin never receives private material.
Needs `credentials:use` and the core `credentials.view` permission.
- `createSshKey({ name, description, username, privateKey, publicKey,
keyType })` saves a key pair as a new encrypted credential and returns its
id. Needs `credentials:write`, a new capability, the core
`credentials.create` permission and an unlocked data key. There is no
delete: a plugin that has to undo a save orders its own writes so it never
needs one (termix-identity publishes the key first and removes that row if
the save fails).
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
@@ -2570,7 +2618,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3), step-ca (C4), vault (C5),
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
+14
View File
@@ -657,3 +657,17 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
editor (sharing only offered with "Share Vault profiles"); switch to the new
redirect URI after allowing it in the Vault role; disable the vault plugin
and a Vault host says it needs it, then enable it again.
- **C6 (termix-identity):** only the public resolver URLs under
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
Nothing in this repo calls them; confirm Termix-Mobile does not before
2.9.0 ships, and add redirects if it does. Owner: D0.
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
no `idx_termix_identity_keys_identity` (core never created one there and
an adoption writes no MySQL indexes), so the public resolver scans the
keys table. Add it in a later plugin migration if a MySQL install needs
it. Owner: D0.
- **C6 (termix-identity):** the docs site still shows the
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
D0.
@@ -106,8 +106,10 @@
"device:serial",
"hosts:write",
"credentials:use",
"credentials:write",
"network:outbound",
"network:serve",
"network:broadcast",
"users:read",
"events:core",
"notify:send",
@@ -116,6 +118,7 @@
"hosts:read",
"db:own",
"kv:own",
"files:own",
"secrets:own",
"settings:read-core",
"ui:surface"
+32 -1
View File
@@ -1382,11 +1382,42 @@ export interface PluginProtocolTarget {
};
}
/** One of the acting user's saved SSH key credentials, public half only. */
export interface PluginSshKeyCredential {
id: number;
name: string;
username: string | null;
/** "<type> <base64>", derived from the private key when none was stored. Null when it cannot be. */
publicKey: string | null;
}
/** A key pair to save as a new credential for the acting user. */
export interface PluginSshKeyCredentialInput {
name: string;
description?: string;
username?: string | null;
privateKey: string;
publicKey: string;
/** The key's algorithm token, such as "ssh-ed25519". */
keyType: string;
}
/**
* Plaintext host credentials, for a plugin that has to hand them to another
* program. Needs credentials:read, and every call is audited.
*/
export interface PluginCredentials {
/**
* The acting user's own SSH key credentials, without any private material.
* Needs credentials:use and the core credentials.view permission. Audited.
*/
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
/**
* Saves a key pair as a new encrypted credential owned by the acting user
* and returns its id. Needs credentials:write, the core credentials.create
* permission and an unlocked data key. Audited.
*/
createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>;
/** Null when the host does not exist or the acting user cannot connect to it. */
resolveHostProtocol: (
hostId: number,
@@ -1627,7 +1658,7 @@ export interface PluginContext {
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Plaintext host protocol credentials. Needs credentials:read. */
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
readonly credentials: PluginCredentials;
/** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit;
+1
View File
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("credentials:write", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
+15
View File
@@ -530,6 +530,19 @@ export interface PluginWsTarget {
protocols?: string[];
}
/** Where the frontend runs, for a plugin that behaves differently in the desktop app. */
export interface DesktopApi {
/** True inside the Termix desktop app. */
readonly available: boolean;
/**
* The remote server the desktop app is connected to for sync, or null
* outside the desktop app and while it runs standalone.
*/
remoteServerUrl: () => Promise<string | null>;
/** Called when the desktop app connects to or leaves a remote server. */
onRemoteServerChange: (listener: () => void) => Disposer;
}
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -629,6 +642,8 @@ export interface TermixApp extends TermixAppInfo {
options?: { origin?: unknown },
) => Promise<PluginWsTarget | null>;
tabs: TabsApi;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: Disposer) => void;
}
+38
View File
@@ -58,6 +58,8 @@ import type {
PluginHostShareResult,
PluginShareableUser,
PluginShareableRole,
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
@@ -120,6 +122,8 @@ export interface FakeContextOptions {
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */
@@ -273,6 +277,8 @@ export interface FakePluginContext {
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
@@ -392,6 +398,7 @@ export function createFakeContext(
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
@@ -972,6 +979,25 @@ export function createFakeContext(
},
credentials: {
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
id: key.id,
name: key.name,
username: key.username ?? null,
publicKey: key.publicKey,
})),
],
createSshKey: async (input) => {
const id =
Math.max(
0,
...(options.sshKeyCredentials ?? []).map((key) => key.id),
...createdSshKeys.map((key) => key.id),
) + 1;
createdSshKeys.push({ ...input, id });
return { id };
},
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
@@ -1049,6 +1075,7 @@ export function createFakeContext(
desktopWindows,
nativeRdpLaunches,
credentialReads,
createdSshKeys,
notifications,
fetches,
processRuns,
@@ -1105,6 +1132,8 @@ export interface MockContextOptions {
sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */
@@ -1164,6 +1193,7 @@ export function createMockCtx(
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
@@ -1462,6 +1492,14 @@ export function createMockCtx(
},
credentials: {
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
},
createSshKey: async (input) => {
require("credentials:write");
return ctx.credentials.createSshKey(input);
},
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);
+14
View File
@@ -0,0 +1,14 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Termix ID moved out of core. The resolver is now
`/plugin-api/termix-identity/u/<handle>`; the 2.8 URLs under
`/termix-id/u/` redirect to it.
- Handles, published keys and certificate authorities are kept on upgrade.
The CA private key is now encrypted with the installation key instead of
the owner's data key, so issuing a certificate no longer needs the data
key unlocked.
- New `termix-identity.use` permission, granted to admins and users.
+33
View File
@@ -0,0 +1,33 @@
# Termix Identity
Claim a public handle and publish your SSH public keys under it. Any server
can then pull them into `authorized_keys`:
```bash
curl -fsSL https://<termix>/plugin-api/termix-identity/u/<handle> >> ~/.ssh/authorized_keys
```
Add `/<ALGO>` (for example `/ED25519`) to serve only one key type. The
resolver needs no login and is never cached.
## Certificate authority
Each handle can have its own SSH certificate authority. Servers trust it with
`TrustedUserCAKeys`, using the public key from `/u/<handle>/ca`. Termix then
issues short-lived certificates for your Ed25519 keys. Rotating the CA
revokes every certificate it issued. The CA private key is stored encrypted
with the installation key.
Certificates are downloaded once and never stored, so hosts in Termix do not
use them to connect. This plugin adds no SSH auth type.
## Old URLs
The 2.8 resolver URLs (`/termix-id/u/<handle>`, `/termix-id/u/<handle>/<ALGO>`
and `/termix-id/u/<handle>/ca`) permanently redirect here, so servers that
fetch them from scripts keep working. `curl -L` follows the redirect.
## Permission
`termix-identity.use` lets a user claim a handle, publish keys and run a CA.
Admins and users have it by default.
+86
View File
@@ -0,0 +1,86 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Failed to load Termix ID",
"claimTitle": "Claim your Termix ID",
"claimIntro": "Pick a unique handle. Your SSH public keys will be published at a public URL you can add to any server's authorized_keys file.",
"handleLabel": "Handle",
"handlePlaceholder": "alice",
"checking": "Checking…",
"available": "Available",
"taken": "Already taken",
"invalidHandle": "Lowercase letters, numbers, - and _ only",
"descriptionLabel": "Description (optional)",
"descriptionPlaceholder": "Work laptop & phone keys",
"create": "Create Termix ID",
"created": "Termix ID created",
"createFailed": "Failed to create Termix ID",
"deleteConfirm": "Delete your Termix ID and all published keys? Servers you provisioned will keep the keys until you remove them manually.",
"deleted": "Termix ID deleted",
"deleteFailed": "Failed to delete Termix ID",
"copyFailed": "Copy failed",
"resolverUrlLabel": "Public resolver URL",
"provisionLabel": "Provision a server",
"publishTitle": "Publish a public key",
"generate": "Generate",
"generateTooltip": "Generate an Ed25519 key pair. Publishes the public key and downloads the private key to your device.",
"saveToVault": "Save To Credentials",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Label (optional)",
"add": "Add",
"importFromCredential": "Or import from a stored credential",
"keyPublished": "Key published",
"addKeyFailed": "Failed to add key",
"generatedSaved": "Key pair generated and saved to your credentials vault. Private key also downloaded.",
"generatedOnly": "Key pair generated. Private key downloaded (shown only once).",
"generateFailed": "Failed to generate key",
"imported": "Key imported from credential",
"importFailed": "Failed to import key",
"noKeys": "No public keys published yet.",
"keysTitle": "Published keys",
"published": "Published",
"hidden": "Hidden",
"keyRemoved": "Key removed",
"removeKeyFailed": "Failed to remove key",
"updateKeyFailed": "Failed to update key",
"fromVault": "From credentials vault",
"linkedToTermixId": "Published via Termix ID",
"selectCredential": "Select a credential...",
"import": "Import",
"caTitle": "Certificate authority",
"caIntro": "Trust this CA on a server and it accepts any certificate you sign. Rotate to revoke everything at once; certificates also expire on their own.",
"caEnable": "Enable CA",
"caEnabled": "CA enabled",
"caCreateFailed": "Failed to enable CA",
"caPublicKeyLabel": "CA public key",
"caTrustLabel": "Trust on a server (run as root)",
"caRotate": "Rotate",
"caRotateConfirm": "Rotate the CA? Every certificate it has signed will stop being accepted and must be re-issued.",
"caRotated": "CA rotated. Previous certificates revoked.",
"caRotateFailed": "Failed to rotate CA",
"caDelete": "Remove CA",
"caDeleteConfirm": "Remove the certificate authority?",
"caDeleted": "CA removed",
"caDeleteFailed": "Failed to remove CA",
"caValidityLabel": "Cert validity (days)",
"issueCert": "Certificate",
"issueCertTooltip": "Issue an SSH certificate for this key, signed by your CA",
"certIssued": "Certificate issued and downloaded",
"certIssueFailed": "Failed to issue certificate",
"docsLink": "View docs",
"principalsLabel": "SSH principals",
"principalsPlaceholder": "SSH principal(s), e.g. root"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
},
"permissions": {
"use": {
"title": "Use Termix ID",
"description": "Claim a public handle, publish SSH keys and run a certificate authority."
}
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix-ID",
"loadFailed": "Kon nie Termix ID laai nie",
"claimTitle": "Eis jou Termix ID op",
"claimIntro": "Kies 'n unieke handvatsel. Jou SSH publieke sleutels sal gepubliseer word by 'n publieke URL wat jy by enige bediener se authorized_keys-lêer kan voeg.",
"handleLabel": "Hanteer",
"handlePlaceholder": "Alice",
"checking": "Kontroleer…",
"available": "Beskikbaar",
"taken": "Reeds geneem",
"invalidHandle": "Slegs kleinletters, syfers, - en _",
"descriptionLabel": "Beskrywing (opsioneel)",
"descriptionPlaceholder": "Werkskootrekenaar- en foonsleutels",
"create": "Skep Termix ID",
"created": "Termix ID geskep",
"createFailed": "Kon nie Termix ID skep nie",
"deleteConfirm": "Vee jou Termix ID en alle gepubliseerde sleutels uit? Bedieners wat jy voorsien het, sal die sleutels hou totdat jy dit handmatig verwyder.",
"deleted": "Termix-ID uitgevee",
"deleteFailed": "Kon nie Termix ID uitvee nie",
"copyFailed": "Kopieer het misluk",
"resolverUrlLabel": "Openbare resolver-URL",
"provisionLabel": "Voorsien 'n bediener",
"publishTitle": "Publiseer 'n publieke sleutel",
"generate": "Genereer",
"generateTooltip": "Genereer 'n Ed25519-sleutelpaar — publiseer die publieke sleutel en laai die privaat sleutel na jou toestel af",
"saveToVault": "Stoor na geloofsbriewe",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... gebruiker@gasheer",
"labelPlaceholder": "Etiket (opsioneel)",
"add": "Voeg by",
"importFromCredential": "Of invoer vanaf 'n gestoorde geloofsbrief",
"keyPublished": "Sleutel gepubliseer",
"addKeyFailed": "Kon nie sleutel byvoeg nie",
"generatedSaved": "Sleutelpaar gegenereer en gestoor in jou geloofsbriewekluis. Privaat sleutel ook afgelaai.",
"generatedOnly": "Sleutelpaar gegenereer — private sleutel afgelaai (slegs een keer getoon).",
"generateFailed": "Kon nie sleutel genereer nie",
"imported": "Sleutel ingevoer vanaf geloofsbriewe",
"importFailed": "Kon nie sleutel invoer nie",
"noKeys": "Geen publieke sleutels nog gepubliseer nie.",
"keysTitle": "Gepubliseerde sleutels",
"published": "Gepubliseer",
"hidden": "Versteek",
"keyRemoved": "Sleutel verwyder",
"removeKeyFailed": "Kon nie sleutel verwyder nie",
"updateKeyFailed": "Kon nie sleutel opdateer nie",
"fromVault": "Vanuit geloofsbriewekluis",
"linkedToTermixId": "Gepubliseer via Termix ID",
"selectCredential": "Kies 'n geloofsbrief...",
"import": "Invoer",
"caTitle": "Sertifikaatowerheid",
"caIntro": "Vertrou hierdie CA op 'n bediener en dit aanvaar enige sertifikaat wat jy teken. Draai om alles gelyktydig te herroep; sertifikate verval ook vanself.",
"caEnable": "Aktiveer CA",
"caEnabled": "CA geaktiveer",
"caCreateFailed": "Kon nie CA aktiveer nie",
"caPublicKeyLabel": "CA publieke sleutel",
"caTrustLabel": "Vertrou op 'n bediener (loop as root)",
"caRotate": "Roteer",
"caRotateConfirm": "Roteer die CA? Elke sertifikaat wat dit onderteken het, sal nie meer aanvaar word nie en moet heruitgereik word.",
"caRotated": "KA geroteer — vorige sertifikate herroep",
"caRotateFailed": "Kon nie CA roteer nie",
"caDelete": "Verwyder CA",
"caDeleteConfirm": "Verwyder die sertifikaatowerheid?",
"caDeleted": "KA verwyder",
"caDeleteFailed": "Kon nie CA verwyder nie",
"caValidityLabel": "Sertifikaatgeldigheid (dae)",
"issueCert": "Sertifikaat",
"issueCertTooltip": "Reik 'n SSH-sertifikaat uit vir hierdie sleutel, onderteken deur jou CA",
"certIssued": "Sertifikaat uitgereik en afgelaai",
"certIssueFailed": "Kon nie sertifikaat uitreik nie"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "فشل تحميل Termix ID",
"claimTitle": "احصل على Termix ID الخاص بك",
"claimIntro": "اختر معرّفًا فريدًا. سيتم نشر مفاتيح SSH العامة الخاصة بك في رابط عام يمكنك إضافته إلى ملف authorized_keys لأي خادم.",
"handleLabel": "المعرّف",
"handlePlaceholder": "alice",
"checking": "جارٍ التحقق…",
"available": "متاح",
"taken": "مستخدم بالفعل",
"invalidHandle": "أحرف صغيرة، أرقام، - و _ فقط",
"descriptionLabel": "الوصف (اختياري)",
"descriptionPlaceholder": "مفاتيح حاسوب العمل والهاتف",
"create": "إنشاء Termix ID",
"created": "تم إنشاء Termix ID",
"createFailed": "فشل إنشاء Termix ID",
"deleteConfirm": "حذف Termix ID وجميع المفاتيح المنشورة؟ ستحتفظ الخوادم التي قمت بتجهيزها بالمفاتيح حتى تقوم بإزالتها يدويًا.",
"deleted": "تم حذف Termix ID",
"deleteFailed": "فشل حذف Termix ID",
"copyFailed": "فشل النسخ",
"resolverUrlLabel": "رابط المُحلل العام",
"provisionLabel": "تجهيز خادم",
"publishTitle": "نشر مفتاح عام",
"generate": "توليد",
"generateTooltip": "توليد زوج مفاتيح Ed25519 — ينشر المفتاح العام ويُنزّل المفتاح الخاص إلى جهازك",
"saveToVault": "حفظ في بيانات الاعتماد",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "وسم (اختياري)",
"add": "إضافة",
"importFromCredential": "أو استيراد من بيانات اعتماد مخزّنة",
"keyPublished": "تم نشر المفتاح",
"addKeyFailed": "فشل إضافة المفتاح",
"generatedSaved": "تم توليد زوج المفاتيح وحفظه في مخزن بيانات الاعتماد. كما تم تنزيل المفتاح الخاص.",
"generatedOnly": "تم توليد زوج المفاتيح — تم تنزيل المفتاح الخاص (يُعرض مرة واحدة فقط).",
"generateFailed": "فشل توليد المفتاح",
"imported": "تم استيراد المفتاح من بيانات الاعتماد",
"importFailed": "فشل استيراد المفتاح",
"noKeys": "لا توجد مفاتيح عامة منشورة بعد.",
"keysTitle": "المفاتيح المنشورة",
"published": "منشور",
"hidden": "مخفي",
"keyRemoved": "تمت إزالة المفتاح",
"removeKeyFailed": "فشل إزالة المفتاح",
"updateKeyFailed": "فشل تحديث المفتاح",
"fromVault": "من خزنة بيانات الاعتماد",
"linkedToTermixId": "منشور عبر Termix ID",
"selectCredential": "اختر بيانات اعتماد...",
"import": "استيراد",
"caTitle": "المرجع المصدق (CA)",
"caIntro": "وثق هذا المرجع المصدق على الخادم ليقبل أي شهادة توقعها. دوِّره لإبطال كل الشهادات دفعة واحدة؛ تنتهي صلاحية الشهادات تلقائيًا أيضًا.",
"caEnable": "فعّل المرجع المصدق",
"caEnabled": "تم تفعيل المرجع المصدق",
"caCreateFailed": "فشل تفعيل المرجع المصدق",
"caPublicKeyLabel": "المفتاح العام للمرجع المصدق",
"caTrustLabel": "ثق به على الخادم (تشغيل بصلاحيات الجذر)",
"caRotate": "دوِّر",
"caRotateConfirm": "هل تريد تدوير المرجع المصدق؟ ستتوقف جميع الشهادات التي وقعها عن القبول ويجب إعادة إصدارها.",
"caRotated": "تم تدوير المرجع المصدق — أُبطلت الشهادات السابقة",
"caRotateFailed": "فشل تدوير المرجع المصدق",
"caDelete": "أزل المرجع المصدق",
"caDeleteConfirm": "هل تريد إزالة المرجع المصدق؟",
"caDeleted": "تمت إزالة المرجع المصدق",
"caDeleteFailed": "فشل إزالة المرجع المصدق",
"caValidityLabel": "صلاحية الشهادة (بالأيام)",
"issueCert": "شهادة",
"issueCertTooltip": "إصدار شهادة SSH لهذا المفتاح، موقعة من المرجع المصدق الخاص بك",
"certIssued": "تم إصدار الشهادة وتنزيلها",
"certIssueFailed": "فشل إصدار الشهادة"
},
"nav": {
"termixId": "معرف"
},
"credentials": {
"idBadge": "بطاقة تعريف"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Термикс ИД",
"loadFailed": "Зареждането на Termix ID не бе успешно",
"claimTitle": "Заявете своя Termix ID",
"claimIntro": "Изберете уникален дескриптор. Вашите SSH публични ключове ще бъдат публикувани на публичен URL адрес, който можете да добавите към файла authorized_keys на всеки сървър.",
"handleLabel": "Дръжка",
"handlePlaceholder": "Алис",
"checking": "Проверка…",
"available": "Налично",
"taken": "Вече е взето",
"invalidHandle": "Само малки букви, цифри, - и _",
"descriptionLabel": "Описание (по избор)",
"descriptionPlaceholder": "Ключове за служебен лаптоп и телефон",
"create": "Създаване на Termix ID",
"created": "Termix ID е създаден",
"createFailed": "Неуспешно създаване на Termix ID",
"deleteConfirm": "Да изтриете ли Termix ID и всички публикувани ключове? Сървърите, които сте предоставили, ще пазят ключовете, докато не ги премахнете ръчно.",
"deleted": "Идентификаторът на Termix е изтрит",
"deleteFailed": "Неуспешно изтриване на Termix ID",
"copyFailed": "Копирането не бе успешно",
"resolverUrlLabel": "URL адрес на публичния резолвер",
"provisionLabel": "Осигуряване на сървър",
"publishTitle": "Публикуване на публичен ключ",
"generate": "Генериране",
"generateTooltip": "Генериране на двойка ключове Ed25519 — публикува публичния ключ и изтегля частния ключ на вашето устройство",
"saveToVault": "Запазване в идентификационни данни",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... потребител@хост",
"labelPlaceholder": "Етикет (по избор)",
"add": "Добавяне",
"importFromCredential": "Или импортирайте от съхранени идентификационни данни",
"keyPublished": "Ключът е публикуван",
"addKeyFailed": "Добавянето на ключ не бе успешно",
"generatedSaved": "Двойка ключове е генерирана и запазена в хранилището ви за идентификационни данни. Частният ключ също е изтеглен.",
"generatedOnly": "Генерирана двойка ключове — изтеглен частен ключ (показва се само веднъж).",
"generateFailed": "Генерирането на ключ не бе успешно",
"imported": "Ключът е импортиран от идентификационни данни",
"importFailed": "Импортирането на ключа не бе успешно",
"noKeys": "Все още няма публикувани публични ключове.",
"keysTitle": "Публикувани ключове",
"published": "Публикувано",
"hidden": "Скрито",
"keyRemoved": "Ключът е премахнат",
"removeKeyFailed": "Неуспешно премахване на ключа",
"updateKeyFailed": "Актуализирането на ключа не бе успешно",
"fromVault": "От трезора за идентификационни данни",
"linkedToTermixId": "Публикувано чрез Termix ID",
"selectCredential": "Изберете идентификационен номер...",
"import": "Внос",
"caTitle": "Сертифициращ орган",
"caIntro": "Доверете се на този CA на сървър и той ще приема всеки сертификат, който подпишете. Завъртете, за да отмените всичко наведнъж; сертификатите също изтичат сами.",
"caEnable": "Активиране на CA",
"caEnabled": "CA е активиран",
"caCreateFailed": "Активирането на CA не бе успешно",
"caPublicKeyLabel": "Публичен ключ на CA",
"caTrustLabel": "Доверие на сървър (изпълнява се като root)",
"caRotate": "Завъртане",
"caRotateConfirm": "Да се завърти ли сертифициращият орган? Всеки подписан от него сертификат ще спре да се приема и ще трябва да бъде преиздаден.",
"caRotated": "Ротация на CA — предишни сертификати са отменени",
"caRotateFailed": "Неуспешно завъртане на CA",
"caDelete": "Премахване на CA",
"caDeleteConfirm": "Да се премахне ли сертифициращият орган?",
"caDeleted": "CA премахнато",
"caDeleteFailed": "Премахването на CA не бе успешно",
"caValidityLabel": "Валидност на сертификата (дни)",
"issueCert": "Сертификат",
"issueCertTooltip": "Издайте SSH сертификат за този ключ, подписан от вашия CA",
"certIssued": "Сертификатът е издаден и изтеглен",
"certIssueFailed": "Издаването на сертификат не бе успешно"
},
"nav": {
"termixId": "Идентификационен номер"
},
"credentials": {
"idBadge": "Идентификационен номер"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "টার্মিক্স আইডি",
"loadFailed": "টার্মিক্স আইডি লোড করতে ব্যর্থ হয়েছে",
"claimTitle": "আপনার টার্মিক্স আইডি দাবি করুন",
"claimIntro": "একটি অনন্য হ্যান্ডেল বেছে নিন। আপনার SSH পাবলিক কীগুলো একটি পাবলিক URL-এ প্রকাশ করা হবে, যা আপনি যেকোনো সার্ভারের authorized_keys ফাইলে যোগ করতে পারবেন।",
"handleLabel": "হাতল",
"handlePlaceholder": "অ্যালিস",
"checking": "… পরীক্ষা করা হচ্ছে",
"available": "উপলব্ধ",
"taken": "ইতিমধ্যে নেওয়া হয়েছে",
"invalidHandle": "শুধুমাত্র ছোট হাতের অক্ষর, সংখ্যা, - এবং _",
"descriptionLabel": "বিবরণ (ঐচ্ছিক)",
"descriptionPlaceholder": "কাজের ল্যাপটপ ও ফোনের চাবি",
"create": "টার্মিক্স আইডি তৈরি করুন",
"created": "টার্মিক্স আইডি তৈরি করা হয়েছে",
"createFailed": "টার্মিক্স আইডি তৈরি করতে ব্যর্থ হয়েছে",
"deleteConfirm": "আপনার টার্মিক্স আইডি এবং সমস্ত প্রকাশিত কী মুছে ফেলবেন? আপনার প্রোভিশন করা সার্ভারগুলিতে কীগুলি থেকে যাবে যতক্ষণ না আপনি সেগুলি ম্যানুয়ালি সরিয়ে ফেলেন।",
"deleted": "টার্মিক্স আইডি মুছে ফেলা হয়েছে",
"deleteFailed": "Termix ID মুছে ফেলা সম্ভব হয়নি",
"copyFailed": "কপি ব্যর্থ হয়েছে",
"resolverUrlLabel": "পাবলিক রিজলভার ইউআরএল",
"provisionLabel": "একটি সার্ভার সরবরাহ করুন",
"publishTitle": "একটি পাবলিক কী প্রকাশ করুন",
"generate": "তৈরি করুন",
"generateTooltip": "একটি Ed25519 কী পেয়ার তৈরি করুন — এটি পাবলিক কী প্রকাশ করে এবং আপনার ডিভাইসে প্রাইভেট কী ডাউনলোড করে।",
"saveToVault": "পরিচয়পত্রে সংরক্ষণ করুন",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "লেবেল (ঐচ্ছিক)",
"add": "যোগ করুন",
"importFromCredential": "অথবা সংরক্ষিত পরিচয়পত্র থেকে আমদানি করুন",
"keyPublished": "মূল প্রকাশিত",
"addKeyFailed": "কী যোগ করতে ব্যর্থ হয়েছে",
"generatedSaved": "কী পেয়ার তৈরি করে আপনার ক্রেডেনশিয়ালস ভল্টে সংরক্ষণ করা হয়েছে। প্রাইভেট কী-টিও ডাউনলোড করা হয়েছে।",
"generatedOnly": "কী পেয়ার তৈরি হয়েছে — প্রাইভেট কী ডাউনলোড করা হয়েছে (শুধুমাত্র একবার দেখানো হবে)।",
"generateFailed": "কী তৈরি করতে ব্যর্থ হয়েছে",
"imported": "ক্রেডেনশিয়াল থেকে আমদানি করা কী",
"importFailed": "কী আমদানি করতে ব্যর্থ হয়েছে",
"noKeys": "এখনো কোনো পাবলিক কী প্রকাশ করা হয়নি।",
"keysTitle": "প্রকাশিত চাবিগুলি",
"published": "প্রকাশিত",
"hidden": "লুকানো",
"keyRemoved": "চাবি সরানো হয়েছে",
"removeKeyFailed": "চাবি সরাতে ব্যর্থ হয়েছে",
"updateKeyFailed": "কী আপডেট করতে ব্যর্থ হয়েছে",
"fromVault": "ক্রেডেনশিয়াল ভল্ট থেকে",
"linkedToTermixId": "টারমিক্স আইডি-এর মাধ্যমে প্রকাশিত",
"selectCredential": "একটি শংসাপত্র নির্বাচন করুন...",
"import": "আমদানি",
"caTitle": "শংসাপত্র কর্তৃপক্ষ",
"caIntro": "সার্ভারে এই CA-কে বিশ্বাস করলে, এটি আপনার স্বাক্ষরিত যেকোনো সার্টিফিকেট গ্রহণ করে। একবারে সবকিছু বাতিল করতে পর্যায়ক্রমে পরিবর্তন করুন; সার্টিফিকেটগুলো আপনাআপনি মেয়াদোত্তীর্ণও হয়ে যায়।",
"caEnable": "CA সক্ষম করুন",
"caEnabled": "CA সক্ষম",
"caCreateFailed": "CA সক্রিয় করতে ব্যর্থ হয়েছে",
"caPublicKeyLabel": "CA পাবলিক কী",
"caTrustLabel": "সার্ভারে বিশ্বাস (রুট হিসেবে চালান)",
"caRotate": "ঘোরান",
"caRotateConfirm": "CA পরিবর্তন করবেন? এর দ্বারা স্বাক্ষরিত প্রতিটি সার্টিফিকেট আর গ্রহণ করা হবে না এবং পুনরায় ইস্যু করতে হবে।",
"caRotated": "CA পরিবর্তিত হয়েছে — পূর্ববর্তী সার্টিফিকেটগুলো বাতিল করা হয়েছে",
"caRotateFailed": "CA ঘোরানো ব্যর্থ হয়েছে",
"caDelete": "CA অপসারণ করুন",
"caDeleteConfirm": "সার্টিফিকেট কর্তৃপক্ষকে অপসারণ করবেন?",
"caDeleted": "CA অপসারণ করেছে",
"caDeleteFailed": "CA অপসারণ করতে ব্যর্থ হয়েছে",
"caValidityLabel": "সার্টিফিকেটের বৈধতা (দিন)",
"issueCert": "সার্টিফিকেট",
"issueCertTooltip": "এই কী-টির জন্য আপনার CA দ্বারা স্বাক্ষরিত একটি SSH সার্টিফিকেট ইস্যু করুন।",
"certIssued": "সার্টিফিকেট ইস্যু এবং ডাউনলোড করা হয়েছে",
"certIssueFailed": "সার্টিফিকেট ইস্যু করতে ব্যর্থ হয়েছে"
},
"nav": {
"termixId": "আইডি"
},
"credentials": {
"idBadge": "আইডি"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "ID de Termix",
"loadFailed": "No s'ha pogut carregar l'ID de Termix.",
"claimTitle": "Reclama el teu ID de Termix",
"claimIntro": "Trieu un identificador únic. Les vostres claus públiques SSH es publicaran en una URL pública que podeu afegir al fitxer authorized_keys de qualsevol servidor.",
"handleLabel": "Maneta",
"handlePlaceholder": "Alícia",
"checking": "Comprovació de…",
"available": "Disponible",
"taken": "Ja ocupat/da",
"invalidHandle": "Només lletres minúscules, números, - i _",
"descriptionLabel": "Descripció (opcional)",
"descriptionPlaceholder": "Claus de portàtil i telèfon de la feina",
"create": "Crea un ID de Termix",
"created": "ID de Termix creat",
"createFailed": "No s'ha pogut crear l'ID de Termix.",
"deleteConfirm": "Voleu suprimir el vostre ID de Termix i totes les claus publicades? Els servidors que heu aprovisionat conservaran les claus fins que les elimineu manualment.",
"deleted": "ID de Termix suprimit",
"deleteFailed": "No s'ha pogut suprimir l'ID de Termix.",
"copyFailed": "S'ha produït un error de còpia.",
"resolverUrlLabel": "URL de resolució pública",
"provisionLabel": "Provisionar un servidor",
"publishTitle": "Publicar una clau pública",
"generate": "Generar",
"generateTooltip": "Genera un parell de claus Ed25519: publica la clau pública i descarrega la clau privada al dispositiu.",
"saveToVault": "Desa a les credencials",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... usuari@amfitrió",
"labelPlaceholder": "Etiqueta (opcional)",
"add": "Afegeix",
"importFromCredential": "O importar des d'una credencial emmagatzemada",
"keyPublished": "Clau publicada",
"addKeyFailed": "No s'ha pogut afegir la clau",
"generatedSaved": "Parell de claus generat i desat al vostre magatzem de credencials. La clau privada també s'ha descarregat.",
"generatedOnly": "Parell de claus generat: clau privada descarregada (només es mostra una vegada).",
"generateFailed": "No s'ha pogut generar la clau",
"imported": "Clau importada de la credencial",
"importFailed": "No s'ha pogut importar la clau",
"noKeys": "Encara no s'han publicat cap clau pública.",
"keysTitle": "Claus publicades",
"published": "Publicat",
"hidden": "Ocult",
"keyRemoved": "Clau retirada",
"removeKeyFailed": "No s'ha pogut treure la clau",
"updateKeyFailed": "No s'ha pogut actualitzar la clau",
"fromVault": "Des de la caixa forta de credencials",
"linkedToTermixId": "Publicat a través de l'ID de Termix",
"selectCredential": "Selecciona una credencial...",
"import": "Importa",
"caTitle": "Autoritat de certificació",
"caIntro": "Confia en aquesta CA en un servidor i acceptarà qualsevol certificat que signeu. Gireu per revocar-ho tot alhora; els certificats també caduquen sols.",
"caEnable": "Habilita CA",
"caEnabled": "CA habilitat",
"caCreateFailed": "No s'ha pogut habilitar l'entitat de certificació",
"caPublicKeyLabel": "Clau pública de CA",
"caTrustLabel": "Confiança en un servidor (executat com a root)",
"caRotate": "Girar",
"caRotateConfirm": "Voleu rotar l'entitat de certificació? Tots els certificats que hagi signat deixaran de ser acceptats i s'hauran de tornar a emetre.",
"caRotated": "CA rotada: certificats anteriors revocats",
"caRotateFailed": "No s'ha pogut rotar CA",
"caDelete": "Eliminar CA",
"caDeleteConfirm": "Voleu eliminar l'autoritat de certificació?",
"caDeleted": "CA eliminada",
"caDeleteFailed": "No s'ha pogut eliminar l'entitat de certificació",
"caValidityLabel": "Validesa del certificat (dies)",
"issueCert": "Certificat",
"issueCertTooltip": "Emet un certificat SSH per a aquesta clau, signat per la teva CA",
"certIssued": "Certificat emès i descarregat",
"certIssueFailed": "No s'ha pogut emetre el certificat"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "ID Termixu",
"loadFailed": "Nepodařilo se načíst ID Termixu",
"claimTitle": "Získejte své Termix ID",
"claimIntro": "Vyberte jedinečný handle. Vaše veřejné klíče SSH budou zveřejněny na veřejné URL adrese, kterou můžete přidat do souboru authorized_keys libovolného serveru.",
"handleLabel": "Zacházet s",
"handlePlaceholder": "Alice",
"checking": "Kontrola…",
"available": "K dispozici",
"taken": "Již obsazeno",
"invalidHandle": "Pouze malá písmena, číslice, - a _",
"descriptionLabel": "Popis (volitelné)",
"descriptionPlaceholder": "Klíče k pracovnímu notebooku a telefonu",
"create": "Vytvořit ID Termixu",
"created": "ID Termixu vytvořeno",
"createFailed": "Nepodařilo se vytvořit ID Termixu",
"deleteConfirm": "Smazat své Termix ID a všechny publikované klíče? Servery, které jste zřídili, si klíče uchovají, dokud je ručně neodstraníte.",
"deleted": "ID Termixu smazáno",
"deleteFailed": "Nepodařilo se smazat ID Termixu",
"copyFailed": "Kopírování se nezdařilo",
"resolverUrlLabel": "URL veřejného resolveru",
"provisionLabel": "Zřízení serveru",
"publishTitle": "Publikovat veřejný klíč",
"generate": "Generovat",
"generateTooltip": "Generování páru klíčů Ed25519 – publikuje veřejný klíč a stáhne soukromý klíč do vašeho zařízení",
"saveToVault": "Uložit do přihlašovacích údajů",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... uživatel@hostitel",
"labelPlaceholder": "Štítek (volitelné)",
"add": "Přidat",
"importFromCredential": "Nebo importovat z uložených přihlašovacích údajů",
"keyPublished": "Klíč zveřejněn",
"addKeyFailed": "Přidání klíče se nezdařilo",
"generatedSaved": "Pár klíčů byl vygenerován a uložen do úložiště vašich přihlašovacích údajů. Soukromý klíč byl také stažen.",
"generatedOnly": "Vygenerovaný pár klíčů — stažený soukromý klíč (zobrazí se pouze jednou).",
"generateFailed": "Nepodařilo se vygenerovat klíč",
"imported": "Klíč importován z přihlašovacích údajů",
"importFailed": "Import klíče se nezdařilo",
"noKeys": "Zatím nebyly zveřejněny žádné veřejné klíče.",
"keysTitle": "Publikované klíče",
"published": "Publikováno",
"hidden": "Skrytý",
"keyRemoved": "Klíč vyjmut",
"removeKeyFailed": "Nepodařilo se vyjmout klíč",
"updateKeyFailed": "Nepodařilo se aktualizovat klíč",
"fromVault": "Z úložiště přihlašovacích údajů",
"linkedToTermixId": "Publikováno přes Termix ID",
"selectCredential": "Vyberte pověření...",
"import": "Importovat",
"caTitle": "Certifikační autorita",
"caIntro": "Důvěřujte této certifikační autoritě na serveru a ta přijme jakýkoli certifikát, který podepíšete. Otočením zrušíte vše najednou; certifikáty také samy vyprší.",
"caEnable": "Povolit certifikační autoritu (CA)",
"caEnabled": "CA povolena",
"caCreateFailed": "Nepodařilo se povolit certifikační autoritu (CA).",
"caPublicKeyLabel": "Veřejný klíč CA",
"caTrustLabel": "Důvěra na serveru (spuštěno jako root)",
"caRotate": "Střídat",
"caRotateConfirm": "Rotovat certifikační autoritu? Každý certifikát, který podepsala, přestane být akceptován a bude nutné jej znovu vydat.",
"caRotated": "CA rotována – předchozí certifikáty zrušeny",
"caRotateFailed": "Nepodařilo se otočit CA",
"caDelete": "Odebrat certifikační autoritu (CA)",
"caDeleteConfirm": "Odebrat certifikační autoritu?",
"caDeleted": "CA odstraněna",
"caDeleteFailed": "Nepodařilo se odebrat certifikační autoritu (CA).",
"caValidityLabel": "Platnost certifikátu (dny)",
"issueCert": "Osvědčení",
"issueCertTooltip": "Vystavte pro tento klíč SSH certifikát podepsaný vaší certifikační autoritou.",
"certIssued": "Certifikát vydán a stažen",
"certIssueFailed": "Vydání certifikátu se nezdařilo"
},
"nav": {
"termixId": "Průkaz totožnosti"
},
"credentials": {
"idBadge": "Průkaz totožnosti"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix-ID",
"loadFailed": "Kunne ikke indlæse Termix-ID'et",
"claimTitle": "Få dit Termix-ID",
"claimIntro": "Vælg et unikt handle. Dine offentlige SSH-nøgler vil blive offentliggjort på en offentlig URL, som du kan tilføje til enhver servers authorized_keys-fil.",
"handleLabel": "Håndtag",
"handlePlaceholder": "Alice",
"checking": "Tjekker…",
"available": "Tilgængelig",
"taken": "Allerede taget",
"invalidHandle": "Kun små bogstaver, tal, - og _",
"descriptionLabel": "Beskrivelse (valgfrit)",
"descriptionPlaceholder": "Arbejdslaptop og telefonnøgler",
"create": "Opret Termix-ID",
"created": "Termix-ID oprettet",
"createFailed": "Kunne ikke oprette Termix ID",
"deleteConfirm": "Vil du slette dit Termix-ID og alle publicerede nøgler? Servere, du har provisioneret, vil beholde nøglerne, indtil du fjerner dem manuelt.",
"deleted": "Termix-ID slettet",
"deleteFailed": "Kunne ikke slette Termix ID",
"copyFailed": "Kopiering mislykkedes",
"resolverUrlLabel": "Offentlig resolver-URL",
"provisionLabel": "Klargør en server",
"publishTitle": "Udgiv en offentlig nøgle",
"generate": "Frembringe",
"generateTooltip": "Generer et Ed25519-nøglepar — publicerer den offentlige nøgle og downloader den private nøgle til din enhed",
"saveToVault": "Gem til legitimationsoplysninger",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... bruger@vært",
"labelPlaceholder": "Etiket (valgfrit)",
"add": "Tilføje",
"importFromCredential": "Eller importer fra en gemt legitimationsoplysninger",
"keyPublished": "Nøgle udgivet",
"addKeyFailed": "Kunne ikke tilføje nøgle",
"generatedSaved": "Nøglepar genereret og gemt i din login-boks. Privat nøgle er også downloadet.",
"generatedOnly": "Nøglepar genereret — privat nøgle downloadet (vises kun én gang).",
"generateFailed": "Kunne ikke generere nøgle",
"imported": "Nøgle importeret fra legitimationsoplysninger",
"importFailed": "Nøglen kunne ikke importeres",
"noKeys": "Ingen offentlige nøgler er udgivet endnu.",
"keysTitle": "Udgivne nøgler",
"published": "Udgivet",
"hidden": "Skjult",
"keyRemoved": "Nøglen er fjernet",
"removeKeyFailed": "Kunne ikke fjerne nøglen",
"updateKeyFailed": "Nøglen kunne ikke opdateres",
"fromVault": "Fra legitimationsoplysninger-arkiv",
"linkedToTermixId": "Udgivet via Termix ID",
"selectCredential": "Vælg en legitimationsoplysninger...",
"import": "Importere",
"caTitle": "Certifikatmyndighed",
"caIntro": "Stol på denne CA på en server, og den accepterer ethvert certifikat, du underskriver. Roter for at tilbagekalde alt på én gang; certifikater udløber også af sig selv.",
"caEnable": "Aktivér CA",
"caEnabled": "CA aktiveret",
"caCreateFailed": "Kunne ikke aktivere CA",
"caPublicKeyLabel": "CA's offentlige nøgle",
"caTrustLabel": "Tillid til en server (kør som root)",
"caRotate": "Rotere",
"caRotateConfirm": "Roter CA'en? Alle certifikater, den har underskrevet, vil ikke længere blive accepteret og skal genudstedes.",
"caRotated": "CA roteret — tidligere certifikater tilbagekaldt",
"caRotateFailed": "Kunne ikke rotere CA",
"caDelete": "Fjern CA",
"caDeleteConfirm": "Fjern certifikatmyndigheden?",
"caDeleted": "CA fjernet",
"caDeleteFailed": "Kunne ikke fjerne CA",
"caValidityLabel": "Certifikatgyldighed (dage)",
"issueCert": "Certifikat",
"issueCertTooltip": "Udsted et SSH-certifikat til denne nøgle, underskrevet af din CA",
"certIssued": "Certifikat udstedt og downloadet",
"certIssueFailed": "Kunne ikke udstede certifikat"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Fehler beim Laden der Termix-ID",
"claimTitle": "Termix-ID beanspruchen",
"claimIntro": "Wählen Sie einen eindeutigen Benutzernamen. Ihre öffentlichen SSH-Schlüssel werden unter einer öffentlichen URL veröffentlicht, die Sie in die authorized_keys-Datei eines beliebigen Servers einfügen können.",
"handleLabel": "Benutzername",
"handlePlaceholder": "alice",
"checking": "Wird überprüft…",
"available": "Verfügbar",
"taken": "Bereits vergeben",
"invalidHandle": "Nur Kleinbuchstaben, Zahlen, - und _ erlaubt",
"descriptionLabel": "Beschreibung (optional)",
"descriptionPlaceholder": "Arbeits-Laptop & Telefonschlüssel",
"create": "Termix-ID erstellen",
"created": "Termix-ID erstellt",
"createFailed": "Fehler beim Erstellen der Termix-ID",
"deleteConfirm": "Termix-ID und alle veröffentlichten Schlüssel löschen? Bereits bereitgestellte Server behalten die Schlüssel, bis Sie sie manuell entfernen.",
"deleted": "Termix-ID gelöscht",
"deleteFailed": "Fehler beim Löschen der Termix-ID",
"copyFailed": "Kopieren fehlgeschlagen",
"resolverUrlLabel": "Öffentliche Resolver-URL",
"provisionLabel": "Server bereitstellen",
"publishTitle": "Öffentlichen Schlüssel veröffentlichen",
"generate": "Generieren",
"generateTooltip": "Ed25519-Schlüsselpaar generieren — veröffentlicht den öffentlichen Schlüssel und lädt den privaten Schlüssel auf Ihr Gerät herunter",
"saveToVault": "In Anmeldeinformationen speichern",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Bezeichnung (optional)",
"add": "Hinzufügen",
"importFromCredential": "Oder aus gespeicherten Anmeldeinformationen importieren",
"keyPublished": "Schlüssel veröffentlicht",
"addKeyFailed": "Fehler beim Hinzufügen des Schlüssels",
"generatedSaved": "Schlüsselpaar generiert und in Ihrem Anmeldeinformations-Tresor gespeichert. Privater Schlüssel ebenfalls heruntergeladen.",
"generatedOnly": "Schlüsselpaar generiert — privater Schlüssel heruntergeladen (wird nur einmal angezeigt).",
"generateFailed": "Fehler beim Generieren des Schlüssels",
"imported": "Schlüssel aus Anmeldeinformationen importiert",
"importFailed": "Schlüsselimport fehlgeschlagen",
"noKeys": "Noch keine öffentlichen Schlüssel veröffentlicht.",
"keysTitle": "Veröffentlichte Schlüssel",
"published": "Veröffentlicht",
"hidden": "Ausgeblendet",
"keyRemoved": "Schlüssel entfernt",
"removeKeyFailed": "Schlüsselentfernung fehlgeschlagen",
"updateKeyFailed": "Schlüsselaktualisierung fehlgeschlagen",
"fromVault": "Aus dem Anmeldedaten-Tresor",
"linkedToTermixId": "Veröffentlicht über Termix ID",
"selectCredential": "Anmeldedaten auswählen...",
"import": "Importieren",
"caTitle": "Zertifizierungsstelle",
"caIntro": "Vertrauen Sie dieser CA auf einem Server, und sie akzeptiert jedes von Ihnen signierte Zertifikat. Durch Rotieren widerrufen Sie alles auf einmal; Zertifikate laufen auch automatisch ab.",
"caEnable": "CA aktivieren",
"caEnabled": "CA aktiviert",
"caCreateFailed": "Aktivierung der CA fehlgeschlagen",
"caPublicKeyLabel": "Öffentlicher Schlüssel der CA",
"caTrustLabel": "Vertrauen auf einem Server (als root ausführen)",
"caRotate": "Rotieren",
"caRotateConfirm": "CA rotieren? Alle von ihr signierten Zertifikate werden nicht mehr akzeptiert und müssen neu ausgestellt werden.",
"caRotated": "CA rotiert — vorherige Zertifikate widerrufen",
"caRotateFailed": "Rotieren der CA fehlgeschlagen",
"caDelete": "CA entfernen",
"caDeleteConfirm": "Zertifizierungsstelle entfernen?",
"caDeleted": "CA entfernt",
"caDeleteFailed": "Entfernen der CA fehlgeschlagen",
"caValidityLabel": "Zertifikatsgültigkeit (Tage)",
"issueCert": "Zertifikat",
"issueCertTooltip": "Ein SSH-Zertifikat für diesen Schlüssel ausstellen, signiert von Ihrer CA",
"certIssued": "Zertifikat ausgestellt und heruntergeladen",
"certIssueFailed": "Ausstellung des Zertifikats fehlgeschlagen"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "AUSWEIS"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Αναγνωριστικό Termix",
"loadFailed": "Αποτυχία φόρτωσης του αναγνωριστικού Termix",
"claimTitle": "Διεκδικήστε το αναγνωριστικό σας Termix",
"claimIntro": "Επιλέξτε μια μοναδική λαβή. Τα δημόσια κλειδιά SSH σας θα δημοσιευτούν σε μια δημόσια διεύθυνση URL που μπορείτε να προσθέσετε στο αρχείο authorized_keys οποιουδήποτε διακομιστή.",
"handleLabel": "Λαβή",
"handlePlaceholder": "Αλίκη",
"checking": "Έλεγχος…",
"available": "Διαθέσιμος",
"taken": "Ήδη καταγεγραμμένο",
"invalidHandle": "Μόνο πεζά γράμματα, αριθμοί και - και _",
"descriptionLabel": "Περιγραφή (προαιρετικό)",
"descriptionPlaceholder": "Κλειδιά φορητού υπολογιστή και τηλεφώνου εργασίας",
"create": "Δημιουργία αναγνωριστικού Termix",
"created": "Το αναγνωριστικό Termix δημιουργήθηκε",
"createFailed": "Αποτυχία δημιουργίας αναγνωριστικού Termix",
"deleteConfirm": "Να διαγραφεί το αναγνωριστικό Termix και όλα τα δημοσιευμένα κλειδιά; Οι διακομιστές που έχετε παράσχει θα διατηρήσουν τα κλειδιά μέχρι να τα καταργήσετε χειροκίνητα.",
"deleted": "Το αναγνωριστικό Termix διαγράφηκε",
"deleteFailed": "Αποτυχία διαγραφής του αναγνωριστικού Termix",
"copyFailed": "Η αντιγραφή απέτυχε.",
"resolverUrlLabel": "Δημόσια διεύθυνση URL αναλυτή",
"provisionLabel": "Παροχή διακομιστή",
"publishTitle": "Δημοσίευση δημόσιου κλειδιού",
"generate": "Παράγω",
"generateTooltip": "Δημιουργήστε ένα ζεύγος κλειδιών Ed25519 — δημοσιεύστε το δημόσιο κλειδί και κατεβάστε το ιδιωτικό κλειδί στη συσκευή σας",
"saveToVault": "Αποθήκευση σε διαπιστευτήρια",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... χρήστης@host",
"labelPlaceholder": "Ετικέτα (προαιρετικά)",
"add": "Προσθέτω",
"importFromCredential": "Ή εισαγωγή από αποθηκευμένα διαπιστευτήρια",
"keyPublished": "Κλειδί δημοσιεύτηκε",
"addKeyFailed": "Η προσθήκη κλειδιού απέτυχε",
"generatedSaved": "Δημιουργήθηκε και αποθηκεύτηκε ζεύγος κλειδιών στο αρχείο διαπιστευτηρίων σας. Επίσης, λήφθηκε το ιδιωτικό κλειδί.",
"generatedOnly": "Δημιουργήθηκε ζεύγος κλειδιών — λήφθηκε ιδιωτικό κλειδί (εμφανίζεται μόνο μία φορά).",
"generateFailed": "Η δημιουργία κλειδιού απέτυχε",
"imported": "Κλειδί εισήχθη από πιστοποιητικά",
"importFailed": "Αποτυχία εισαγωγής κλειδιού",
"noKeys": "Δεν έχουν δημοσιευτεί ακόμη δημόσια κλειδιά.",
"keysTitle": "Δημοσιευμένα κλειδιά",
"published": "Δημοσιεύτηκε",
"hidden": "Κεκρυμμένος",
"keyRemoved": "Το κλειδί αφαιρέθηκε",
"removeKeyFailed": "Αποτυχία αφαίρεσης κλειδιού",
"updateKeyFailed": "Η ενημέρωση του κλειδιού απέτυχε",
"fromVault": "Από το θησαυροφυλάκιο διαπιστευτηρίων",
"linkedToTermixId": "Δημοσιεύτηκε μέσω του Termix ID",
"selectCredential": "Επιλέξτε ένα πιστοποιητικό...",
"import": "Εισαγωγή",
"caTitle": "Αρχή έκδοσης πιστοποιητικών",
"caIntro": "Να εμπιστεύεστε αυτήν την CA σε έναν διακομιστή και αυτή θα αποδέχεται οποιοδήποτε πιστοποιητικό υπογράφετε. Περιστρέψτε για να ακυρώσετε τα πάντα ταυτόχρονα. Τα πιστοποιητικά λήγουν επίσης από μόνα τους.",
"caEnable": "Ενεργοποίηση CA",
"caEnabled": "Ενεργοποιημένο CA",
"caCreateFailed": "Αποτυχία ενεργοποίησης CA",
"caPublicKeyLabel": "Δημόσιο κλειδί CA",
"caTrustLabel": "Εμπιστοσύνη σε διακομιστή (εκτέλεση ως root)",
"caRotate": "Γυρίζω",
"caRotateConfirm": "Να εναλλάσσεται η CA; Κάθε πιστοποιητικό που έχει υπογράψει θα σταματήσει να γίνεται αποδεκτό και θα πρέπει να εκδοθεί εκ νέου.",
"caRotated": "Εναλλαγή CA — ανακλήθηκαν προηγούμενα πιστοποιητικά",
"caRotateFailed": "Αποτυχία εναλλαγής CA",
"caDelete": "Κατάργηση CA",
"caDeleteConfirm": "Κατάργηση της αρχής έκδοσης πιστοποιητικών;",
"caDeleted": "Η CA καταργήθηκε.",
"caDeleteFailed": "Αποτυχία κατάργησης CA",
"caValidityLabel": "Ισχύς πιστοποιητικού (ημέρες)",
"issueCert": "Πιστοποιητικό",
"issueCertTooltip": "Έκδοση πιστοποιητικού SSH για αυτό το κλειδί, υπογεγραμμένου από την CA σας",
"certIssued": "Έκδοση και λήψη πιστοποιητικού",
"certIssueFailed": "Αποτυχία έκδοσης πιστοποιητικού"
},
"nav": {
"termixId": "ταυτότητα"
},
"credentials": {
"idBadge": "ταυτότητα"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Error al cargar Termix ID",
"claimTitle": "Reclama tu Termix ID",
"claimIntro": "Elige un identificador único. Tus claves públicas SSH se publicarán en una URL pública que puedes añadir al archivo authorized_keys de cualquier servidor.",
"handleLabel": "Identificador",
"handlePlaceholder": "alice",
"checking": "Comprobando…",
"available": "Disponible",
"taken": "Ya está en uso",
"invalidHandle": "Solo letras minúsculas, números, - y _",
"descriptionLabel": "Descripción (opcional)",
"descriptionPlaceholder": "Claves del portátil de trabajo y del teléfono",
"create": "Crear Termix ID",
"created": "Termix ID creado",
"createFailed": "Error al crear Termix ID",
"deleteConfirm": "¿Eliminar tu Termix ID y todas las claves publicadas? Los servidores que aprovisionaste conservarán las claves hasta que las elimines manualmente.",
"deleted": "Termix ID eliminado",
"deleteFailed": "Error al eliminar Termix ID",
"copyFailed": "Error al copiar",
"resolverUrlLabel": "URL pública de resolución",
"provisionLabel": "Aprovisionar un servidor",
"publishTitle": "Publicar una clave pública",
"generate": "Generar",
"generateTooltip": "Generar un par de claves Ed25519: publica la clave pública y descarga la clave privada en tu dispositivo",
"saveToVault": "Guardar en credenciales",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Etiqueta (opcional)",
"add": "Añadir",
"importFromCredential": "O importar desde una credencial almacenada",
"keyPublished": "Clave publicada",
"addKeyFailed": "Error al añadir clave",
"generatedSaved": "Par de claves generado y guardado en tu almacén de credenciales. También se descargó la clave privada.",
"generatedOnly": "Par de claves generado: clave privada descargada (se muestra solo una vez).",
"generateFailed": "Error al generar clave",
"imported": "Clave importada desde la credencial",
"importFailed": "Error al importar la clave",
"noKeys": "Aún no se han publicado claves públicas.",
"keysTitle": "Claves publicadas",
"published": "Publicada",
"hidden": "Oculta",
"keyRemoved": "Clave eliminada",
"removeKeyFailed": "Error al eliminar la clave",
"updateKeyFailed": "Error al actualizar la clave",
"fromVault": "Del almacén de credenciales",
"linkedToTermixId": "Publicada mediante Termix ID",
"selectCredential": "Seleccionar una credencial...",
"import": "Importar",
"caTitle": "Autoridad de certificación",
"caIntro": "Confíe en esta CA en un servidor y aceptará cualquier certificado que firme. Rote para revocar todo a la vez; los certificados también caducan por sí solos.",
"caEnable": "Activar CA",
"caEnabled": "CA activada",
"caCreateFailed": "Error al activar la CA",
"caPublicKeyLabel": "Clave pública de la CA",
"caTrustLabel": "Confiar en un servidor (ejecutar como root)",
"caRotate": "Rotar",
"caRotateConfirm": "¿Rotar la CA? Todos los certificados que ha firmado dejarán de ser aceptados y deberán volver a emitirse.",
"caRotated": "CA rotada — certificados anteriores revocados",
"caRotateFailed": "Error al rotar la CA",
"caDelete": "Eliminar CA",
"caDeleteConfirm": "¿Eliminar la autoridad de certificación?",
"caDeleted": "CA eliminada",
"caDeleteFailed": "Error al eliminar la CA",
"caValidityLabel": "Validez del certificado (días)",
"issueCert": "Certificado",
"issueCertTooltip": "Emitir un certificado SSH para esta clave, firmado por su CA",
"certIssued": "Certificado emitido y descargado",
"certIssueFailed": "Error al emitir el certificado"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "IDENTIFICACIÓN"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix-tunnus",
"loadFailed": "Termix-tunnuksen lataaminen epäonnistui",
"claimTitle": "Lunasta Termix-tunnuksesi",
"claimIntro": "Valitse yksilöllinen käyttäjätunnus. SSH-julkiset avaimesi julkaistaan julkisessa URL-osoitteessa, jonka voit lisätä minkä tahansa palvelimen authorized_keys-tiedostoon.",
"handleLabel": "Kahva",
"handlePlaceholder": "Alice",
"checking": "Tarkistetaan…",
"available": "Saatavilla",
"taken": "Jo otettu",
"invalidHandle": "Vain pienet kirjaimet, numerot - ja _",
"descriptionLabel": "Kuvaus (valinnainen)",
"descriptionPlaceholder": "Työkannettava ja puhelimen avaimet",
"create": "Luo Termix-tunnus",
"created": "Termix-tunnus luotu",
"createFailed": "Termix-tunnuksen luominen epäonnistui",
"deleteConfirm": "Poistetaanko Termix-tunnuksesi ja kaikki julkaistut avaimet? Määrittämäsi palvelimet säilyttävät avaimet, kunnes poistat ne manuaalisesti.",
"deleted": "Termix-tunnus poistettu",
"deleteFailed": "Termix-tunnuksen poistaminen epäonnistui",
"copyFailed": "Kopiointi epäonnistui",
"resolverUrlLabel": "Julkisen ratkaisijan URL-osoite",
"provisionLabel": "Palvelimen valmistelu",
"publishTitle": "Julkaise julkinen avain",
"generate": "Luo",
"generateTooltip": "Luo Ed25519-avainpari — julkaisee julkisen avaimen ja lataa yksityisen avaimen laitteellesi",
"saveToVault": "Tallenna tunnistetietoihin",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... käyttäjä@isäntä",
"labelPlaceholder": "Tarra (valinnainen)",
"add": "Lisätä",
"importFromCredential": "Tai tuo tallennetuista tunnistetiedoista",
"keyPublished": "Avain julkaistu",
"addKeyFailed": "Avaimen lisääminen epäonnistui",
"generatedSaved": "Avainpari luotiin ja tallennettiin tunnistetietoholviisi. Myös yksityinen avain ladattiin.",
"generatedOnly": "Avainpari luotu — yksityinen avain ladattu (näytetään vain kerran).",
"generateFailed": "Avaimen luominen epäonnistui",
"imported": "Avain tuotu tunnistetiedoista",
"importFailed": "Avaimen tuonti epäonnistui",
"noKeys": "Julkisia avaimia ei ole vielä julkaistu.",
"keysTitle": "Julkaistut avaimet",
"published": "Julkaistu",
"hidden": "Piilotettu",
"keyRemoved": "Avain poistettu",
"removeKeyFailed": "Avaimen poistaminen epäonnistui",
"updateKeyFailed": "Avaimen päivittäminen epäonnistui",
"fromVault": "Tunnistetietosäilöstä",
"linkedToTermixId": "Julkaistu Termix ID:n kautta",
"selectCredential": "Valitse tunnistetiedot...",
"import": "Tuoda",
"caTitle": "Varmentaja",
"caIntro": "Luota tähän palvelimella olevaan varmenteiden myöntäjään, niin se hyväksyy kaikki allekirjoittamasi varmenteet. Kierrätä peruuttaaksesi kaiken kerralla; varmenteet myös vanhenevat itsestään.",
"caEnable": "Ota käyttöön CA",
"caEnabled": "CA käytössä",
"caCreateFailed": "CA:n käyttöönotto epäonnistui",
"caPublicKeyLabel": "CA:n julkinen avain",
"caTrustLabel": "Luota palvelimeen (aja root-käyttäjänä)",
"caRotate": "Kiertää",
"caRotateConfirm": "Kierrätetäänkö varmentajaa? Kaikki sen allekirjoittamat varmenteet lakkaavat olemasta hyväksyttäviä ja ne on myönnettävä uudelleen.",
"caRotated": "Varmentaja vaihdettu – aiemmat varmenteet peruutettu",
"caRotateFailed": "CA:n vaihtaminen epäonnistui",
"caDelete": "Poista CA",
"caDeleteConfirm": "Poistetaanko varmenteen myöntäjä?",
"caDeleted": "CA poistettu",
"caDeleteFailed": "CA:n poistaminen epäonnistui",
"caValidityLabel": "Sertifikaatin voimassaolo (päivää)",
"issueCert": "Todistus",
"issueCertTooltip": "Myönnä tälle avaimelle SSH-varmenne, jonka CA on allekirjoittanut",
"certIssued": "Todistus myönnetty ja ladattu",
"certIssueFailed": "Varmenteen myöntäminen epäonnistui"
},
"nav": {
"termixId": "Henkilöllisyystodistus"
},
"credentials": {
"idBadge": "Henkilöllisyystodistus"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Échec du chargement de Termix ID",
"claimTitle": "Obtenez votre Termix ID",
"claimIntro": "Choisissez un identifiant unique. Vos clés publiques SSH seront publiées à une URL publique que vous pourrez ajouter au fichier authorized_keys de n'importe quel serveur.",
"handleLabel": "Identifiant",
"handlePlaceholder": "alice",
"checking": "Vérification…",
"available": "Disponible",
"taken": "Déjà pris",
"invalidHandle": "Lettres minuscules, chiffres, - et _ uniquement",
"descriptionLabel": "Description (facultative)",
"descriptionPlaceholder": "Clés pour ordinateur portable pro et téléphone",
"create": "Créer un Termix ID",
"created": "Termix ID créé",
"createFailed": "Échec de la création de Termix ID",
"deleteConfirm": "Supprimer votre Termix ID et toutes les clés publiées ? Les serveurs que vous avez provisionnés conserveront les clés jusqu'à ce que vous les supprimiez manuellement.",
"deleted": "Termix ID supprimé",
"deleteFailed": "Échec de la suppression de Termix ID",
"copyFailed": "Échec de la copie",
"resolverUrlLabel": "URL de résolution publique",
"provisionLabel": "Provisionner un serveur",
"publishTitle": "Publier une clé publique",
"generate": "Générer",
"generateTooltip": "Générer une paire de clés Ed25519 — publie la clé publique et télécharge la clé privée sur votre appareil",
"saveToVault": "Enregistrer dans les identifiants",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Libellé (facultatif)",
"add": "Ajouter",
"importFromCredential": "Ou importer depuis un identifiant stocké",
"keyPublished": "Clé publiée",
"addKeyFailed": "Échec de l'ajout de la clé",
"generatedSaved": "Paire de clés générée et enregistrée dans votre coffre-fort d'identifiants. Clé privée également téléchargée.",
"generatedOnly": "Paire de clés générée — clé privée téléchargée (affichée une seule fois).",
"generateFailed": "Échec de la génération de la clé",
"imported": "Clé importée depuis l'identifiant",
"importFailed": "Échec de l'importation de la clé",
"noKeys": "Aucune clé publique publiée pour le moment.",
"keysTitle": "Clés publiées",
"published": "Publiée",
"hidden": "Masquée",
"keyRemoved": "Clé supprimée",
"removeKeyFailed": "Échec de la suppression de la clé",
"updateKeyFailed": "Échec de la mise à jour de la clé",
"fromVault": "Du coffre-fort des identifiants",
"linkedToTermixId": "Publiée via Termix ID",
"selectCredential": "Sélectionner un identifiant...",
"import": "Importer",
"caTitle": "Autorité de certification",
"caIntro": "Faites confiance à cette AC sur un serveur et il acceptera tout certificat que vous signez. Renouvelez pour tout révoquer d'un coup ; les certificats expirent également d'eux-mêmes.",
"caEnable": "Activer l'AC",
"caEnabled": "AC activée",
"caCreateFailed": "Échec de l'activation de l'AC",
"caPublicKeyLabel": "Clé publique de l'AC",
"caTrustLabel": "Confiance sur un serveur (exécuter en tant que root)",
"caRotate": "Renouveler",
"caRotateConfirm": "Renouveler l'AC ? Tous les certificats qu'elle a signés ne seront plus acceptés et devront être réémis.",
"caRotated": "AC renouvelée — certificats précédents révoqués",
"caRotateFailed": "Échec du renouvellement de l'AC",
"caDelete": "Supprimer l'AC",
"caDeleteConfirm": "Supprimer l'autorité de certification ?",
"caDeleted": "AC supprimée",
"caDeleteFailed": "Échec de la suppression de l'AC",
"caValidityLabel": "Validité du certificat (jours)",
"issueCert": "Certificat",
"issueCertTooltip": "Émettre un certificat SSH pour cette clé, signé par votre AC",
"certIssued": "Certificat émis et téléchargé",
"certIssueFailed": "Échec de l'émission du certificat"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "IDENTIFIANT"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "מזהה טרמיקס",
"loadFailed": "נכשלה טעינת מזהה Termix",
"claimTitle": "תבע את מזהה ה-Termix שלך",
"claimIntro": "בחר שם משתמש ייחודי. המפתחות הציבוריים של SSH שלך יפורסמו בכתובת URL ציבורית שתוכל להוסיף לקובץ authorized_keys של כל שרת.",
"handleLabel": "יָדִית",
"handlePlaceholder": "אליס",
"checking": "בודק…",
"available": "זָמִין",
"taken": "כבר תפוס",
"invalidHandle": "אותיות קטנות, מספרים, - ו-_ בלבד",
"descriptionLabel": "תיאור (אופציונלי)",
"descriptionPlaceholder": "מפתחות למחשב נייד ולטלפון עבודה",
"create": "צור מזהה טרמיקס",
"created": "מזהה טרמיקס נוצר",
"createFailed": "נכשלה יצירת מזהה Termix",
"deleteConfirm": "למחוק את מזהה ה-Termix שלך ואת כל המפתחות שפורסמו? השרתים שהקמת ישמרו את המפתחות עד שתסיר אותם ידנית.",
"deleted": "מזהה טרמיקס נמחק",
"deleteFailed": "נכשלה מחיקת מזהה Termix",
"copyFailed": "ההעתקה נכשלה",
"resolverUrlLabel": "כתובת URL ציבורית לפתרון",
"provisionLabel": "הקצאת שרת",
"publishTitle": "פרסום מפתח ציבורי",
"generate": "לִיצוֹר",
"generateTooltip": "צור זוג מפתחות Ed25519 - מפרסם את המפתח הציבורי ומוריד את המפתח הפרטי למכשיר שלך",
"saveToVault": "שמור אל אישורים",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... משתמש@מארח",
"labelPlaceholder": "תווית (אופציונלי)",
"add": "לְהוֹסִיף",
"importFromCredential": "או ייבוא מאישור מאוחסן",
"keyPublished": "מפתח פורסם",
"addKeyFailed": "הוספת המפתח נכשלה",
"generatedSaved": "זוג המפתחות נוצר ונשמר בכספת האישורים שלך. המפתח הפרטי גם הורד.",
"generatedOnly": "נוצר זוג מפתחות - הורד מפתח פרטי (מוצג פעם אחת בלבד).",
"generateFailed": "יצירת המפתח נכשלה",
"imported": "מפתח יובא מהאישור",
"importFailed": "ייבוא המפתח נכשל",
"noKeys": "עדיין לא פורסמו מפתחות ציבוריים.",
"keysTitle": "מפתחות שפורסמו",
"published": "פורסם",
"hidden": "מוּסתָר",
"keyRemoved": "המפתח הוסר",
"removeKeyFailed": "הסרת המפתח נכשלה",
"updateKeyFailed": "עדכון המפתח נכשל",
"fromVault": "מכספת האישורים",
"linkedToTermixId": "פורסם באמצעות Termix ID",
"selectCredential": "בחר אישור...",
"import": "יְבוּא",
"caTitle": "רשות אישורים",
"caIntro": "בטח באישור CA זה בשרת והוא יקבל כל אישור שאתה חותם עליו. סובב כדי לבטל הכל בבת אחת; אישורים גם פגים מעצמם.",
"caEnable": "הפעלת CA",
"caEnabled": "אישור אישורים (CA) מופעל",
"caCreateFailed": "נכשל בהפעלת CA",
"caPublicKeyLabel": "מפתח ציבורי של CA",
"caTrustLabel": "אמון בשרת (הפעלה כ-root)",
"caRotate": "לְסוֹבֵב",
"caRotateConfirm": "לסובב את רשות האישור? כל אישור שהיא חתמה עליו יפסיק להתקבל ויהיה צורך להנפיק אותו מחדש.",
"caRotated": "רשות אישורים (CA) סיבובית - אישורים קודמים בוטלו",
"caRotateFailed": "נכשל בסבב CA",
"caDelete": "הסר את CA",
"caDeleteConfirm": "להסיר את רשות האישורים?",
"caDeleted": "רשות האישור הוסרה",
"caDeleteFailed": "נכשל בהסרת CA",
"caValidityLabel": "תוקף האישור (ימים)",
"issueCert": "תְעוּדָה",
"issueCertTooltip": "הנפק אישור SSH עבור מפתח זה, חתום על ידי רשות האישור שלך",
"certIssued": "תעודה הונפקה והורדה",
"certIssueFailed": "הנפקת האישור נכשלה"
},
"nav": {
"termixId": "תְעוּדַת זֶהוּת"
},
"credentials": {
"idBadge": "תְעוּדַת זֶהוּת"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Termix ID लोड करने में विफल",
"claimTitle": "अपना Termix ID क्लेम करें",
"claimIntro": "एक अनूठा हैंडल चुनें। आपकी SSH सार्वजनिक कुंजियाँ एक सार्वजनिक URL पर प्रकाशित की जाएंगी जिसे आप किसी भी सर्वर की authorized_keys फ़ाइल में जोड़ सकते हैं।",
"handleLabel": "हैंडल",
"handlePlaceholder": "alice",
"checking": "जाँच हो रही है…",
"available": "उपलब्ध",
"taken": "पहले से उपयोग में",
"invalidHandle": "केवल छोटे अक्षर, अंक, - और _",
"descriptionLabel": "विवरण (वैकल्पिक)",
"descriptionPlaceholder": "कार्य लैपटॉप और फ़ोन कुंजियाँ",
"create": "Termix ID बनाएँ",
"created": "Termix ID बन गई",
"createFailed": "Termix ID बनाने में विफल",
"deleteConfirm": "अपनी Termix ID और सभी प्रकाशित कुंजियाँ हटाएँ? जो सर्वर आपने प्रोविज़न किए हैं वे कुंजियाँ तब तक रखेंगे जब तक आप उन्हें मैन्युअल रूप से नहीं हटाते।",
"deleted": "Termix ID हटाई गई",
"deleteFailed": "Termix ID हटाने में विफल",
"copyFailed": "कॉपी करने में विफल",
"resolverUrlLabel": "सार्वजनिक रिज़ॉल्वर URL",
"provisionLabel": "सर्वर प्रोविज़न करें",
"publishTitle": "सार्वजनिक कुंजी प्रकाशित करें",
"generate": "जनरेट करें",
"generateTooltip": "Ed25519 कुंजी युग्म जनरेट करता है — सार्वजनिक कुंजी प्रकाशित करता है और निजी कुंजी आपके डिवाइस पर डाउनलोड करता है",
"saveToVault": "क्रेडेंशियल में सहेजें",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "लेबल (वैकल्पिक)",
"add": "जोड़ें",
"importFromCredential": "या संग्रहीत क्रेडेंशियल से आयात करें",
"keyPublished": "कुंजी प्रकाशित",
"addKeyFailed": "कुंजी जोड़ने में विफल",
"generatedSaved": "कुंजी युग्म जनरेट हुआ और आपके क्रेडेंशियल वॉल्ट में सहेजा गया। निजी कुंजी भी डाउनलोड की गई।",
"generatedOnly": "कुंजी युग्म जनरेट हुआ — निजी कुंजी डाउनलोड की गई (केवल एक बार दिखाई गई)।",
"generateFailed": "कुंजी जनरेट करने में विफल",
"imported": "क्रेडेंशियल से कुंजी आयात की गई",
"importFailed": "कुंजी आयात करने में विफल",
"noKeys": "अभी तक कोई सार्वजनिक कुंजी प्रकाशित नहीं हुई है।",
"keysTitle": "प्रकाशित कुंजियाँ",
"published": "प्रकाशित",
"hidden": "छिपी हुई",
"keyRemoved": "कुंजी हटाई गई",
"removeKeyFailed": "कुंजी हटाने में विफल",
"updateKeyFailed": "कुंजी अपडेट करने में विफल",
"fromVault": "क्रेडेंशियल वॉल्ट से",
"linkedToTermixId": "Termix ID के माध्यम से प्रकाशित",
"selectCredential": "एक क्रेडेंशियल चुनें...",
"import": "आयात करें",
"caTitle": "प्रमाणपत्र प्राधिकरण",
"caIntro": "सर्वर पर इस CA पर भरोसा करें और यह आपके द्वारा हस्ताक्षरित किसी भी प्रमाणपत्र को स्वीकार करता है। एक ही बार में सब कुछ निरस्त करने के लिए CA को रोटेट करें; प्रमाणपत्र अपने आप भी समाप्त हो जाते हैं।",
"caEnable": "CA सक्षम करें",
"caEnabled": "CA सक्षम",
"caCreateFailed": "CA सक्षम करने में विफल",
"caPublicKeyLabel": "CA सार्वजनिक कुंजी",
"caTrustLabel": "सर्वर पर भरोसा करें (रूट के रूप में चलाएँ)",
"caRotate": "रोटेट करें",
"caRotateConfirm": "CA को रोटेट करें? इसके द्वारा हस्ताक्षरित प्रत्येक प्रमाणपत्र स्वीकार करना बंद कर दिया जाएगा और उन्हें फिर से जारी करना होगा।",
"caRotated": "CA रोटेट किया गया — पिछले प्रमाणपत्र निरस्त",
"caRotateFailed": "CA रोटेट करने में विफल",
"caDelete": "CA हटाएँ",
"caDeleteConfirm": "प्रमाणपत्र प्राधिकरण हटाएँ?",
"caDeleted": "CA हटाया गया",
"caDeleteFailed": "CA हटाने में विफल",
"caValidityLabel": "प्रमाणपत्र वैधता (दिनों में)",
"issueCert": "प्रमाणपत्र",
"issueCertTooltip": "इस कुंजी के लिए एक SSH प्रमाणपत्र जारी करें, आपके CA द्वारा हस्ताक्षरित",
"certIssued": "प्रमाणपत्र जारी और डाउनलोड किया गया",
"certIssueFailed": "प्रमाणपत्र जारी करने में विफल"
},
"nav": {
"termixId": "आईडी"
},
"credentials": {
"idBadge": "पहचान"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix azonosító",
"loadFailed": "Nem sikerült betölteni a Termix azonosítót",
"claimTitle": "Igényelje Termix azonosítóját",
"claimIntro": "Válassz egyedi azonosítót. Az SSH nyilvános kulcsaid egy nyilvános URL-címen lesznek közzétéve, amelyet hozzáadhatsz bármelyik szerver authorized_keys fájljához.",
"handleLabel": "Fogantyú",
"handlePlaceholder": "Alice",
"checking": "… ellenőrzése",
"available": "Elérhető",
"taken": "Már foglalt",
"invalidHandle": "Csak kisbetűk, számok - és _",
"descriptionLabel": "Leírás (opcionális)",
"descriptionPlaceholder": "Munkahelyi laptop és telefonkulcsok",
"create": "Termix azonosító létrehozása",
"created": "Termix azonosító létrehozva",
"createFailed": "Nem sikerült létrehozni a Termix azonosítót",
"deleteConfirm": "Törli a Termix azonosítóját és az összes közzétett kulcsát? A kiépített szerverek mindaddig megőrzik a kulcsokat, amíg manuálisan el nem távolítja azokat.",
"deleted": "Termix azonosító törölve",
"deleteFailed": "Nem sikerült törölni a Termix azonosítót",
"copyFailed": "Másolás sikertelen",
"resolverUrlLabel": "Nyilvános feloldó URL",
"provisionLabel": "Szerver kiépítése",
"publishTitle": "Nyilvános kulcs közzététele",
"generate": "Generálás",
"generateTooltip": "Ed25519 kulcspár generálása — közzéteszi a nyilvános kulcsot és letölti a privát kulcsot az eszközére",
"saveToVault": "Hitelesítő adatok mentése",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... felhasználó@hoszt",
"labelPlaceholder": "Címke (opcionális)",
"add": "Hozzáadás",
"importFromCredential": "Vagy importáljon egy tárolt hitelesítő adatból",
"keyPublished": "Kulcs közzétéve",
"addKeyFailed": "Nem sikerült hozzáadni a kulcsot",
"generatedSaved": "Kulcspár generálva és mentve a hitelesítő adatok tárolására szolgáló tárolóba. A privát kulcs is letöltődött.",
"generatedOnly": "Kulcspár generálva — privát kulcs letöltve (csak egyszer jelenik meg).",
"generateFailed": "Nem sikerült kulcsot generálni",
"imported": "Kulcs importálva a hitelesítő adatokból",
"importFailed": "Nem sikerült importálni a kulcsot",
"noKeys": "Még nincsenek közzétett nyilvános kulcsok.",
"keysTitle": "Közzétett kulcsok",
"published": "Közzétett",
"hidden": "Rejtett",
"keyRemoved": "Kulcs eltávolítva",
"removeKeyFailed": "Nem sikerült eltávolítani a kulcsot",
"updateKeyFailed": "Nem sikerült frissíteni a kulcsot",
"fromVault": "A hitelesítő adatok tárolójából",
"linkedToTermixId": "Közzétéve Termix ID-n keresztül",
"selectCredential": "Válasszon hitelesítő adatot...",
"import": "Importálás",
"caTitle": "Tanúsítványkibocsátó",
"caIntro": "Bízzon meg ebben a hitelesítésszolgáltatóban egy szerveren, és az elfogad minden aláírt tanúsítványt. Forgassa el, hogy mindent egyszerre visszavonjon; a tanúsítványok maguktól lejárnak.",
"caEnable": "CA engedélyezése",
"caEnabled": "CA engedélyezve",
"caCreateFailed": "Nem sikerült engedélyezni a CA-t",
"caPublicKeyLabel": "CA nyilvános kulcs",
"caTrustLabel": "Megbízhatóság egy szerveren (root felhasználóként futtatva)",
"caRotate": "Forog",
"caRotateConfirm": "Lecseréli a hitelesítésszolgáltatót? Az általa aláírt tanúsítványok elfogadása megszűnik, és újra kell kiállítani őket.",
"caRotated": "CA lecserélve – korábbi tanúsítványok visszavonva",
"caRotateFailed": "Nem sikerült elforgatni a CA-t",
"caDelete": "CA eltávolítása",
"caDeleteConfirm": "Eltávolítja a hitelesítésszolgáltatót?",
"caDeleted": "CA eltávolítva",
"caDeleteFailed": "Nem sikerült eltávolítani a CA-t",
"caValidityLabel": "Tanúsítvány érvényessége (nap)",
"issueCert": "Bizonyítvány",
"issueCertTooltip": "Állítson ki egy SSH tanúsítványt ehhez a kulcshoz, amelyet a hitelesítésszolgáltató aláírt",
"certIssued": "Kiállított és letöltött tanúsítvány",
"certIssueFailed": "Nem sikerült kiállítani a tanúsítványt"
},
"nav": {
"termixId": "azonosító"
},
"credentials": {
"idBadge": "azonosító"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "ID Termix",
"loadFailed": "Gagal memuat ID Termix",
"claimTitle": "Klaim ID Termix Anda",
"claimIntro": "Pilih nama pengguna yang unik. Kunci publik SSH Anda akan dipublikasikan di URL publik yang dapat Anda tambahkan ke file authorized_keys server mana pun.",
"handleLabel": "Menangani",
"handlePlaceholder": "Alice",
"checking": "Memeriksa…",
"available": "Tersedia",
"taken": "Sudah diambil",
"invalidHandle": "Hanya huruf kecil, angka, - dan _",
"descriptionLabel": "Deskripsi (opsional)",
"descriptionPlaceholder": "Kunci laptop dan telepon kerja",
"create": "Buat ID Termix",
"created": "ID Termix telah dibuat",
"createFailed": "Gagal membuat ID Termix",
"deleteConfirm": "Apakah Anda menghapus ID Termix dan semua kunci yang dipublikasikan? Server yang Anda sediakan akan tetap menyimpan kunci tersebut hingga Anda menghapusnya secara manual.",
"deleted": "ID Termix dihapus",
"deleteFailed": "Gagal menghapus ID Termix",
"copyFailed": "Penyalinan gagal",
"resolverUrlLabel": "URL resolver publik",
"provisionLabel": "Menyediakan server",
"publishTitle": "Publikasikan kunci publik",
"generate": "Menghasilkan",
"generateTooltip": "Hasilkan pasangan kunci Ed25519 — publikasikan kunci publik dan unduh kunci pribadi ke perangkat Anda.",
"saveToVault": "Simpan ke Kredensial",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Label (opsional)",
"add": "Menambahkan",
"importFromCredential": "Atau impor dari kredensial yang tersimpan.",
"keyPublished": "Kunci telah diterbitkan",
"addKeyFailed": "Gagal menambahkan kunci",
"generatedSaved": "Pasangan kunci telah dibuat dan disimpan ke brankas kredensial Anda. Kunci pribadi juga telah diunduh.",
"generatedOnly": "Pasangan kunci berhasil dibuat — kunci privat telah diunduh (hanya ditampilkan sekali).",
"generateFailed": "Gagal menghasilkan kunci",
"imported": "Kunci diimpor dari kredensial",
"importFailed": "Gagal mengimpor kunci",
"noKeys": "Belum ada kunci publik yang dipublikasikan.",
"keysTitle": "Kunci yang diterbitkan",
"published": "Diterbitkan",
"hidden": "Tersembunyi",
"keyRemoved": "Kunci dilepas",
"removeKeyFailed": "Gagal melepas kunci",
"updateKeyFailed": "Gagal memperbarui kunci",
"fromVault": "Dari brankas kredensial",
"linkedToTermixId": "Diterbitkan melalui ID Termix",
"selectCredential": "Pilih kredensial...",
"import": "Impor",
"caTitle": "Lembaga sertifikasi",
"caIntro": "Percayai CA ini pada server dan CA ini akan menerima sertifikat apa pun yang Anda tandatangani. Lakukan rotasi untuk mencabut semuanya sekaligus; sertifikat juga akan kedaluwarsa dengan sendirinya.",
"caEnable": "Aktifkan CA",
"caEnabled": "CA diaktifkan",
"caCreateFailed": "Gagal mengaktifkan CA",
"caPublicKeyLabel": "Kunci publik CA",
"caTrustLabel": "Kepercayaan pada server (jalankan sebagai root)",
"caRotate": "Memutar",
"caRotateConfirm": "Apakah CA (Certificate Authority) perlu diganti? Setiap sertifikat yang telah ditandatanganinya akan berhenti diterima dan harus diterbitkan ulang.",
"caRotated": "CA dirotasi — sertifikat sebelumnya dicabut",
"caRotateFailed": "Gagal memutar CA",
"caDelete": "Hapus CA",
"caDeleteConfirm": "Hapus otoritas sertifikat?",
"caDeleted": "CA dihapus",
"caDeleteFailed": "Gagal menghapus CA",
"caValidityLabel": "Masa berlaku sertifikat (hari)",
"issueCert": "Sertifikat",
"issueCertTooltip": "Terbitkan sertifikat SSH untuk kunci ini, yang ditandatangani oleh CA Anda.",
"certIssued": "Sertifikat telah diterbitkan dan diunduh.",
"certIssueFailed": "Gagal menerbitkan sertifikat"
},
"nav": {
"termixId": "PENGENAL"
},
"credentials": {
"idBadge": "PENGENAL"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Caricamento Termix ID fallito",
"claimTitle": "Rivendica il tuo Termix ID",
"claimIntro": "Scegli un identificativo univoco. Le tue chiavi pubbliche SSH saranno pubblicate a un URL pubblico che puoi aggiungere al file authorized_keys di qualsiasi server.",
"handleLabel": "Identificativo",
"handlePlaceholder": "alice",
"checking": "Controllo…",
"available": "Disponibile",
"taken": "Già in uso",
"invalidHandle": "Solo lettere minuscole, numeri, - e _",
"descriptionLabel": "Descrizione (facoltativa)",
"descriptionPlaceholder": "Chiavi per portatile da lavoro e telefono",
"create": "Crea Termix ID",
"created": "Termix ID creato",
"createFailed": "Creazione Termix ID fallita",
"deleteConfirm": "Eliminare il tuo Termix ID e tutte le chiavi pubblicate? I server configurati manterranno le chiavi finché non le rimuovi manualmente.",
"deleted": "Termix ID eliminato",
"deleteFailed": "Eliminazione Termix ID fallita",
"copyFailed": "Copia fallita",
"resolverUrlLabel": "URL del resolver pubblico",
"provisionLabel": "Provisiona un server",
"publishTitle": "Pubblica una chiave pubblica",
"generate": "Genera",
"generateTooltip": "Genera una coppia di chiavi Ed25519 — pubblica la chiave pubblica e scarica la chiave privata sul tuo dispositivo",
"saveToVault": "Salva nelle credenziali",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Etichetta (facoltativa)",
"add": "Aggiungi",
"importFromCredential": "Oppure importa da una credenziale memorizzata",
"keyPublished": "Chiave pubblicata",
"addKeyFailed": "Aggiunta chiave fallita",
"generatedSaved": "Coppia di chiavi generata e salvata nell'archivio delle credenziali. Anche la chiave privata è stata scaricata.",
"generatedOnly": "Coppia di chiavi generata — chiave privata scaricata (visualizzata una sola volta).",
"generateFailed": "Generazione chiave fallita",
"imported": "Chiave importata dalla credenziale",
"importFailed": "Importazione chiave non riuscita",
"noKeys": "Nessuna chiave pubblica ancora pubblicata.",
"keysTitle": "Chiavi pubblicate",
"published": "Pubblicata",
"hidden": "Nascosta",
"keyRemoved": "Chiave rimossa",
"removeKeyFailed": "Rimozione chiave non riuscita",
"updateKeyFailed": "Aggiornamento chiave non riuscito",
"fromVault": "Dal vault delle credenziali",
"linkedToTermixId": "Pubblicata tramite Termix ID",
"selectCredential": "Seleziona una credenziale...",
"import": "Importa",
"caTitle": "Autorità di certificazione",
"caIntro": "Rendi attendibile questa CA su un server e questo accetterà qualsiasi certificato da te firmato. Ruota per revocare tutto in una volta; i certificati scadono anche automaticamente.",
"caEnable": "Abilita CA",
"caEnabled": "CA abilitata",
"caCreateFailed": "Abilitazione CA non riuscita",
"caPublicKeyLabel": "Chiave pubblica CA",
"caTrustLabel": "Rendi attendibile sul server (esegui come root)",
"caRotate": "Ruota",
"caRotateConfirm": "Ruotare la CA? Tutti i certificati da essa firmati smetteranno di essere accettati e dovranno essere riemessi.",
"caRotated": "CA ruotata — certificati precedenti revocati",
"caRotateFailed": "Rotazione CA non riuscita",
"caDelete": "Rimuovi CA",
"caDeleteConfirm": "Rimuovere l'autorità di certificazione?",
"caDeleted": "CA rimossa",
"caDeleteFailed": "Rimozione CA non riuscita",
"caValidityLabel": "Validità certificato (giorni)",
"issueCert": "Certificato",
"issueCertTooltip": "Emetti un certificato SSH per questa chiave, firmato dalla tua CA",
"certIssued": "Certificato emesso e scaricato",
"certIssueFailed": "Emissione certificato non riuscita"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Termix IDのロードに失敗しました",
"claimTitle": "Termix IDを取得",
"claimIntro": "一意のハンドルを選択します。SSH公開鍵が公開URLで公開され、任意のサーバーのauthorized_keysファイルに追加できます。",
"handleLabel": "ハンドル",
"handlePlaceholder": "alice",
"checking": "確認中…",
"available": "利用可能",
"taken": "既に使用されています",
"invalidHandle": "英小文字、数字、ハイフン(-)、アンダースコア(_)のみ使用できます",
"descriptionLabel": "説明(任意)",
"descriptionPlaceholder": "仕事用ラップトップとスマートフォンのキー",
"create": "Termix IDを作成",
"created": "Termix IDが作成されました",
"createFailed": "Termix IDの作成に失敗しました",
"deleteConfirm": "Termix IDと公開されたすべてのキーを削除しますか?プロビジョニングしたサーバーでは、手動で削除するまでキーが保持されます。",
"deleted": "Termix IDが削除されました",
"deleteFailed": "Termix IDの削除に失敗しました",
"copyFailed": "コピーに失敗しました",
"resolverUrlLabel": "公開リゾルバーURL",
"provisionLabel": "サーバーをプロビジョニング",
"publishTitle": "公開鍵を公開",
"generate": "生成",
"generateTooltip": "Ed25519キーペアを生成 — 公開鍵を公開し、秘密鍵をデバイスにダウンロードします",
"saveToVault": "認証情報に保存",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "ラベル(任意)",
"add": "追加",
"importFromCredential": "または保存済みの認証情報からインポート",
"keyPublished": "キーが公開されました",
"addKeyFailed": "キーの追加に失敗しました",
"generatedSaved": "キーペアが生成され、認証情報ボールトに保存されました。秘密鍵もダウンロードされました。",
"generatedOnly": "キーペアが生成されました — 秘密鍵がダウンロードされました(一度だけ表示されます)。",
"generateFailed": "キーの生成に失敗しました",
"imported": "キーが認証情報からインポートされました",
"importFailed": "鍵のインポートに失敗しました",
"noKeys": "まだ公開鍵は公開されていません。",
"keysTitle": "公開鍵",
"published": "公開済み",
"hidden": "非表示",
"keyRemoved": "鍵が削除されました",
"removeKeyFailed": "鍵の削除に失敗しました",
"updateKeyFailed": "鍵の更新に失敗しました",
"fromVault": "認証情報ボールトから",
"linkedToTermixId": "Termix ID 経由で公開",
"selectCredential": "認証情報を選択...",
"import": "インポート",
"caTitle": "認証局",
"caIntro": "このCAをサーバーで信頼すると、署名した証明書が受け入れられます。ローテーションで一度にすべてを失効させることができ、証明書自体も有効期限が切れます。",
"caEnable": "CA を有効化",
"caEnabled": "CA 有効",
"caCreateFailed": "CA の有効化に失敗しました",
"caPublicKeyLabel": "CA 公開鍵",
"caTrustLabel": "サーバーで信頼する(root で実行)",
"caRotate": "ローテーション",
"caRotateConfirm": "CA をローテーションしますか?署名されたすべての証明書の受け入れが停止され、再発行が必要になります。",
"caRotated": "CA がローテーションされました — 以前の証明書は失効しました",
"caRotateFailed": "CA のローテーションに失敗しました",
"caDelete": "CA を削除",
"caDeleteConfirm": "認証局を削除しますか?",
"caDeleted": "CA が削除されました",
"caDeleteFailed": "CA の削除に失敗しました",
"caValidityLabel": "証明書の有効期間(日)",
"issueCert": "証明書",
"issueCertTooltip": "この鍵に対して、CA が署名した SSH 証明書を発行します",
"certIssued": "証明書が発行され、ダウンロードされました",
"certIssueFailed": "証明書の発行に失敗しました"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Termix ID 로드 실패",
"claimTitle": "Termix ID 등록",
"claimIntro": "고유한 핸들을 선택하세요. SSH 공개 키는 공개 URL에 게시되어 어느 서버의 authorized_keys 파일에든 추가할 수 있습니다.",
"handleLabel": "핸들",
"handlePlaceholder": "alice",
"checking": "확인 중…",
"available": "사용 가능",
"taken": "이미 사용 중",
"invalidHandle": "소문자, 숫자, - 및 _만 사용",
"descriptionLabel": "설명 (선택 사항)",
"descriptionPlaceholder": "업무용 노트북 및 휴대폰 키",
"create": "Termix ID 만들기",
"created": "Termix ID 생성됨",
"createFailed": "Termix ID 생성 실패",
"deleteConfirm": "Termix ID와 게시된 모든 키를 삭제하시겠습니까? 프로비저닝된 서버는 수동으로 제거할 때까지 키를 유지합니다.",
"deleted": "Termix ID 삭제됨",
"deleteFailed": "Termix ID 삭제 실패",
"copyFailed": "복사 실패",
"resolverUrlLabel": "공개 리졸버 URL",
"provisionLabel": "서버 프로비저닝",
"publishTitle": "공개 키 게시",
"generate": "생성",
"generateTooltip": "Ed25519 키 쌍 생성 — 공개 키를 게시하고 개인 키를 기기에 다운로드합니다",
"saveToVault": "자격 증명에 저장",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "레이블 (선택 사항)",
"add": "추가",
"importFromCredential": "또는 저장된 자격 증명에서 가져오기",
"keyPublished": "키 게시됨",
"addKeyFailed": "키 추가 실패",
"generatedSaved": "키 쌍이 생성되어 자격 증명 보관함에 저장되었습니다. 개인 키도 다운로드되었습니다.",
"generatedOnly": "키 쌍 생성됨 — 개인 키가 다운로드되었습니다 (한 번만 표시됨).",
"generateFailed": "키 생성 실패",
"imported": "자격 증명에서 키를 가져왔습니다",
"importFailed": "키 가져오기 실패",
"noKeys": "아직 게시된 공개 키가 없습니다.",
"keysTitle": "게시된 키",
"published": "게시됨",
"hidden": "숨김",
"keyRemoved": "키 제거됨",
"removeKeyFailed": "키 제거 실패",
"updateKeyFailed": "키 업데이트 실패",
"fromVault": "자격 증명 보관소에서",
"linkedToTermixId": "Termix ID를 통해 게시됨",
"selectCredential": "자격 증명 선택...",
"import": "가져오기",
"caTitle": "인증 기관",
"caIntro": "서버에서 이 CA를 신뢰하면 서명한 모든 인증서가 수락됩니다. 로테이션하면 모든 인증서가 즉시 폐기되며, 인증서는 자체적으로도 만료됩니다.",
"caEnable": "CA 활성화",
"caEnabled": "CA 활성화됨",
"caCreateFailed": "CA 활성화 실패",
"caPublicKeyLabel": "CA 공개 키",
"caTrustLabel": "서버에서 신뢰 (루트로 실행)",
"caRotate": "로테이션",
"caRotateConfirm": "CA를 로테이션하시겠습니까? 서명한 모든 인증서가 수락되지 않으며 다시 발급해야 합니다.",
"caRotated": "CA 로테이션됨 — 이전 인증서 폐기됨",
"caRotateFailed": "CA 로테이션 실패",
"caDelete": "CA 제거",
"caDeleteConfirm": "인증 기관을 제거하시겠습니까?",
"caDeleted": "CA 제거됨",
"caDeleteFailed": "CA 제거 실패",
"caValidityLabel": "인증서 유효 기간(일)",
"issueCert": "인증서",
"issueCertTooltip": "이 키에 대해 CA가 서명한 SSH 인증서를 발급합니다.",
"certIssued": "인증서 발급 및 다운로드 완료",
"certIssueFailed": "인증서 발급 실패"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Het laden van de Termix-ID is mislukt.",
"claimTitle": "Vraag je Termix ID aan",
"claimIntro": "Kies een unieke gebruikersnaam. Uw openbare SSH-sleutels worden gepubliceerd op een openbare URL die u kunt toevoegen aan het authorized_keys-bestand van elke server.",
"handleLabel": "Hendel",
"handlePlaceholder": "Alice",
"checking": "Controleren…",
"available": "Beschikbaar",
"taken": "Reeds meegenomen",
"invalidHandle": "Alleen kleine letters, cijfers, - en _",
"descriptionLabel": "Beschrijving (optioneel)",
"descriptionPlaceholder": "Werklaptop en telefoonsleutels",
"create": "Maak een Termix ID aan",
"created": "Termix ID aangemaakt",
"createFailed": "Het aanmaken van een Termix ID is mislukt.",
"deleteConfirm": "Wil je je Termix ID en alle gepubliceerde sleutels verwijderen? Servers die je hebt geconfigureerd, bewaren de sleutels totdat je ze handmatig verwijdert.",
"deleted": "Termix ID verwijderd",
"deleteFailed": "Het verwijderen van de Termix-ID is mislukt.",
"copyFailed": "Kopiëren mislukt",
"resolverUrlLabel": "Openbare resolver-URL",
"provisionLabel": "Een server inrichten",
"publishTitle": "Publiceer een openbare sleutel",
"generate": "Genereren",
"generateTooltip": "Genereer een Ed25519-sleutelpaar — publiceert de publieke sleutel en downloadt de privésleutel naar uw apparaat.",
"saveToVault": "Opslaan in inloggegevens",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Label (optioneel)",
"add": "Toevoegen",
"importFromCredential": "Of importeren vanuit opgeslagen inloggegevens",
"keyPublished": "Sleutel gepubliceerd",
"addKeyFailed": "Sleutel toevoegen mislukt",
"generatedSaved": "Sleutelpaar gegenereerd en opgeslagen in uw inloggegevenskluis. Privésleutel ook gedownload.",
"generatedOnly": "Sleutelpaar gegenereerd — privésleutel gedownload (wordt slechts één keer weergegeven).",
"generateFailed": "Sleutel genereren mislukt",
"imported": "Sleutel geïmporteerd uit inloggegevens",
"importFailed": "Het importeren van de sleutel is mislukt.",
"noKeys": "Er zijn nog geen openbare sleutels gepubliceerd.",
"keysTitle": "Gepubliceerde sleutels",
"published": "Gepubliceerd",
"hidden": "Verborgen",
"keyRemoved": "Sleutel verwijderd",
"removeKeyFailed": "Het verwijderen van de sleutel is mislukt.",
"updateKeyFailed": "Sleutel bijwerken mislukt",
"fromVault": "Vanuit de inloggegevenskluis",
"linkedToTermixId": "Gepubliceerd via Termix ID",
"selectCredential": "Selecteer een certificaat...",
"import": "Importeren",
"caTitle": "Certificeringsinstantie",
"caIntro": "Vertrouw deze certificeringsinstantie op een server en deze accepteert elk certificaat dat u ondertekent. Roteer de certificaten om ze allemaal tegelijk in te trekken; certificaten verlopen ook automatisch.",
"caEnable": "CA inschakelen",
"caEnabled": "CA ingeschakeld",
"caCreateFailed": "CA inschakelen mislukt",
"caPublicKeyLabel": "CA openbare sleutel",
"caTrustLabel": "Vertrouw op een server (uitvoeren als root)",
"caRotate": "Draaien",
"caRotateConfirm": "Als u de certificeringsinstantie (CA) wijzigt, worden alle certificaten die door die instantie zijn ondertekend niet langer geaccepteerd en moeten ze opnieuw worden uitgegeven.",
"caRotated": "CA geroteerd — vorige certificaten ingetrokken",
"caRotateFailed": "CA-rotatie mislukt",
"caDelete": "Verwijder CA",
"caDeleteConfirm": "De certificeringsinstantie verwijderen?",
"caDeleted": "CA verwijderd",
"caDeleteFailed": "Het verwijderen van CA is mislukt.",
"caValidityLabel": "Geldigheidsduur certificaat (dagen)",
"issueCert": "Certificaat",
"issueCertTooltip": "Geef een SSH-certificaat uit voor deze sleutel, ondertekend door uw certificeringsinstantie (CA).",
"certIssued": "Certificaat uitgegeven en gedownload",
"certIssueFailed": "Certificaat kon niet worden uitgegeven"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix-ID",
"loadFailed": "Kunne ikke laste inn Termix-ID",
"claimTitle": "Krev din Termix-ID",
"claimIntro": "Velg en unik brukernavn. Dine offentlige SSH-nøkler publiseres på en offentlig URL som du kan legge til i en hvilken som helst servers authorized_keys-fil.",
"handleLabel": "Håndtak",
"handlePlaceholder": "Alice",
"checking": "Sjekker…",
"available": "Tilgjengelig",
"taken": "Allerede tatt",
"invalidHandle": "Bare små bokstaver, tall, - og _",
"descriptionLabel": "Beskrivelse (valgfritt)",
"descriptionPlaceholder": "Jobb-laptop og telefonnøkler",
"create": "Opprett Termix-ID",
"created": "Termix-ID opprettet",
"createFailed": "Kunne ikke opprette Termix-ID",
"deleteConfirm": "Vil du slette Termix-ID-en din og alle publiserte nøkler? Servere du har klargjort vil beholde nøklene til du fjerner dem manuelt.",
"deleted": "Termix-ID slettet",
"deleteFailed": "Kunne ikke slette Termix-ID",
"copyFailed": "Kopieringen mislyktes",
"resolverUrlLabel": "Offentlig resolver-URL",
"provisionLabel": "Klargjør en server",
"publishTitle": "Publiser en offentlig nøkkel",
"generate": "Generere",
"generateTooltip": "Generer et Ed25519-nøkkelpar – publiserer den offentlige nøkkelen og laster ned den private nøkkelen til enheten din",
"saveToVault": "Lagre til legitimasjon",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... bruker@vert",
"labelPlaceholder": "Etikett (valgfritt)",
"add": "Legge til",
"importFromCredential": "Eller importer fra en lagret legitimasjon",
"keyPublished": "Nøkkel publisert",
"addKeyFailed": "Kunne ikke legge til nøkkel",
"generatedSaved": "Nøkkelpar generert og lagret i påloggingshvelvet ditt. Privatnøkkelen er også lastet ned.",
"generatedOnly": "Nøkkelpar generert – privat nøkkel lastet ned (vises bare én gang).",
"generateFailed": "Kunne ikke generere nøkkel",
"imported": "Nøkkel importert fra legitimasjon",
"importFailed": "Kunne ikke importere nøkkelen",
"noKeys": "Ingen offentlige nøkler er publisert ennå.",
"keysTitle": "Publiserte nøkler",
"published": "Publisert",
"hidden": "Skjult",
"keyRemoved": "Nøkkelen er fjernet",
"removeKeyFailed": "Kunne ikke fjerne nøkkelen",
"updateKeyFailed": "Kunne ikke oppdatere nøkkelen",
"fromVault": "Fra legitimasjonshvelvet",
"linkedToTermixId": "Publisert via Termix-ID",
"selectCredential": "Velg en legitimasjon...",
"import": "Import",
"caTitle": "Sertifikatmyndighet",
"caIntro": "Stol på denne sertifikatutstederen på en server, og den godtar alle sertifikater du signerer. Roter for å tilbakekalle alt samtidig; sertifikater utløper også av seg selv.",
"caEnable": "Aktiver CA",
"caEnabled": "CA-aktivert",
"caCreateFailed": "Kunne ikke aktivere CA",
"caPublicKeyLabel": "CA offentlig nøkkel",
"caTrustLabel": "Stol på en server (kjør som root)",
"caRotate": "Rotere",
"caRotateConfirm": "Roter CA-en? Alle sertifikater den har signert vil slutte å bli akseptert og må utstedes på nytt.",
"caRotated": "CA rotert – tidligere sertifikater inndratt",
"caRotateFailed": "Kunne ikke rotere CA",
"caDelete": "Fjern CA",
"caDeleteConfirm": "Fjerne sertifikatmyndigheten?",
"caDeleted": "CA fjernet",
"caDeleteFailed": "Kunne ikke fjerne CA",
"caValidityLabel": "Sertifikatgyldighet (dager)",
"issueCert": "Sertifikat",
"issueCertTooltip": "Utsted et SSH-sertifikat for denne nøkkelen, signert av din CA",
"certIssued": "Sertifikat utstedt og nedlastet",
"certIssueFailed": "Kunne ikke utstede sertifikatet"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Identyfikator Termix",
"loadFailed": "Nie udało się załadować identyfikatora Termix",
"claimTitle": "Zarezerwuj swój identyfikator Termix",
"claimIntro": "Wybierz unikalny identyfikator. Publiczne klucze SSH zostaną opublikowane pod publicznym adresem URL, który można dodać do pliku authorized_keys dowolnego serwera.",
"handleLabel": "Identyfikator",
"handlePlaceholder": "alicja",
"checking": "Sprawdzanie…",
"available": "Dostępny",
"taken": "Już zajęty",
"invalidHandle": "Tylko małe litery, cyfry oraz - i _",
"descriptionLabel": "Opis (opcjonalnie)",
"descriptionPlaceholder": "Klucze do laptopa i telefonu służbowego",
"create": "Utwórz identyfikator Termix",
"created": "Utworzono identyfikator Termix",
"createFailed": "Nie udało się utworzyć identyfikatora Termix",
"deleteConfirm": "Usunąć swój identyfikator Termix i wszystkie opublikowane klucze? Skonfigurowane wcześniej serwery zachowają klucze, dopóki nie usuniesz ich ręcznie.",
"deleted": "Usunięto identyfikator Termix",
"deleteFailed": "Nie udało się usunąć identyfikatora Termix",
"copyFailed": "Kopiowanie nie powiodło się",
"resolverUrlLabel": "Publiczny adres URL resolvera",
"provisionLabel": "Skonfiguruj serwer",
"publishTitle": "Opublikuj klucz publiczny",
"generate": "Generuj",
"generateTooltip": "Wygeneruj parę kluczy Ed25519: publikuje klucz publiczny i pobiera klucz prywatny na Twoje urządzenie",
"saveToVault": "Zapisz w poświadczeniach",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Etykieta (opcjonalnie)",
"add": "Dodaj",
"importFromCredential": "Albo zaimportuj z zapisanego poświadczenia",
"keyPublished": "Klucz opublikowany",
"addKeyFailed": "Nie udało się dodać klucza",
"generatedSaved": "Wygenerowano parę kluczy i zapisano w sejfie poświadczeń. Klucz prywatny został również pobrany.",
"generatedOnly": "Wygenerowano parę kluczy, pobrano klucz prywatny (wyświetlany tylko raz).",
"generateFailed": "Nie udało się wygenerować klucza",
"imported": "Zaimportowano klucz z poświadczenia",
"importFailed": "Nie udało się zaimportować klucza",
"noKeys": "Klucze publiczne nie zostały jeszcze opublikowane.",
"keysTitle": "Opublikowane klucze",
"published": "Opublikowany",
"hidden": "Ukryty",
"keyRemoved": "Usunięto klucz",
"removeKeyFailed": "Nie udało się usunąć klucza",
"updateKeyFailed": "Nie udało się zaktualizować klucza",
"fromVault": "Z sejfu poświadczeń",
"linkedToTermixId": "Opublikowano przez identyfikator Termix",
"selectCredential": "Wybierz poświadczenie...",
"import": "Import",
"caTitle": "Urząd certyfikacji (CA)",
"caIntro": "Dodaj to CA do zaufanych na serwerze, a zaakceptuje on każdy podpisany przez Ciebie certyfikat. Rotacja unieważnia wszystko naraz; certyfikaty wygasają też samodzielnie.",
"caEnable": "Włącz CA",
"caEnabled": "CA włączone",
"caCreateFailed": "Nie udało się włączyć CA",
"caPublicKeyLabel": "Klucz publiczny CA",
"caTrustLabel": "Zaufanie na serwerze (uruchom jako root)",
"caRotate": "Rotuj",
"caRotateConfirm": "Wykonać rotację CA? Każdy podpisany przez niego certyfikat przestanie być akceptowany i trzeba go wystawić ponownie.",
"caRotated": "Wykonano rotację CA, poprzednie certyfikaty unieważnione",
"caRotateFailed": "Nie udało się wykonać rotacji CA",
"caDelete": "Usuń CA",
"caDeleteConfirm": "Usunąć CA?",
"caDeleted": "CA usunięto",
"caDeleteFailed": "Nie udało się usunąć CA",
"caValidityLabel": "Ważność certyfikatu (dni)",
"issueCert": "Certyfikat",
"issueCertTooltip": "Wystaw certyfikat SSH dla tego klucza, podpisany przez Twoje CA",
"certIssued": "Wystawiono i pobrano certyfikat",
"certIssueFailed": "Nie udało się wystawić certyfikatu"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Falha ao carregar o ID do Termix",
"claimTitle": "Reivindique seu ID Termix",
"claimIntro": "Escolha um nome de usuário exclusivo. Suas chaves públicas SSH serão publicadas em um URL público que você pode adicionar ao arquivo authorized_keys de qualquer servidor.",
"handleLabel": "Lidar",
"handlePlaceholder": "Alice",
"checking": "Verificando…",
"available": "Disponível",
"taken": "Já tomadas",
"invalidHandle": "Somente letras minúsculas, números, hífens e sublinhados.",
"descriptionLabel": "Descrição (opcional)",
"descriptionPlaceholder": "Chaves do laptop e do celular de trabalho",
"create": "Criar ID Termix",
"created": "Termix ID criado",
"createFailed": "Falha ao criar o ID do Termix",
"deleteConfirm": "Excluir seu ID do Termix e todas as chaves publicadas? Os servidores que você provisionou manterão as chaves até que você as remova manualmente.",
"deleted": "ID do Termix excluído",
"deleteFailed": "Falha ao excluir o ID do Termix",
"copyFailed": "Falha na cópia",
"resolverUrlLabel": "URL do resolvedor público",
"provisionLabel": "Provisione um servidor",
"publishTitle": "Publicar uma chave pública",
"generate": "Gerar",
"generateTooltip": "Gere um par de chaves Ed25519 — publica a chave pública e baixa a chave privada para o seu dispositivo.",
"saveToVault": "Salvar nas credenciais",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... usuário@host",
"labelPlaceholder": "Rótulo (opcional)",
"add": "Adicionar",
"importFromCredential": "Ou importe a partir de uma credencial armazenada.",
"keyPublished": "Chave publicada",
"addKeyFailed": "Falha ao adicionar a chave",
"generatedSaved": "O par de chaves foi gerado e salvo em seu cofre de credenciais. A chave privada também foi baixada.",
"generatedOnly": "Par de chaves gerado — chave privada baixada (exibida apenas uma vez).",
"generateFailed": "Falha ao gerar a chave",
"imported": "Chave importada da credencial",
"importFailed": "Falha ao importar a chave",
"noKeys": "Nenhuma chave pública foi publicada ainda.",
"keysTitle": "Chaves publicadas",
"published": "Publicado",
"hidden": "Escondido",
"keyRemoved": "Chave removida",
"removeKeyFailed": "Falha ao remover a chave",
"updateKeyFailed": "Falha ao atualizar a chave",
"fromVault": "Do cofre de credenciais",
"linkedToTermixId": "Publicado via Termix ID",
"selectCredential": "Selecione uma credencial...",
"import": "Importar",
"caTitle": "Autoridade certificadora",
"caIntro": "Confie nesta Autoridade Certificadora (CA) em um servidor e ela aceitará qualquer certificado que você assinar. Rotacione os certificados para revogar todos de uma só vez; os certificados também expiram automaticamente.",
"caEnable": "Habilitar CA",
"caEnabled": "CA habilitado",
"caCreateFailed": "Falha ao ativar a CA",
"caPublicKeyLabel": "chave pública CA",
"caTrustLabel": "Confie em um servidor (execute como root)",
"caRotate": "Girar",
"caRotateConfirm": "Rotacionar a Autoridade Certificadora? Todos os certificados assinados por ela deixarão de ser aceitos e precisarão ser reemitidos.",
"caRotated": "Autoridade Certificadora rotacionada — certificados anteriores revogados",
"caRotateFailed": "Falha ao rotacionar o CA",
"caDelete": "Remover CA",
"caDeleteConfirm": "Remover a autoridade certificadora?",
"caDeleted": "CA removido",
"caDeleteFailed": "Falha ao remover a CA",
"caValidityLabel": "Validade do certificado (dias)",
"issueCert": "Certificado",
"issueCertTooltip": "Emita um certificado SSH para esta chave, assinado pela sua Autoridade Certificadora (CA).",
"certIssued": "Certificado emitido e baixado",
"certIssueFailed": "Falha na emissão do certificado"
},
"nav": {
"termixId": "EU IA"
},
"credentials": {
"idBadge": "EU IA"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Falha ao carregar o Termix ID",
"claimTitle": "Reivindicar o seu Termix ID",
"claimIntro": "Escolha um identificador único. As suas chaves públicas SSH serão publicadas num URL público que pode adicionar ao ficheiro authorized_keys de qualquer servidor.",
"handleLabel": "Identificador",
"handlePlaceholder": "alice",
"checking": "A verificar…",
"available": "Disponível",
"taken": "Já utilizado",
"invalidHandle": "Apenas letras minúsculas, números, - e _",
"descriptionLabel": "Descrição (opcional)",
"descriptionPlaceholder": "Chaves do portátil de trabalho e do telemóvel",
"create": "Criar Termix ID",
"created": "Termix ID criado",
"createFailed": "Falha ao criar o Termix ID",
"deleteConfirm": "Eliminar o seu Termix ID e todas as chaves publicadas? Os servidores que provisionou manterão as chaves até as remover manualmente.",
"deleted": "Termix ID eliminado",
"deleteFailed": "Falha ao eliminar o Termix ID",
"copyFailed": "Falha ao copiar",
"resolverUrlLabel": "URL público de resolução",
"provisionLabel": "Provisionar um servidor",
"publishTitle": "Publicar uma chave pública",
"generate": "Gerar",
"generateTooltip": "Gerar um par de chaves Ed25519 — publica a chave pública e descarrega a chave privada para o seu dispositivo",
"saveToVault": "Guardar nas credenciais",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Etiqueta (opcional)",
"add": "Adicionar",
"importFromCredential": "Ou importar de uma credencial guardada",
"keyPublished": "Chave publicada",
"addKeyFailed": "Falha ao adicionar chave",
"generatedSaved": "Par de chaves gerado e guardado no cofre de credenciais. A chave privada também foi descarregada.",
"generatedOnly": "Par de chaves gerado — chave privada descarregada (exibida apenas uma vez).",
"generateFailed": "Falha ao gerar chave",
"imported": "Chave importada da credencial",
"importFailed": "Falha ao importar chave",
"noKeys": "Nenhuma chave pública publicada ainda.",
"keysTitle": "Chaves publicadas",
"published": "Publicada",
"hidden": "Oculta",
"keyRemoved": "Chave removida",
"removeKeyFailed": "Falha ao remover chave",
"updateKeyFailed": "Falha ao atualizar chave",
"fromVault": "Do cofre de credenciais",
"linkedToTermixId": "Publicada via Termix ID",
"selectCredential": "Selecionar uma credencial...",
"import": "Importar",
"caTitle": "Autoridade de certificação",
"caIntro": "Confie nesta CA num servidor e ele aceitará qualquer certificado que assinar. Rode para revogar tudo de uma só vez; os certificados também expiram automaticamente.",
"caEnable": "Ativar CA",
"caEnabled": "CA ativada",
"caCreateFailed": "Falha ao ativar CA",
"caPublicKeyLabel": "Chave pública da CA",
"caTrustLabel": "Confiar num servidor (executar como root)",
"caRotate": "Rodar",
"caRotateConfirm": "Rodar a CA? Todos os certificados que assinou deixarão de ser aceites e terão de ser reemitidos.",
"caRotated": "CA rodada — certificados anteriores revogados",
"caRotateFailed": "Falha ao rodar CA",
"caDelete": "Remover CA",
"caDeleteConfirm": "Remover a CA?",
"caDeleted": "CA removida",
"caDeleteFailed": "Falha ao remover CA",
"caValidityLabel": "Validade do certificado (dias)",
"issueCert": "Certificado",
"issueCertTooltip": "Emitir um certificado SSH para esta chave, assinado pela sua CA",
"certIssued": "Certificado emitido e transferido",
"certIssueFailed": "Falha ao emitir certificado"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "EU IA"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "ID-ul Termix",
"loadFailed": "Nu s-a putut încărca ID-ul Termix",
"claimTitle": "Revendică-ți ID-ul Termix",
"claimIntro": "Alegeți un handle unic. Cheile dvs. publice SSH vor fi publicate la o adresă URL publică pe care o puteți adăuga în fișierul authorized_keys al oricărui server.",
"handleLabel": "Mâner",
"handlePlaceholder": "Alice",
"checking": "Verificare…",
"available": "Disponibil",
"taken": "Deja luate",
"invalidHandle": "Numai litere mici, cifre, - și _",
"descriptionLabel": "Descriere (opțional)",
"descriptionPlaceholder": "Taste pentru laptop și telefon de serviciu",
"create": "Creați ID-ul Termix",
"created": "ID-ul Termix a fost creat",
"createFailed": "Nu s-a putut crea ID-ul Termix.",
"deleteConfirm": "Ștergeți ID-ul Termix și toate cheile publicate? Serverele pe care le-ați furnizat vor păstra cheile până când le eliminați manual.",
"deleted": "ID-ul Termix a fost șters",
"deleteFailed": "Ștergerea ID-ului Termix a eșuat.",
"copyFailed": "Copierea a eșuat",
"resolverUrlLabel": "URL-ul rezolvitorului public",
"provisionLabel": "Furnizarea unui server",
"publishTitle": "Publicați o cheie publică",
"generate": "Genera",
"generateTooltip": "Generează o pereche de chei Ed25519 — publică cheia publică și descarcă cheia privată pe dispozitivul tău",
"saveToVault": "Salvați în acreditări",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... utilizator@gazdă",
"labelPlaceholder": "Etichetă (opțional)",
"add": "Adăuga",
"importFromCredential": "Sau importați dintr-o acreditare stocată",
"keyPublished": "Cheie publicată",
"addKeyFailed": "Cheia nu a putut fi adăugată",
"generatedSaved": "Perechea de chei a fost generată și salvată în seiful de acreditări. Cheia privată a fost, de asemenea, descărcată.",
"generatedOnly": "Pereche de chei generată — cheie privată descărcată (afișată o singură dată).",
"generateFailed": "Cheia nu a putut fi generată",
"imported": "Cheie importată din credențiale",
"importFailed": "Importarea cheii a eșuat",
"noKeys": "Nicio cheie publică nu a fost publicată încă.",
"keysTitle": "Chei publicate",
"published": "Publicat",
"hidden": "Ascuns",
"keyRemoved": "Cheia a fost scoasă",
"removeKeyFailed": "Cheia nu a putut fi eliminată",
"updateKeyFailed": "Cheia nu a putut fi actualizată",
"fromVault": "Din seiful de credențiale",
"linkedToTermixId": "Publicat prin intermediul ID-ului Termix",
"selectCredential": "Selectați o acreditare...",
"import": "Import",
"caTitle": "Autoritatea de certificare",
"caIntro": "Dacă aveți încredere în această CA pe un server, aceasta va accepta orice certificat pe care îl semnați. Rotiți opțiunea pentru a revoca totul simultan; certificatele expiră și ele singure.",
"caEnable": "Activează CA",
"caEnabled": "CA activat",
"caCreateFailed": "Activarea CA-ului nu a reușit",
"caPublicKeyLabel": "Cheia publică CA",
"caTrustLabel": "Încredere pe un server (executare ca root)",
"caRotate": "Roti",
"caRotateConfirm": "Rotiți CA-ul? Fiecare certificat semnat de aceasta nu va mai fi acceptat și va trebui emis din nou.",
"caRotated": "CA rotită — certificatele anterioare revocate",
"caRotateFailed": "Nu s-a putut roti CA",
"caDelete": "Eliminați CA-ul",
"caDeleteConfirm": "Eliminați autoritatea de certificare?",
"caDeleted": "Autorizație de certificare eliminată",
"caDeleteFailed": "Eliminarea CA-ului nu a reușit",
"caValidityLabel": "Valabilitatea certificatului (zile)",
"issueCert": "Certificat",
"issueCertTooltip": "Emiteți un certificat SSH pentru această cheie, semnat de CA-ul dvs.",
"certIssued": "Certificat emis și descărcat",
"certIssueFailed": "Nu s-a putut emite certificatul"
},
"nav": {
"termixId": "ID-ul"
},
"credentials": {
"idBadge": "ID-ul"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Не удалось загрузить Termix ID",
"claimTitle": "Зарегистрируйте свой Termix ID",
"claimIntro": "Выберите уникальный идентификатор. Ваши открытые ключи SSH будут опубликованы по общедоступному URL, который вы сможете добавить в файл authorized_keys на любом сервере.",
"handleLabel": "Идентификатор",
"handlePlaceholder": "alice",
"checking": "Проверка…",
"available": "Доступно",
"taken": "Уже занято",
"invalidHandle": "Только строчные буквы, цифры, - и _",
"descriptionLabel": "Описание (необязательно)",
"descriptionPlaceholder": "Ключи рабочего ноутбука и телефона",
"create": "Создать Termix ID",
"created": "Termix ID создан",
"createFailed": "Не удалось создать Termix ID",
"deleteConfirm": "Удалить свой Termix ID и все опубликованные ключи? Серверы, которые вы настроили, сохранят ключи, пока вы не удалите их вручную.",
"deleted": "Termix ID удалён",
"deleteFailed": "Не удалось удалить Termix ID",
"copyFailed": "Не удалось скопировать",
"resolverUrlLabel": "Общедоступный URL резолвера",
"provisionLabel": "Настроить сервер",
"publishTitle": "Опубликовать открытый ключ",
"generate": "Сгенерировать",
"generateTooltip": "Сгенерировать пару ключей Ed25519 — публикует открытый ключ и загружает закрытый ключ на ваше устройство",
"saveToVault": "Сохранить в учётные данные",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Метка (необязательно)",
"add": "Добавить",
"importFromCredential": "Или импортировать из сохранённых учётных данных",
"keyPublished": "Ключ опубликован",
"addKeyFailed": "Не удалось добавить ключ",
"generatedSaved": "Пара ключей сгенерирована и сохранена в хранилище учётных данных. Закрытый ключ также загружен.",
"generatedOnly": "Пара ключей сгенерирована — закрытый ключ загружен (отображается только один раз).",
"generateFailed": "Не удалось сгенерировать ключ",
"imported": "Ключ импортирован из учётных данных",
"importFailed": "Не удалось импортировать ключ",
"noKeys": "Нет опубликованных открытых ключей.",
"keysTitle": "Опубликованные ключи",
"published": "Опубликован",
"hidden": "Скрыт",
"keyRemoved": "Ключ удалён",
"removeKeyFailed": "Не удалось удалить ключ",
"updateKeyFailed": "Не удалось обновить ключ",
"fromVault": "Из хранилища учётных данных",
"linkedToTermixId": "Опубликовано через Termix ID",
"selectCredential": "Выберите учётные данные…",
"import": "Импортировать",
"caTitle": "Центр сертификации",
"caIntro": "Доверьте этот центр сертификации на сервере, и он будет принимать любые сертификаты, подписанные вами. Ротация мгновенно отзывает все сертификаты; также сертификаты истекают автоматически.",
"caEnable": "Включить ЦС",
"caEnabled": "ЦС включён",
"caCreateFailed": "Не удалось включить ЦС",
"caPublicKeyLabel": "Открытый ключ ЦС",
"caTrustLabel": "Доверие на сервере (запустите от root)",
"caRotate": "Ротировать",
"caRotateConfirm": "Ротировать ЦС? Все выданные им сертификаты перестанут приниматься и потребуют перевыпуска.",
"caRotated": "ЦС ротирован — предыдущие сертификаты отозваны",
"caRotateFailed": "Не удалось ротировать ЦС",
"caDelete": "Удалить ЦС",
"caDeleteConfirm": "Удалить центр сертификации?",
"caDeleted": "ЦС удалён",
"caDeleteFailed": "Не удалось удалить ЦС",
"caValidityLabel": "Срок действия сертификата (дней)",
"issueCert": "Сертификат",
"issueCertTooltip": "Выпустить SSH-сертификат для этого ключа, подписанный вашим ЦС",
"certIssued": "Сертификат выпущен и загружен",
"certIssueFailed": "Не удалось выпустить сертификат"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ИДЕНТИФИКАТОР"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Термикс ИД",
"loadFailed": "Учитавање Termix ID-а није успело",
"claimTitle": "Преузмите свој Termix ID",
"claimIntro": "Изаберите јединствени идентификатор. Ваши SSH јавни кључеви биће објављени на јавној URL адреси коју можете додати у датотеку authorized_keys било ког сервера.",
"handleLabel": "Ручка",
"handlePlaceholder": "Алиса",
"checking": "Провера…",
"available": "Доступно",
"taken": "Већ је заузето",
"invalidHandle": "Само мала слова, бројеви, - и _",
"descriptionLabel": "Опис (опционо)",
"descriptionPlaceholder": "Кључеви за лаптоп и телефон за посао",
"create": "Креирај Termix ID",
"created": "Termix ID је креиран",
"createFailed": "Није успело креирање Termix ID-а",
"deleteConfirm": "Желите да обришете свој Termix ID и све објављене кључеве? Сервери које сте обезбедили ће чувати кључеве док их ручно не уклоните.",
"deleted": "ИД Termix-а је обрисан",
"deleteFailed": "Брисање Termix ID-а није успело",
"copyFailed": "Копирање није успело",
"resolverUrlLabel": "Јавни URL резолвера",
"provisionLabel": "Опремите сервер",
"publishTitle": "Објавите јавни кључ",
"generate": "Генериши",
"generateTooltip": "Генеришите пар кључева Ed25519 — објављује јавни кључ и преузима приватни кључ на ваш уређај",
"saveToVault": "Сачувај у акредитиве",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... корисник@домаћин",
"labelPlaceholder": "Ознака (опционо)",
"add": "Додај",
"importFromCredential": "Или увезите из сачуваних акредитива",
"keyPublished": "Кључ објављен",
"addKeyFailed": "Додавање кључа није успело",
"generatedSaved": "Пар кључева је генерисан и сачуван у вашем трезору акредитива. Приватни кључ је такође преузет.",
"generatedOnly": "Генерисан пар кључева — преузет приватни кључ (приказан само једном).",
"generateFailed": "Генерисање кључа није успело",
"imported": "Кључ увезен из акредитива",
"importFailed": "Увоз кључа није успео",
"noKeys": "Још увек нису објављени јавни кључеви.",
"keysTitle": "Објављени кључеви",
"published": "Објављено",
"hidden": "Скривено",
"keyRemoved": "Кључ уклоњен",
"removeKeyFailed": "Уклањање кључа није успело",
"updateKeyFailed": "Ажурирање кључа није успело",
"fromVault": "Из трезора акредитива",
"linkedToTermixId": "Објављено преко Termix ID-а",
"selectCredential": "Изаберите акредитив...",
"import": "Увоз",
"caTitle": "Сертификатски ауторитет",
"caIntro": "Верујте овом ЦА на серверу и он ће прихватити сваки сертификат који потпишете. Ротирајте да бисте опозвали све одједном; сертификати такође сами истичу.",
"caEnable": "Омогући CA",
"caEnabled": "ЦА омогућен",
"caCreateFailed": "Омогућавање CA није успело",
"caPublicKeyLabel": "Јавни кључ CA",
"caTrustLabel": "Поверење на серверу (покрени као root)",
"caRotate": "Ротирај",
"caRotateConfirm": "Ротирати CA? Сваки сертификат који је потписао престаће да се прихвата и мораће се поново издати.",
"caRotated": "CA ротиран — претходни сертификати опозвани",
"caRotateFailed": "Ротација CA није успела",
"caDelete": "Уклони CA",
"caDeleteConfirm": "Уклонити ауторитет за сертификате?",
"caDeleted": "ЦА је уклоњен",
"caDeleteFailed": "Уклањање CA није успело",
"caValidityLabel": "Важење сертификата (дани)",
"issueCert": "Сертификат",
"issueCertTooltip": "Издајте SSH сертификат за овај кључ, потписан од стране вашег CA",
"certIssued": "Сертификат издат и преузет",
"certIssueFailed": "Издавање сертификата није успело"
},
"nav": {
"termixId": "ИД"
},
"credentials": {
"idBadge": "ИД"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix-ID",
"loadFailed": "Misslyckades med att ladda Termix-ID",
"claimTitle": "Hämta ditt Termix-ID",
"claimIntro": "Välj ett unikt användarnamn. Dina publika SSH-nycklar kommer att publiceras på en offentlig URL som du kan lägga till i valfri servers authorized_keys-fil.",
"handleLabel": "Hantera",
"handlePlaceholder": "Alice",
"checking": "Kontrollerar…",
"available": "Tillgänglig",
"taken": "Redan upptagen",
"invalidHandle": "Endast små bokstäver, siffror, - och _",
"descriptionLabel": "Beskrivning (valfritt)",
"descriptionPlaceholder": "Arbetslaptop och telefonnycklar",
"create": "Skapa Termix-ID",
"created": "Termix-ID skapat",
"createFailed": "Misslyckades med att skapa Termix-ID",
"deleteConfirm": "Vill du ta bort ditt Termix-ID och alla publicerade nycklar? Servrar som du har etablerat kommer att behålla nycklarna tills du tar bort dem manuellt.",
"deleted": "Termix-ID raderat",
"deleteFailed": "Misslyckades med att ta bort Termix-ID",
"copyFailed": "Kopieringen misslyckades",
"resolverUrlLabel": "Offentlig resolver-URL",
"provisionLabel": "Provisionera en server",
"publishTitle": "Publicera en offentlig nyckel",
"generate": "Generera",
"generateTooltip": "Generera ett Ed25519-nyckelpar — publicerar den offentliga nyckeln och laddar ner den privata nyckeln till din enhet",
"saveToVault": "Spara till autentiseringsuppgifter",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... användare@värd",
"labelPlaceholder": "Etikett (valfritt)",
"add": "Tillägga",
"importFromCredential": "Eller importera från en lagrad autentiseringsuppgift",
"keyPublished": "Nyckel publicerad",
"addKeyFailed": "Misslyckades med att lägga till nyckel",
"generatedSaved": "Nyckelpar genererat och sparat i ditt inloggningsvalv. Privat nyckel laddades också ner.",
"generatedOnly": "Nyckelpar genererat — privat nyckel nedladdad (visas endast en gång).",
"generateFailed": "Misslyckades med att generera nyckeln",
"imported": "Nyckel importerad från autentiseringsuppgifter",
"importFailed": "Misslyckades med att importera nyckeln",
"noKeys": "Inga publika nycklar publicerade än.",
"keysTitle": "Publicerade nycklar",
"published": "Publicerad",
"hidden": "Dold",
"keyRemoved": "Nyckel borttagen",
"removeKeyFailed": "Misslyckades med att ta bort nyckeln",
"updateKeyFailed": "Misslyckades med att uppdatera nyckeln",
"fromVault": "Från inloggningsvalvet",
"linkedToTermixId": "Publicerad via Termix-ID",
"selectCredential": "Välj en behörighetsinloggning...",
"import": "Importera",
"caTitle": "Certifikatutfärdare",
"caIntro": "Lita på denna certifikatutfärdare på en server så accepterar den alla certifikat du signerar. Rotera för att återkalla allt på en gång; certifikat upphör också att gälla av sig själva.",
"caEnable": "Aktivera CA",
"caEnabled": "CA-aktiverad",
"caCreateFailed": "Misslyckades med att aktivera CA",
"caPublicKeyLabel": "CA:s offentliga nyckel",
"caTrustLabel": "Lita på en server (kör som root)",
"caRotate": "Rotera",
"caRotateConfirm": "Rotera CA:n? Alla certifikat som den har signerat kommer att sluta accepteras och måste utfärdas på nytt.",
"caRotated": "CA roterad — tidigare certifikat återkallade",
"caRotateFailed": "Misslyckades med att rotera CA",
"caDelete": "Ta bort certifikatutfärdare",
"caDeleteConfirm": "Ta bort certifikatutfärdaren?",
"caDeleted": "CA borttagen",
"caDeleteFailed": "Misslyckades med att ta bort certifikatutfärdaren",
"caValidityLabel": "Certifikatgiltighet (dagar)",
"issueCert": "Certifikat",
"issueCertTooltip": "Utfärda ett SSH-certifikat för den här nyckeln, signerat av din CA",
"certIssued": "Certifikat utfärdat och nedladdat",
"certIssueFailed": "Misslyckades med att utfärda certifikat"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "เทอร์มิกซ์ ไอดี",
"loadFailed": "ไม่สามารถโหลด Termix ID ได้",
"claimTitle": "รับรหัส Termix ของคุณ",
"claimIntro": "เลือกชื่อผู้ใช้ที่ไม่ซ้ำกัน คีย์สาธารณะ SSH ของคุณจะถูกเผยแพร่ที่ URL สาธารณะที่คุณสามารถเพิ่มลงในไฟล์ authorized_keys ของเซิร์ฟเวอร์ใดก็ได้",
"handleLabel": "รับมือ",
"handlePlaceholder": "อลิซ",
"checking": "กำลังตรวจสอบ…",
"available": "มีอยู่",
"taken": "เอาไปแล้ว",
"invalidHandle": "ใช้เฉพาะตัวอักษรพิมพ์เล็ก ตัวเลข และเครื่องหมายขีดกลาง (-) เท่านั้น",
"descriptionLabel": "คำอธิบาย (ไม่บังคับ)",
"descriptionPlaceholder": "ปุ่มสำหรับแล็ปท็อปและโทรศัพท์ที่ใช้ในการทำงาน",
"create": "สร้าง Termix ID",
"created": "สร้าง Termix ID แล้ว",
"createFailed": "ไม่สามารถสร้าง Termix ID ได้",
"deleteConfirm": "ลบ Termix ID และคีย์ที่เผยแพร่ทั้งหมดของคุณแล้วหรือยัง? เซิร์ฟเวอร์ที่คุณตั้งค่าไว้จะเก็บคีย์เหล่านั้นไว้จนกว่าคุณจะลบออกด้วยตนเอง",
"deleted": "รหัส Termix ถูกลบแล้ว",
"deleteFailed": "ไม่สามารถลบ Termix ID ได้",
"copyFailed": "การคัดลอกล้มเหลว",
"resolverUrlLabel": "URL ตัวแก้ไขสาธารณะ",
"provisionLabel": "จัดเตรียมเซิร์ฟเวอร์",
"publishTitle": "เผยแพร่คีย์สาธารณะ",
"generate": "สร้าง",
"generateTooltip": "สร้างคู่คีย์ Ed25519 — เผยแพร่คีย์สาธารณะและดาวน์โหลดคีย์ส่วนตัวไปยังอุปกรณ์ของคุณ",
"saveToVault": "บันทึกไปยังข้อมูลรับรอง",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "ป้ายกำกับ (ไม่บังคับ)",
"add": "เพิ่ม",
"importFromCredential": "หรือนำเข้าจากข้อมูลรับรองที่บันทึกไว้",
"keyPublished": "คีย์ที่เผยแพร่",
"addKeyFailed": "ไม่สามารถเพิ่มคีย์ได้",
"generatedSaved": "สร้างและบันทึกคู่คีย์ลงในคลังข้อมูลประจำตัวของคุณเรียบร้อยแล้ว คีย์ส่วนตัวก็ถูกดาวน์โหลดด้วยเช่นกัน",
"generatedOnly": "สร้างคู่คีย์แล้ว — ดาวน์โหลดคีย์ส่วนตัวแล้ว (แสดงเพียงครั้งเดียว)",
"generateFailed": "ไม่สามารถสร้างคีย์ได้",
"imported": "คีย์ที่นำเข้าจากข้อมูลประจำตัว",
"importFailed": "ไม่สามารถนำเข้าคีย์ได้",
"noKeys": "ยังไม่มีการเผยแพร่คีย์สาธารณะใดๆ",
"keysTitle": "คีย์ที่เผยแพร่",
"published": "ที่ตีพิมพ์",
"hidden": "ที่ซ่อนอยู่",
"keyRemoved": "กุญแจถูกถอดออก",
"removeKeyFailed": "ไม่สามารถถอดกุญแจได้",
"updateKeyFailed": "ไม่สามารถอัปเดตคีย์ได้",
"fromVault": "จากคลังข้อมูลประจำตัว",
"linkedToTermixId": "เผยแพร่ผ่าน Termix ID",
"selectCredential": "เลือกข้อมูลประจำตัว...",
"import": "นำเข้า",
"caTitle": "หน่วยงานออกใบรับรอง",
"caIntro": "เชื่อถือ CA นี้บนเซิร์ฟเวอร์ และมันจะยอมรับใบรับรองใดๆ ก็ตามที่คุณลงนาม หมุนเวียนใบรับรองเพื่อยกเลิกทุกอย่างพร้อมกัน ใบรับรองจะหมดอายุเองโดยอัตโนมัติ",
"caEnable": "เปิดใช้งาน CA",
"caEnabled": "CA เปิดใช้งานแล้ว",
"caCreateFailed": "ไม่สามารถเปิดใช้งาน CA ได้",
"caPublicKeyLabel": "คีย์สาธารณะ CA",
"caTrustLabel": "เชื่อถือเซิร์ฟเวอร์ (เรียกใช้ในฐานะ root)",
"caRotate": "หมุน",
"caRotateConfirm": "หมุนเวียน CA? ใบรับรองทั้งหมดที่ CA นั้นได้ลงนามไปแล้วจะไม่ได้รับการยอมรับอีกต่อไป และจะต้องออกใหม่",
"caRotated": "CA หมุนเวียนแล้ว — ใบรับรองเดิมถูกเพิกถอน",
"caRotateFailed": "ไม่สามารถหมุน CA ได้",
"caDelete": "ลบ CA",
"caDeleteConfirm": "ลบหน่วยงานออกใบรับรองหรือไม่?",
"caDeleted": "CA ถูกลบออก",
"caDeleteFailed": "ไม่สามารถลบ CA ได้",
"caValidityLabel": "ระยะเวลาที่ใบรับรองมีผลบังคับใช้ (วัน)",
"issueCert": "ใบรับรอง",
"issueCertTooltip": "ออกใบรับรอง SSH สำหรับคีย์นี้ โดยให้หน่วยงานออกใบรับรอง (CA) ของคุณเป็นผู้ลงนาม",
"certIssued": "ออกและดาวน์โหลดใบรับรองแล้ว",
"certIssueFailed": "ไม่สามารถออกใบรับรองได้"
},
"nav": {
"termixId": "รหัสประจำตัว"
},
"credentials": {
"idBadge": "รหัสประจำตัว"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Termix ID yüklenemedi",
"claimTitle": "Termix ID'nizi alın",
"claimIntro": "Benzersiz bir kullanıcı adı seçin. SSH genel anahtarlarınız, herhangi bir sunucunun authorized_keys dosyasına ekleyebileceğiniz herkese açık bir URL'de yayımlanacaktır.",
"handleLabel": "Kullanıcı Adı",
"handlePlaceholder": "alice",
"checking": "Kontrol ediliyor…",
"available": "Kullanılabilir",
"taken": "Zaten alınmış",
"invalidHandle": "Yalnızca küçük harfler, rakamlar, - ve _ kullanılabilir",
"descriptionLabel": "Açıklama (isteğe bağlı)",
"descriptionPlaceholder": "İş bilgisayarı ve telefon anahtarları",
"create": "Termix ID Oluştur",
"created": "Termix ID oluşturuldu",
"createFailed": "Termix ID oluşturulamadı",
"deleteConfirm": "Termix ID'nizi ve yayımlanmış tüm anahtarları silmek istediğinize emin misiniz? Hazırladığınız sunucular, anahtarları siz manuel olarak kaldırana kadar saklayacaktır.",
"deleted": "Termix ID silindi",
"deleteFailed": "Termix ID silinemedi",
"copyFailed": "Kopyalama başarısız",
"resolverUrlLabel": "Genel çözümleyici URL",
"provisionLabel": "Sunucu hazırla",
"publishTitle": "Genel anahtar yayımla",
"generate": "Oluştur",
"generateTooltip": "Ed25519 anahtar çifti oluştur — genel anahtarı yayımlar ve özel anahtarı cihazınıza indirir",
"saveToVault": "Kimlik Bilgilerine Kaydet",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Etiket (isteğe bağlı)",
"add": "Ekle",
"importFromCredential": "Veya kayıtlı bir kimlik bilgisinden içe aktar",
"keyPublished": "Anahtar yayımlandı",
"addKeyFailed": "Anahtar eklenemedi",
"generatedSaved": "Anahtar çifti oluşturuldu ve kimlik bilgileri kasanıza kaydedildi. Özel anahtar da indirildi.",
"generatedOnly": "Anahtar çifti oluşturuldu — özel anahtar indirildi (yalnızca bir kez gösterilir).",
"generateFailed": "Anahtar oluşturulamadı",
"imported": "Anahtar kimlik bilgisinden içe aktarıldı",
"importFailed": "Anahtar içe aktarılamadı",
"noKeys": "Henüz yayınlanmış genel anahtar yok.",
"keysTitle": "Yayınlanan anahtarlar",
"published": "Yayınlandı",
"hidden": "Gizli",
"keyRemoved": "Anahtar kaldırıldı",
"removeKeyFailed": "Anahtar kaldırılamadı",
"updateKeyFailed": "Anahtar güncellenemedi",
"fromVault": "Kimlik bilgileri kasasından",
"linkedToTermixId": "Termix ID ile yayınlandı",
"selectCredential": "Bir kimlik bilgisi seçin...",
"import": "İçe aktar",
"caTitle": "Sertifika yetkilisi",
"caIntro": "Bu CA'yı bir sunucuda güvenilir yapın, imzaladığınız tüm sertifikaları kabul eder. Tek seferde tümünü iptal etmek için döndürün; sertifikalar ayrıca kendiliğinden süresi dolar.",
"caEnable": "CA'yı etkinleştir",
"caEnabled": "CA etkinleştirildi",
"caCreateFailed": "CA etkinleştirilemedi",
"caPublicKeyLabel": "CA genel anahtarı",
"caTrustLabel": "Sunucuda güven (root olarak çalıştırın)",
"caRotate": "Döndür",
"caRotateConfirm": "CA'yı döndürmek istediğinize emin misiniz? İmzaladığı tüm sertifikalar kabul edilmeyecek ve yeniden düzenlenmeleri gerekecek.",
"caRotated": "CA döndürüldü — önceki sertifikalar iptal edildi",
"caRotateFailed": "CA döndürülemedi",
"caDelete": "CA'yı kaldır",
"caDeleteConfirm": "Sertifika yetkilisi kaldırılsın mı?",
"caDeleted": "CA kaldırıldı",
"caDeleteFailed": "CA kaldırılamadı",
"caValidityLabel": "Sertifika geçerliliği (gün)",
"issueCert": "Sertifika",
"issueCertTooltip": "Bu anahtar için CA'nız tarafından imzalanmış bir SSH sertifikası düzenleyin",
"certIssued": "Sertifika düzenlendi ve indirildi",
"certIssueFailed": "Sertifika düzenlenemedi"
},
"nav": {
"termixId": "Kimlik"
},
"credentials": {
"idBadge": "İD"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Ідентифікатор Termix",
"loadFailed": "Не вдалося завантажити ідентифікатор Termix",
"claimTitle": "Отримайте свій ідентифікатор Termix",
"claimIntro": "Виберіть унікальний дескриптор. Ваші публічні ключі SSH будуть опубліковані за публічною URL-адресою, яку можна додати до файлу authorized_keys будь-якого сервера.",
"handleLabel": "Ручка",
"handlePlaceholder": "Аліса",
"checking": "Перевірка…",
"available": "Доступно",
"taken": "Вже зайнято",
"invalidHandle": "Тільки малі літери, цифри, - та _",
"descriptionLabel": "Опис (необов'язково)",
"descriptionPlaceholder": "Клавіші для робочого ноутбука та телефону",
"create": "Створити ідентифікатор Termix",
"created": "Ідентифікатор Termix створено",
"createFailed": "Не вдалося створити ідентифікатор Termix",
"deleteConfirm": "Видалити свій Termix ID та всі опубліковані ключі? Сервери, які ви налаштували, зберігатимуть ключі, доки ви не видалите їх вручну.",
"deleted": "Ідентифікатор Termix видалено",
"deleteFailed": "Не вдалося видалити ідентифікатор Termix",
"copyFailed": "Не вдалося скопіювати",
"resolverUrlLabel": "URL-адреса публічного резолвера",
"provisionLabel": "Підготовка сервера",
"publishTitle": "Опублікувати відкритий ключ",
"generate": "Згенерувати",
"generateTooltip": "Згенеруйте пару ключів Ed25519 — публікує відкритий ключ і завантажує закритий ключ на ваш пристрій",
"saveToVault": "Зберегти в облікові дані",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... користувач@хост",
"labelPlaceholder": "Мітка (необов'язково)",
"add": "Додати",
"importFromCredential": "Або імпортувати зі збережених облікових даних",
"keyPublished": "Ключ опубліковано",
"addKeyFailed": "Не вдалося додати ключ",
"generatedSaved": "Пару ключів згенеровано та збережено у вашому сховищі облікових даних. Також завантажено закритий ключ.",
"generatedOnly": "Згенерована пара ключів — завантажено закритий ключ (відображається лише один раз).",
"generateFailed": "Не вдалося згенерувати ключ",
"imported": "Ключ імпортовано з облікових даних",
"importFailed": "Не вдалося імпортувати ключ",
"noKeys": "Відкриті ключі ще не опубліковані.",
"keysTitle": "Опубліковані ключі",
"published": "Опубліковано",
"hidden": "Прихований",
"keyRemoved": "Ключ вилучено",
"removeKeyFailed": "Не вдалося видалити ключ",
"updateKeyFailed": "Не вдалося оновити ключ",
"fromVault": "Зі сховища облікових даних",
"linkedToTermixId": "Опубліковано через Termix ID",
"selectCredential": "Виберіть облікові дані...",
"import": "Імпорт",
"caTitle": "Центр сертифікації",
"caIntro": "Довіртеся цьому ЦС на сервері, і він прийматиме будь-який підписаний вами сертифікат. Зробіть поворот, щоб скасувати все одразу; сертифікати також закінчуються самостійно.",
"caEnable": "Увімкнути ЦС",
"caEnabled": "ЦС увімкнено",
"caCreateFailed": "Не вдалося ввімкнути CA",
"caPublicKeyLabel": "Відкритий ключ CA",
"caTrustLabel": "Довіра на сервері (запуск від імені root)",
"caRotate": "Повернути",
"caRotateConfirm": "Ротувати ЦС? Кожен підписаний ним сертифікат більше не прийматиметься та має бути виданий повторно.",
"caRotated": "ЦС повернуто — попередні сертифікати скасовано",
"caRotateFailed": "Не вдалося повернути CA",
"caDelete": "Видалити ЦС",
"caDeleteConfirm": "Видалити центр сертифікації?",
"caDeleted": "CA видалено",
"caDeleteFailed": "Не вдалося видалити CA",
"caValidityLabel": "Термін дії сертифіката (дні)",
"issueCert": "Сертифікат",
"issueCertTooltip": "Видайте SSH-сертифікат для цього ключа, підписаний вашим ЦС",
"certIssued": "Сертифікат видано та завантажено",
"certIssueFailed": "Не вдалося видати сертифікат"
},
"nav": {
"termixId": "Ідентифікатор"
},
"credentials": {
"idBadge": "Ідентифікатор"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Tải Termix ID thất bại",
"claimTitle": "Nhận Termix ID của bạn",
"claimIntro": "Chọn một tên người dùng duy nhất. Khóa công khai SSH của bạn sẽ được công bố tại một URL công khai mà bạn có thể thêm vào tệp authorized_keys của bất kỳ máy chủ nào.",
"handleLabel": "Tên người dùng",
"handlePlaceholder": "alice",
"checking": "Đang kiểm tra…",
"available": "Có sẵn",
"taken": "Đã được sử dụng",
"invalidHandle": "Chỉ chữ thường, số, - và _",
"descriptionLabel": "Mô tả (tùy chọn)",
"descriptionPlaceholder": "Khóa laptop & điện thoại",
"create": "Tạo Termix ID",
"created": "Đã tạo Termix ID",
"createFailed": "Tạo Termix ID thất bại",
"deleteConfirm": "Xóa Termix ID và tất cả khóa đã công bố? Các máy chủ bạn đã cung cấp sẽ vẫn giữ khóa cho đến khi bạn xóa thủ công.",
"deleted": "Đã xóa Termix ID",
"deleteFailed": "Xóa Termix ID thất bại",
"copyFailed": "Sao chép thất bại",
"resolverUrlLabel": "URL phân giải công khai",
"provisionLabel": "Cung cấp máy chủ",
"publishTitle": "Công bố khóa công khai",
"generate": "Tạo",
"generateTooltip": "Tạo cặp khóa Ed25519 — công bố khóa công khai và tải khóa riêng về thiết bị của bạn",
"saveToVault": "Lưu vào Kho thông tin",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "Nhãn (tùy chọn)",
"add": "Thêm",
"importFromCredential": "Hoặc nhập từ một thông tin đăng nhập đã lưu",
"keyPublished": "Đã công bố khóa",
"addKeyFailed": "Thêm khóa thất bại",
"generatedSaved": "Đã tạo cặp khóa và lưu vào kho thông tin đăng nhập của bạn. Khóa riêng cũng đã được tải xuống.",
"generatedOnly": "Đã tạo cặp khóa — khóa riêng đã được tải xuống (chỉ hiển thị một lần).",
"generateFailed": "Tạo khóa thất bại",
"imported": "Đã nhập khóa từ thông tin đăng nhập",
"importFailed": "Nhập khóa thất bại",
"noKeys": "Chưa có khóa công khai được công bố.",
"keysTitle": "Khóa đã công bố",
"published": "Đã công bố",
"hidden": "Ẩn",
"keyRemoved": "Đã xóa khóa",
"removeKeyFailed": "Xóa khóa thất bại",
"updateKeyFailed": "Cập nhật khóa thất bại",
"fromVault": "Từ kho thông tin xác thực",
"linkedToTermixId": "Công bố qua Termix ID",
"selectCredential": "Chọn thông tin xác thực...",
"import": "Nhập",
"caTitle": "Cơ quan chứng thực",
"caIntro": "Tin cậy CA này trên máy chủ và nó chấp nhận mọi chứng chỉ do bạn ký. Xoay để thu hồi tất cả cùng lúc; chứng chỉ cũng tự hết hạn.",
"caEnable": "Bật CA",
"caEnabled": "Đã bật CA",
"caCreateFailed": "Bật CA thất bại",
"caPublicKeyLabel": "Khóa công khai CA",
"caTrustLabel": "Tin cậy trên máy chủ (chạy với quyền root)",
"caRotate": "Xoay",
"caRotateConfirm": "Xoay CA? Mọi chứng chỉ do CA này ký sẽ không còn được chấp nhận và phải được cấp lại.",
"caRotated": "Đã xoay CA — các chứng chỉ trước bị thu hồi",
"caRotateFailed": "Xoay CA thất bại",
"caDelete": "Gỡ bỏ CA",
"caDeleteConfirm": "Gỡ bỏ cơ quan chứng thực?",
"caDeleted": "Đã gỡ CA",
"caDeleteFailed": "Gỡ CA thất bại",
"caValidityLabel": "Hiệu lực chứng chỉ (ngày)",
"issueCert": "Chứng chỉ",
"issueCertTooltip": "Cấp chứng chỉ SSH cho khóa này, được ký bởi CA của bạn",
"certIssued": "Chứng chỉ đã được cấp và tải xuống",
"certIssueFailed": "Cấp chứng chỉ thất bại"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "NHẬN DẠNG"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "Termix ID 加载失败",
"claimTitle": "认领您的 Termix ID",
"claimIntro": "选择一个唯一的用户名。您的 SSH 公钥将发布在一个公开 URL 上,您可以将该 URL 添加到任意服务器的 authorized_keys 文件中。",
"handleLabel": "用户名",
"handlePlaceholder": "alice",
"checking": "检查中…",
"available": "可用",
"taken": "已被占用",
"invalidHandle": "仅允许小写字母、数字、- 和 _",
"descriptionLabel": "描述(可选)",
"descriptionPlaceholder": "工作笔记本和手机密钥",
"create": "创建 Termix ID",
"created": "Termix ID 已创建",
"createFailed": "创建 Termix ID 失败",
"deleteConfirm": "删除您的 Termix ID 和所有已发布的密钥?您配置的服务器将保留密钥,直到您手动移除。",
"deleted": "Termix ID 已删除",
"deleteFailed": "删除 Termix ID 失败",
"copyFailed": "复制失败",
"resolverUrlLabel": "公开解析器 URL",
"provisionLabel": "配置服务器",
"publishTitle": "发布公钥",
"generate": "生成",
"generateTooltip": "生成 Ed25519 密钥对 — 发布公钥并将私钥下载到您的设备",
"saveToVault": "保存到凭据",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "标签(可选)",
"add": "添加",
"importFromCredential": "或从已存储的凭据导入",
"keyPublished": "密钥已发布",
"addKeyFailed": "添加密钥失败",
"generatedSaved": "密钥对已生成并保存到您的凭据库。私钥也已下载。",
"generatedOnly": "密钥对已生成 — 私钥已下载(仅显示一次)。",
"generateFailed": "密钥生成失败",
"imported": "密钥已从凭据导入",
"importFailed": "导入密钥失败",
"noKeys": "尚未发布公钥。",
"keysTitle": "已发布的密钥",
"published": "已发布",
"hidden": "隐藏",
"keyRemoved": "密钥已移除",
"removeKeyFailed": "移除密钥失败",
"updateKeyFailed": "更新密钥失败",
"fromVault": "来自凭据 Vault",
"linkedToTermixId": "通过 Termix ID 发布",
"selectCredential": "选择凭据...",
"import": "导入",
"caTitle": "证书颁发机构",
"caIntro": "在服务器上信任此 CA,它将接受您签名的任何证书。轮换可一次性吊销所有证书;证书也会自动过期。",
"caEnable": "启用 CA",
"caEnabled": "CA 已启用",
"caCreateFailed": "启用 CA 失败",
"caPublicKeyLabel": "CA 公钥",
"caTrustLabel": "在服务器上信任(以 root 身份运行)",
"caRotate": "轮换",
"caRotateConfirm": "轮换 CA?其已签名的所有证书将停止被接受,必须重新签发。",
"caRotated": "CA 已轮换 — 先前证书已吊销",
"caRotateFailed": "轮换 CA 失败",
"caDelete": "移除 CA",
"caDeleteConfirm": "移除证书颁发机构?",
"caDeleted": "CA 已移除",
"caDeleteFailed": "移除 CA 失败",
"caValidityLabel": "证书有效期(天)",
"issueCert": "证书",
"issueCertTooltip": "为此密钥签发一个 SSH 证书,由您的 CA 签名",
"certIssued": "证书已签发并下载",
"certIssueFailed": "签发证书失败"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
@@ -0,0 +1,77 @@
{
"termixId": {
"title": "Termix ID",
"loadFailed": "無法載入 Termix ID",
"claimTitle": "註冊您的 Termix ID",
"claimIntro": "選擇一個唯一的使用者代號。您的 SSH 公開金鑰將發佈在一個公開 URL,您可以將其加入任何伺服器的 authorized_keys 檔案中。",
"handleLabel": "代號",
"handlePlaceholder": "alice",
"checking": "檢查中…",
"available": "可用",
"taken": "已被使用",
"invalidHandle": "僅限小寫字母、數字、- 和 _",
"descriptionLabel": "描述(選填)",
"descriptionPlaceholder": "工作筆電和手機金鑰",
"create": "建立 Termix ID",
"created": "Termix ID 已建立",
"createFailed": "無法建立 Termix ID",
"deleteConfirm": "刪除您的 Termix ID 和所有已發佈的金鑰?您已佈建的伺服器將保留這些金鑰,直到您手動移除為止。",
"deleted": "Termix ID 已刪除",
"deleteFailed": "無法刪除 Termix ID",
"copyFailed": "複製失敗",
"resolverUrlLabel": "公開解析器 URL",
"provisionLabel": "佈建伺服器",
"publishTitle": "發佈公開金鑰",
"generate": "產生",
"generateTooltip": "產生一組 Ed25519 金鑰對 — 發佈公開金鑰並將私密金鑰下載到您的裝置",
"saveToVault": "儲存至憑證庫",
"keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host",
"labelPlaceholder": "標籤(選填)",
"add": "新增",
"importFromCredential": "或從已儲存的憑證匯入",
"keyPublished": "金鑰已發佈",
"addKeyFailed": "無法新增金鑰",
"generatedSaved": "金鑰對已產生並儲存至您的憑證庫。私密金鑰也已下載。",
"generatedOnly": "金鑰對已產生 — 私密金鑰已下載(僅顯示一次)。",
"generateFailed": "無法產生金鑰",
"imported": "已從憑證匯入金鑰",
"importFailed": "匯入金鑰失敗",
"noKeys": "尚未發布任何公開金鑰。",
"keysTitle": "已發布的金鑰",
"published": "已發布",
"hidden": "已隱藏",
"keyRemoved": "金鑰已移除",
"removeKeyFailed": "移除金鑰失敗",
"updateKeyFailed": "更新金鑰失敗",
"fromVault": "來自憑證庫",
"linkedToTermixId": "透過 Termix ID 發布",
"selectCredential": "選擇一個憑證...",
"import": "匯入",
"caTitle": "憑證授權中心",
"caIntro": "在伺服器上信任此 CA,即可接受您簽署的任何憑證。輪替可一次性撤銷所有憑證;憑證也會自行過期。",
"caEnable": "啟用 CA",
"caEnabled": "CA 已啟用",
"caCreateFailed": "啟用 CA 失敗",
"caPublicKeyLabel": "CA 公開金鑰",
"caTrustLabel": "在伺服器上信任(以 root 執行)",
"caRotate": "輪替",
"caRotateConfirm": "輪替 CA?所有由其簽署的憑證將不再被接受,必須重新簽發。",
"caRotated": "CA 已輪替 — 之前的憑證已撤銷",
"caRotateFailed": "輪替 CA 失敗",
"caDelete": "移除 CA",
"caDeleteConfirm": "移除憑證授權中心?",
"caDeleted": "CA 已移除",
"caDeleteFailed": "移除 CA 失敗",
"caValidityLabel": "憑證有效期(天)",
"issueCert": "憑證",
"issueCertTooltip": "為此金鑰簽發 SSH 憑證,由您的 CA 簽署",
"certIssued": "憑證已簽發並下載",
"certIssueFailed": "簽發憑證失敗"
},
"nav": {
"termixId": "ID"
},
"credentials": {
"idBadge": "ID"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"id": "termix-identity",
"name": "Termix Identity",
"version": "1.0.0",
"description": "Publish your SSH public keys under a public handle that servers can fetch into authorized_keys, and run your own SSH certificate authority to issue short-lived user certificates.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "Fingerprint",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:serve",
"credentials:use",
"credentials:write",
"ui:surface"
],
"contributes": {
"tabs": [
{
"id": "termix-id",
"titleKey": "nav.termixId",
"icon": "Fingerprint",
"openFrom": ["rail"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,46 @@
-- termix-identity 0001: adopt_termix_identity_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (keys and ca to their identity, keys to ssh_credentials).
CREATE TABLE IF NOT EXISTS `termix_identities` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL UNIQUE,
`handle` varchar(255) NOT NULL UNIQUE,
`description` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `termix_identities` TO `p_termix_identity_identities`;
CREATE TABLE IF NOT EXISTS `termix_identity_keys` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`identity_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`public_key` text NOT NULL,
`key_type` text NOT NULL,
`algorithm` text NOT NULL,
`label` text,
`comment` text,
`source` text NOT NULL DEFAULT ('manual'),
`credential_id` int,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`identity_id`) REFERENCES `p_termix_identity_identities` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials` (`id`) ON DELETE SET NULL
);
RENAME TABLE `termix_identity_keys` TO `p_termix_identity_keys`;
CREATE TABLE IF NOT EXISTS `termix_identity_ca` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`identity_id` int NOT NULL UNIQUE,
`user_id` varchar(255) NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,
`validity_days` int NOT NULL DEFAULT 90,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`identity_id`) REFERENCES `p_termix_identity_identities` (`id`) ON DELETE CASCADE
);
RENAME TABLE `termix_identity_ca` TO `p_termix_identity_ca`;
@@ -0,0 +1,47 @@
-- termix-identity 0001: adopt_termix_identity_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (keys and ca to their identity, keys to ssh_credentials).
CREATE TABLE IF NOT EXISTS "termix_identities" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL UNIQUE,
"handle" varchar(255) NOT NULL UNIQUE,
"description" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "termix_identities" RENAME TO "p_termix_identity_identities";
CREATE TABLE IF NOT EXISTS "termix_identity_keys" (
"id" serial PRIMARY KEY,
"identity_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"public_key" text NOT NULL,
"key_type" text NOT NULL,
"algorithm" text NOT NULL,
"label" text,
"comment" text,
"source" text NOT NULL DEFAULT 'manual',
"credential_id" integer,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE,
FOREIGN KEY ("credential_id") REFERENCES "ssh_credentials" ("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "idx_termix_identity_keys_identity" ON "termix_identity_keys" ("identity_id");
ALTER TABLE "termix_identity_keys" RENAME TO "p_termix_identity_keys";
CREATE TABLE IF NOT EXISTS "termix_identity_ca" (
"id" serial PRIMARY KEY,
"identity_id" integer NOT NULL UNIQUE,
"user_id" varchar(255) NOT NULL,
"public_key" text NOT NULL,
"private_key" text NOT NULL,
"validity_days" integer NOT NULL DEFAULT 90,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE
);
ALTER TABLE "termix_identity_ca" RENAME TO "p_termix_identity_ca";
@@ -0,0 +1,146 @@
{
"version": 1,
"tables": {
"identities": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true,
"unique": true
},
"handle": {
"type": "varchar",
"length": 255,
"notNull": true,
"unique": true
},
"description": {
"type": "text"
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "termix_identities"
},
"keys": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"identityId": {
"type": "integer",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"publicKey": {
"type": "text",
"notNull": true
},
"keyType": {
"type": "text",
"notNull": true
},
"algorithm": {
"type": "text",
"notNull": true
},
"label": {
"type": "text"
},
"comment": {
"type": "text"
},
"source": {
"type": "text",
"notNull": true,
"defaultValue": "manual"
},
"credentialId": {
"type": "integer"
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_termix_identity_keys_identity",
"columns": ["identityId"]
}
],
"adopts": "termix_identity_keys"
},
"ca": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"identityId": {
"type": "integer",
"notNull": true,
"unique": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"publicKey": {
"type": "text",
"notNull": true
},
"privateKey": {
"type": "text",
"notNull": true
},
"validityDays": {
"type": "integer",
"notNull": true,
"defaultValue": 90
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "termix_identity_ca"
}
}
}
@@ -0,0 +1,47 @@
-- termix-identity 0001: adopt_termix_identity_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (keys and ca to their identity, keys to ssh_credentials).
CREATE TABLE IF NOT EXISTS "termix_identities" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL UNIQUE,
"handle" text NOT NULL UNIQUE,
"description" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "termix_identities" RENAME TO "p_termix_identity_identities";
CREATE TABLE IF NOT EXISTS "termix_identity_keys" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"identity_id" integer NOT NULL,
"user_id" text NOT NULL,
"public_key" text NOT NULL,
"key_type" text NOT NULL,
"algorithm" text NOT NULL,
"label" text,
"comment" text,
"source" text NOT NULL DEFAULT 'manual',
"credential_id" integer,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE,
FOREIGN KEY ("credential_id") REFERENCES "ssh_credentials" ("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "idx_termix_identity_keys_identity" ON "termix_identity_keys" ("identity_id");
ALTER TABLE "termix_identity_keys" RENAME TO "p_termix_identity_keys";
CREATE TABLE IF NOT EXISTS "termix_identity_ca" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"identity_id" integer NOT NULL UNIQUE,
"user_id" text NOT NULL,
"public_key" text NOT NULL,
"private_key" text NOT NULL,
"validity_days" integer NOT NULL DEFAULT 90,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE
);
ALTER TABLE "termix_identity_ca" RENAME TO "p_termix_identity_ca";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/termix-identity",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -115,7 +115,7 @@ export function signUserCertificate(opts: SignCertOptions): string | null {
.map((name) => Buffer.concat([sshString(name), sshString("")])),
);
// Everything up to (not including) the signature — this is what gets signed.
// Everything up to (not including) the signature: this is what gets signed.
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)), // nonce
@@ -0,0 +1,69 @@
/** A GitHub-like public namespace: lowercase, starts alphanumeric, 1-39 chars. */
export const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
const MAX_DESCRIPTION_LENGTH = 500;
export function isValidHandle(handle: string): boolean {
return HANDLE_REGEX.test(handle) && !RESERVED_HANDLES.has(handle);
}
export function cleanDescription(value: unknown): string | null {
if (typeof value !== "string") return null;
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
}
export function cleanLabel(value: unknown): string | null {
return typeof value === "string" ? value.trim() || null : null;
}
/** Validity in days: a positive whole number, at most ten years. */
export function clampValidityDays(value: unknown, fallback: number): number {
const days = Number(value);
if (!Number.isFinite(days) || days <= 0) return fallback;
return Math.min(Math.floor(days), 3650);
}
interface DbError {
code?: string;
errno?: number;
message?: string;
cause?: unknown;
}
function errorChain(error: unknown): DbError[] {
const chain: DbError[] = [];
let current = error as DbError | undefined;
while (current && typeof current === "object" && chain.length < 5) {
chain.push(current);
current = current.cause as DbError | undefined;
}
return chain;
}
/** A UNIQUE violation on sqlite, Postgres or MySQL. */
export function isUniqueViolation(error: unknown): boolean {
return errorChain(error).some(
(entry) =>
entry.code === "SQLITE_CONSTRAINT_UNIQUE" ||
entry.code === "23505" ||
entry.code === "ER_DUP_ENTRY" ||
entry.errno === 1062 ||
(typeof entry.message === "string" &&
entry.message.includes("UNIQUE constraint failed")),
);
}
/** Whether a UNIQUE violation was on user_id (one handle per user) rather than the handle. */
export function isUserIdViolation(error: unknown): boolean {
return errorChain(error).some(
(entry) =>
typeof entry.message === "string" &&
(entry.message.includes("user_id") || entry.message.includes("userId")),
);
}
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -0,0 +1,20 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { ca, identities, keys } from "./tables.js";
import { createStore } from "./store.js";
import { PUBLIC_PATHS } from "./resolver.js";
import { registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const store = createStore(ctx, {
identities: await ctx.db.define(identities),
keys: await ctx.db.define(keys),
ca: await ctx.db.define(ca),
});
registerRoutes(ctx, ctx.http.router<Router>({ public: PUBLIC_PATHS }), store);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -1,5 +1,5 @@
// Pure, dependency-free helpers for SSH public-key parsing/classification used
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
// by the Termix Identity plugin.
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
// line is ~720 chars); this caps the value the unauthenticated resolver later
@@ -78,7 +78,7 @@ export function parsePublicKey(
/**
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
* filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
* filter. Exact match only: `ED25519` must NOT also return `ED25519-SK`.
*/
export function matchesAlgoFilter(
algorithm: string,
@@ -0,0 +1,173 @@
import type { Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { HANDLE_REGEX, asError } from "./handles.js";
import { matchesAlgoFilter } from "./keys.js";
import type { KeyRow, Store } from "./store.js";
/**
* The resolver needs no login: servers fetch it from scripts. Paths are
* relative to /plugin-api/termix-identity/.
*/
export const PUBLIC_PATHS = ["/u/:handle", "/u/:handle/:algo"];
/** The public resolver path for a handle, below the base URL. */
export function resolverPath(handle: string): string {
return `/plugin-api/termix-identity/u/${handle}`;
}
// A disabled or removed key must not keep being served by a cache, and the
// feed stays out of search indexes. Set on every response, 404s included.
function setResolverHeaders(res: Response) {
res.setHeader("Cache-Control", "no-store, max-age=0");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
}
function notFound(res: Response, body = "Not found\n") {
return res.status(404).type("text/plain").send(body);
}
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 renderHtml(
handle: string,
keys: Array<Pick<KeyRow, "algorithm" | "publicKey">>,
resolverUrl: string,
): string {
const keyRows = keys.length
? keys
.map(
(key) =>
`<div class="key"><span class="algo">${escapeHtml(
key.algorithm,
)}</span><code>${escapeHtml(key.publicKey)}</code></div>`,
)
.join("")
: `<p class="empty">No public keys published yet.</p>`;
// A standalone page, not the React app, so the dark theme is inlined.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta name="color-scheme" content="dark" />
<title>Termix ID @${escapeHtml(handle)}</title>
<style>
:root {
--bg: #0c0d0b; --panel: #141614; --panel-2: #181a17; --border: rgba(255,255,255,.12);
--fg: #f8f8f6; --muted: #8f9189; --accent: #f59145;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); line-height: 1.5;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.wrap { max-width: 820px; margin: 0 auto; padding: 32px 16px 48px; }
.card { background: var(--panel); border: 1px solid var(--border); }
header.card { padding: 16px 18px; display: flex; align-items: center; gap: 10px; }
h1 { font-size: 1.05rem; font-weight: 700; margin: 0; letter-spacing: .02em; color: var(--fg); }
h1 .handle { color: var(--accent); }
.label { font-size: 10px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
color: var(--muted); margin: 22px 0 8px; }
pre.cmd { margin: 0; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
padding: 12px 14px; overflow-x: auto; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.keys { display: flex; flex-direction: column; gap: 8px; }
.key { background: var(--panel); border: 1px solid var(--border); padding: 10px 12px;
display: flex; align-items: center; gap: 10px; overflow-x: auto; }
.algo { flex: 0 0 auto; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--accent); border: 1px solid rgba(245,145,69,.4); padding: 1px 5px; line-height: 1.4; }
code { white-space: pre; word-break: break-all; font-size: 12px; color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.empty { color: var(--muted); font-size: 13px; }
footer { margin-top: 28px; color: var(--muted); font-size: 11px; letter-spacing: .04em; }
</style>
</head>
<body>
<div class="wrap">
<header class="card">
<h1>Termix ID <span class="handle">@${escapeHtml(handle)}</span></h1>
</header>
<div class="label">Provision a server</div>
<pre class="cmd">curl -fsSL ${escapeHtml(resolverUrl)} &gt;&gt; ~/.ssh/authorized_keys</pre>
<div class="label">Published keys</div>
<div class="keys">${keyRows}</div>
<footer>Served by Termix</footer>
</div>
</body>
</html>`;
}
export function createResolver(ctx: PluginContext, store: Store) {
const handleOf = (req: Request) =>
String(req.params.handle || "").toLowerCase();
/** authorized_keys as text, or a small viewer for browsers. */
async function keys(req: Request, res: Response) {
setResolverHeaders(res);
const handle = handleOf(req);
if (!HANDLE_REGEX.test(handle)) return notFound(res);
const algoFilter = req.params.algo
? String(req.params.algo).toUpperCase()
: null;
try {
const identity = await store.identityByHandle(handle);
if (!identity) return notFound(res);
const published = (await store.enabledKeys(identity.id)).filter((key) =>
matchesAlgoFilter(key.algorithm, algoFilter),
);
if ((req.headers.accept || "").includes("text/html")) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.send(
renderHtml(
handle,
published,
`${ctx.http.baseUrl(req)}${resolverPath(handle)}`,
),
);
}
const body = published
.map((key) => `${key.publicKey} #termix-id @${handle}`)
.join("\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(body ? `${body}\n` : "");
} catch (error) {
ctx.log.error("Termix ID resolve failed", asError(error));
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
/** The CA public key, for TrustedUserCAKeys or an @cert-authority line. */
async function caKey(req: Request, res: Response) {
setResolverHeaders(res);
const handle = handleOf(req);
if (!HANDLE_REGEX.test(handle)) return notFound(res);
try {
const identity = await store.identityByHandle(handle);
if (!identity) return notFound(res);
const ca = await store.caForIdentity(identity.id);
if (!ca) return notFound(res, "No CA configured\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
} catch (error) {
ctx.log.error("Termix ID CA resolve failed", asError(error));
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
return { keys, caKey };
}
@@ -0,0 +1,987 @@
import type { Request, Response, Router } from "express";
// ssh2 is CommonJS and its `utils` named export is not visible to ESM, so it
// is read off the default import.
import ssh2 from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parsePublicKey } from "./keys.js";
import {
ed25519RawFromLine,
generateCa,
signUserCertificate,
} from "./certificate.js";
import {
asError,
clampValidityDays,
cleanDescription,
cleanLabel,
isUniqueViolation,
isUserIdViolation,
isValidHandle,
} from "./handles.js";
import { createResolver, resolverPath } from "./resolver.js";
import type { IdentityRow, Store } from "./store.js";
const ALREADY_HAVE_ONE =
"You already have a Termix ID. Use update to rename it.";
function parseId(value: unknown): number | null {
const id = Number.parseInt(String(value), 10);
return Number.isInteger(id) && id > 0 ? id : null;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
store: Store,
): void {
const resolver = createResolver(ctx, store);
const userId = () => {
const actor = ctx.currentActor();
if (!actor) throw new Error("No acting user");
return actor;
};
const withUrls = (req: Request, handle: string, suffix = "") => {
const path = `${resolverPath(handle)}${suffix}`;
return {
resolverPath: path,
resolverUrl: `${ctx.http.baseUrl(req)}${path}`,
};
};
const fail = (res: Response, message: string, error: unknown) => {
ctx.log.error(message, asError(error));
res.status(500).json({ error: message });
};
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}/ca:
* get:
* summary: Public CA key for a handle
* description: Public. The certificate authority's public key, for TrustedUserCAKeys or an @cert-authority line. The 2.8 URL /termix-id/u/{handle}/ca redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The CA public key as text.
* 404:
* description: No such handle, or it has no CA.
*/
router.get("/u/:handle/ca", resolver.caKey);
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}:
* get:
* summary: Published keys for a handle
* description: Public. authorized_keys lines for every enabled key, or an HTML viewer when the client accepts text/html. Never cached. The 2.8 URL /termix-id/u/{handle} redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The keys as text or HTML.
* 404:
* description: No such handle.
*/
router.get("/u/:handle", resolver.keys);
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}/{algo}:
* get:
* summary: Published keys of one algorithm for a handle
* description: Public. Like /u/{handle}, keeping only keys whose algorithm group matches (RSA, ED25519, ECDSA and so on). The 2.8 URL /termix-id/u/{handle}/{algo} redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* - in: path
* name: algo
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The matching keys as text or HTML.
* 404:
* description: No such handle.
*/
router.get("/u/:handle/:algo", resolver.keys);
// Everything below needs a signed-in user holding termix-identity.use.
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/termix-identity/me:
* get:
* summary: Get the current user's Termix ID and keys
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Identity and keys (identity may be null)
*/
router.get("/me", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ identity: null, keys: [] });
res.json({
identity: { ...identity, ...withUrls(req, identity.handle) },
keys: await store.keysForIdentity(identity.id),
});
} catch (error) {
fail(res, "Failed to fetch Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/check/{handle}:
* get:
* summary: Check if a handle is available
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Availability and validity status
*/
router.get("/check/:handle", async (req, res) => {
const handle = String(req.params.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.json({ available: false, valid: false });
}
try {
res.json({
available: !(await store.isHandleTaken(handle)),
valid: true,
});
} catch (error) {
fail(res, "Failed to check handle", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* post:
* summary: Create a Termix ID
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [handle]
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 201:
* description: Created identity
* 400:
* description: Invalid handle
* 409:
* description: Handle taken or user already has an identity
*/
router.post("/", async (req, res) => {
const handle = String(req.body?.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
try {
const owner = userId();
if (await store.identityForUser(owner)) {
return res.status(409).json({ error: ALREADY_HAVE_ONE });
}
if (await store.isHandleTaken(handle)) {
return res.status(409).json({ error: "Handle already taken" });
}
const identity = await store.createIdentity({
userId: owner,
handle,
description: cleanDescription(req.body?.description),
});
await ctx.audit.record({
action: "create_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: handle,
success: true,
});
res.status(201).json(identity);
} catch (error) {
// The checks above race a double submit; the UNIQUE constraints on
// handle and user_id are the real guard.
if (isUniqueViolation(error)) {
return res.status(409).json({
error: isUserIdViolation(error)
? ALREADY_HAVE_ONE
: "Handle already taken",
});
}
fail(res, "Failed to create Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* put:
* summary: Update Termix ID handle or description
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 200:
* description: Updated identity
* 404:
* description: No Termix ID
* 409:
* description: Handle taken
*/
router.put("/", async (req, res) => {
try {
const owner = userId();
const identity = await store.identityForUser(owner);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const update: { handle?: string; description?: string | null } = {};
if (req.body?.handle !== undefined) {
const handle = String(req.body.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
if (handle !== identity.handle && (await store.isHandleTaken(handle))) {
return res.status(409).json({ error: "Handle already taken" });
}
update.handle = handle;
}
if (req.body?.description !== undefined) {
update.description = cleanDescription(req.body.description);
}
const updated = await store.updateIdentity(owner, update);
await ctx.audit.record({
action: "update_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: updated?.handle ?? identity.handle,
details:
update.handle && update.handle !== identity.handle
? `renamed ${identity.handle} -> ${update.handle}`
: undefined,
success: true,
});
res.json(updated);
} catch (error) {
if (isUniqueViolation(error)) {
return res.status(409).json({ error: "Handle already taken" });
}
fail(res, "Failed to update Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* delete:
* summary: Delete Termix ID with its keys and CA
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
* 404:
* description: No Termix ID
*/
router.delete("/", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
await store.deleteIdentity(identity);
await ctx.audit.record({
action: "delete_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: identity.handle,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete Termix ID", error);
}
});
const needIdentity = async (res: Response): Promise<IdentityRow | null> => {
const identity = await store.identityForUser(userId());
if (!identity) {
res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
return identity;
};
/**
* @openapi
* /plugin-api/termix-identity/keys:
* post:
* summary: Publish a public key
* description: Publishes a pasted key, or the public half of one of the user's saved SSH key credentials.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* credentialId:
* type: integer
* label:
* type: string
* responses:
* 201:
* description: Published key
* 400:
* description: No identity, or the key is invalid
* 404:
* description: Credential not found
* 409:
* description: Already published
*/
router.post("/keys", async (req, res) => {
const credentialId =
req.body?.credentialId != null ? parseId(req.body.credentialId) : null;
try {
const identity = await needIdentity(res);
if (!identity) return;
let rawPublicKey: string | null = null;
let source = "manual";
let label = cleanLabel(req.body?.label);
if (credentialId) {
const credential = (await ctx.credentials.listSshKeys()).find(
(key) => key.id === credentialId,
);
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
if (!credential.publicKey) {
return res.status(400).json({
error:
"This credential has no public key and one could not be derived. Paste the public key manually.",
});
}
source = "credential";
label = label || credential.name || null;
rawPublicKey = credential.publicKey;
} else if (typeof req.body?.publicKey === "string") {
rawPublicKey = req.body.publicKey;
}
const parsed = parsePublicKey(rawPublicKey);
if (!parsed) {
return res.status(400).json({ error: "Invalid SSH public key" });
}
const existing = await store.keysForIdentity(identity.id);
if (existing.some((key) => key.publicKey === parsed.normalized)) {
return res.status(409).json({ error: "This key is already published" });
}
const key = await store.createKey({
identityId: identity.id,
userId: identity.userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
});
await ctx.audit.record({
action: "add_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(key.id),
resourceName: identity.handle,
details: `${parsed.algorithm} (${source})`,
success: true,
});
res.status(201).json(key);
} catch (error) {
fail(res, "Failed to add key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/generate:
* post:
* summary: Generate a new key pair and publish the public key
* description: The private key is returned once. With saveCredential (the default) the pair is also saved as an SSH key credential.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* enum: [ed25519, rsa]
* label:
* type: string
* saveCredential:
* type: boolean
* username:
* type: string
* responses:
* 201:
* description: Generated key info with the private key (shown once)
*/
router.post("/keys/generate", async (req, res) => {
const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519";
const saveCredential = req.body?.saveCredential !== false;
const username =
typeof req.body?.username === "string" ? req.body.username.trim() : null;
try {
const identity = await needIdentity(res);
if (!identity) return;
const comment = `termix-${identity.handle}`;
const pair =
keyType === "rsa"
? ssh2.utils.generateKeyPairSync("rsa", { bits: 4096, comment })
: ssh2.utils.generateKeyPairSync("ed25519", { comment });
const parsed = parsePublicKey(pair.public);
if (!parsed) {
return res.status(500).json({ error: "Key generation failed" });
}
// The published row only ever holds the public key; the pair goes to
// the user's encrypted credentials. Publish first so a failed save can
// be undone here, since the plugin cannot delete credentials.
const key = await store.createKey({
identityId: identity.id,
userId: identity.userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: cleanLabel(req.body?.label) || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId: null,
});
let credentialId: number | null = null;
if (saveCredential) {
try {
({ id: credentialId } = await ctx.credentials.createSshKey({
name: `Termix ID @${identity.handle} (${parsed.algorithm})`,
description: "Auto-generated by Termix ID",
username,
privateKey: pair.private,
publicKey: parsed.normalized,
keyType: parsed.type,
}));
await store.updateKey(identity.userId, key.id, { credentialId });
} catch (error) {
await store.deleteKey(identity.userId, key.id);
throw error;
}
}
await ctx.audit.record({
action: "generate_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(key.id),
resourceName: identity.handle,
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
success: true,
});
res.status(201).json({
key: { ...key, credentialId },
privateKey: pair.private,
publicKey: parsed.normalized,
credentialId,
});
} catch (error) {
fail(res, "Failed to generate key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}:
* patch:
* summary: Update key metadata (enabled state or label)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* label:
* type: string
* responses:
* 200:
* description: Updated key
* 404:
* description: Key not found
*/
router.patch("/keys/:id", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const update: { enabled?: boolean; label?: string | null } = {};
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
if (req.body?.label !== undefined)
update.label = cleanLabel(req.body.label);
const key = await store.updateKey(userId(), id, update);
if (!key) return res.status(404).json({ error: "Key not found" });
await ctx.audit.record({
action: "update_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: key.label ?? key.algorithm,
details:
update.enabled !== undefined
? `enabled: ${update.enabled}`
: "label updated",
success: true,
});
res.json(key);
} catch (error) {
fail(res, "Failed to update key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}:
* delete:
* summary: Revoke and delete a published key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted
* 404:
* description: Key not found
*/
router.delete("/keys/:id", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const deleted = await store.deleteKey(userId(), id);
if (!deleted) return res.status(404).json({ error: "Key not found" });
await ctx.audit.record({
action: "delete_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: deleted.algorithm,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* get:
* summary: Get the current user's certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: CA info or null
*/
router.get("/ca", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ ca: null });
const ca = await store.caForIdentity(identity.id);
if (!ca) return res.json({ ca: null });
res.json({
ca: {
publicKey: ca.publicKey,
validityDays: ca.validityDays,
...withUrls(req, identity.handle, "/ca"),
},
});
} catch (error) {
fail(res, "Failed to fetch CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* post:
* summary: Create a certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 201:
* description: Created CA
* 409:
* description: CA already exists
*/
router.post("/ca", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle first" });
}
if (await store.caForIdentity(identity.id)) {
return res.status(409).json({ error: "CA already exists" });
}
const validityDays = clampValidityDays(req.body?.validityDays, 90);
const generated = generateCa();
await store.createCa({
identityId: identity.id,
userId: identity.userId,
publicKey: generated.publicKeyLine,
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
validityDays,
});
await ctx.audit.record({
action: "create_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.status(201).json({
publicKey: generated.publicKeyLine,
validityDays,
...withUrls(req, identity.handle, "/ca"),
});
} catch (error) {
if (isUniqueViolation(error)) {
return res.status(409).json({ error: "CA already exists" });
}
fail(res, "Failed to create CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca/rotate:
* post:
* summary: Rotate the certificate authority (revokes all issued certificates)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 200:
* description: New CA public key
* 404:
* description: No Termix ID or no CA
*/
router.post("/ca/rotate", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const existing = await store.caForIdentity(identity.id);
if (!existing) return res.status(404).json({ error: "No CA to rotate" });
const validityDays = clampValidityDays(
req.body?.validityDays,
existing.validityDays,
);
// A new key invalidates every certificate the old one signed: this is
// the revocation mechanism.
const generated = generateCa();
await store.replaceCa(identity.id, {
publicKey: generated.publicKeyLine,
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
validityDays,
});
await ctx.audit.record({
action: "rotate_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.json({
publicKey: generated.publicKeyLine,
validityDays,
...withUrls(req, identity.handle, "/ca"),
});
} catch (error) {
fail(res, "Failed to rotate CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* delete:
* summary: Delete the certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
* 404:
* description: No Termix ID or no CA
*/
router.delete("/ca", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
if (!(await store.deleteCa(identity.id))) {
return res.status(404).json({ error: "No CA to delete" });
}
await ctx.audit.record({
action: "delete_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}/certificate:
* post:
* summary: Issue an SSH certificate for a key (Ed25519 only)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* principals:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Issued certificate
* 400:
* description: Not an Ed25519 key, or no CA
* 404:
* description: Key not found
*/
router.post("/keys/:id/certificate", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const owner = userId();
const key = await store.keyForUser(owner, id);
if (!key) return res.status(404).json({ error: "Key not found" });
if (!ed25519RawFromLine(key.publicKey)) {
return res
.status(400)
.json({ error: "Certificates are only supported for Ed25519 keys" });
}
const identity = await store.identityForUser(owner);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const ca = await store.caForIdentity(identity.id);
const caPrivateKey = ca ? await ctx.secrets.unseal(ca.privateKey) : null;
if (!ca || !caPrivateKey) {
return res
.status(400)
.json({ error: "Enable a certificate authority first" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
ca.validityDays,
);
const principals: string[] = Array.isArray(req.body?.principals)
? req.body.principals
.filter((p: unknown) => typeof p === "string" && p.trim())
.map((p: string) => p.trim())
.slice(0, 32)
: [];
const now = Math.floor(Date.now() / 1000);
const validBefore = now + validityDays * 86400;
const keyId = `termix:@${identity.handle}:${id}`;
const certificate = signUserCertificate({
userPublicKeyLine: key.publicKey,
caPrivateKeyPem: caPrivateKey,
caPublicKeyLine: ca.publicKey,
keyId,
principals,
validAfter: now - 60,
validBefore,
});
if (!certificate) {
return res.status(500).json({ error: "Failed to sign certificate" });
}
await ctx.audit.record({
action: "issue_termix_id_certificate",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: identity.handle,
details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`,
success: true,
});
res.json({ certificate, keyId, validBefore, principals, validityDays });
} catch (error) {
fail(res, "Failed to issue certificate", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/linked-credentials:
* get:
* summary: Credential ids with at least one enabled published key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of credential ids
*/
router.get("/linked-credentials", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ credentialIds: [] });
res.json({
credentialIds: await store.linkedCredentialIds(identity.id),
});
} catch (error) {
fail(res, "Failed to fetch linked credentials", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/credentials:
* get:
* summary: The user's SSH key credentials that can be published
* description: Id and name of each saved SSH key credential, for the import picker. No key material.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of key credentials
*/
router.get("/credentials", async (_req, res) => {
try {
const keys = await ctx.credentials.listSshKeys();
res.json({
credentials: keys.map((key) => ({ id: key.id, name: key.name })),
});
} catch (error) {
fail(res, "Failed to fetch credentials", error);
}
});
}
@@ -0,0 +1,296 @@
import { and, asc, eq, isNotNull } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the tables 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 IdentityRow {
id: number;
userId: string;
handle: string;
description: string | null;
createdAt: string;
updatedAt: string;
}
export interface KeyRow {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface CaRow {
id: number;
identityId: number;
userId: string;
publicKey: string;
/** Sealed with ctx.secrets.seal. */
privateKey: string;
validityDays: number;
createdAt: string;
updatedAt: string;
}
export type NewKey = Omit<KeyRow, "id" | "enabled" | "createdAt">;
export interface Tables {
identities: Table;
keys: Table;
ca: Table;
}
function toKey(row: Record<string, unknown>): KeyRow {
return {
...(row as unknown as KeyRow),
enabled: row.enabled === true || row.enabled === 1 || row.enabled === "1",
};
}
export type Store = ReturnType<typeof createStore>;
export function createStore(ctx: PluginContext, tables: Tables) {
const { identities, keys, ca } = tables;
const client = () => ctx.db.client<Drizzle>();
async function first<T>(query: Promise<T[]>): Promise<T | null> {
const rows = await query;
return rows[0] ?? null;
}
const identityForUser = async (userId: string) =>
first<IdentityRow>(
(await client())
.select()
.from(identities)
.where(eq(identities.userId, userId))
.limit(1),
);
const identityByHandle = async (handle: string) =>
first<IdentityRow>(
(await client())
.select()
.from(identities)
.where(eq(identities.handle, handle))
.limit(1),
);
const keysForIdentity = async (identityId: number): Promise<KeyRow[]> =>
(
await (
await client()
)
.select()
.from(keys)
.where(eq(keys.identityId, identityId))
.orderBy(asc(keys.id))
).map(toKey);
const keyForUser = async (userId: string, id: number) => {
const row = await first<Record<string, unknown>>(
(await client())
.select()
.from(keys)
.where(and(eq(keys.id, id), eq(keys.userId, userId)))
.limit(1),
);
return row ? toKey(row) : null;
};
const caForIdentity = async (identityId: number) =>
first<CaRow>(
(await client())
.select()
.from(ca)
.where(eq(ca.identityId, identityId))
.limit(1),
);
return {
identityForUser,
identityByHandle,
async isHandleTaken(handle: string): Promise<boolean> {
return (await identityByHandle(handle)) !== null;
},
async createIdentity(input: {
userId: string;
handle: string;
description: string | null;
}): Promise<IdentityRow> {
const now = new Date().toISOString();
await (
await client()
)
.insert(identities)
.values({ ...input, createdAt: now, updatedAt: now });
await ctx.db.persist();
return (await identityForUser(input.userId)) as IdentityRow;
},
async updateIdentity(
userId: string,
update: { handle?: string; description?: string | null },
): Promise<IdentityRow | null> {
await (
await client()
)
.update(identities)
.set({ ...update, updatedAt: new Date().toISOString() })
.where(eq(identities.userId, userId));
await ctx.db.persist();
return identityForUser(userId);
},
/** Deletes the identity with its keys and CA. */
async deleteIdentity(identity: IdentityRow): Promise<void> {
const db = await client();
await db.delete(keys).where(eq(keys.identityId, identity.id));
await db.delete(ca).where(eq(ca.identityId, identity.id));
await db.delete(identities).where(eq(identities.id, identity.id));
await ctx.db.persist();
},
keysForIdentity,
keyForUser,
async enabledKeys(identityId: number): Promise<KeyRow[]> {
return (await keysForIdentity(identityId)).filter((key) => key.enabled);
},
async createKey(input: NewKey): Promise<KeyRow> {
await (
await client()
)
.insert(keys)
.values({
...input,
enabled: true,
createdAt: new Date().toISOString(),
});
await ctx.db.persist();
// A public key appears once per identity, so it finds the new row.
const row = await first<Record<string, unknown>>(
(await client())
.select()
.from(keys)
.where(
and(
eq(keys.identityId, input.identityId),
eq(keys.publicKey, input.publicKey),
),
)
.limit(1),
);
return toKey(row as Record<string, unknown>);
},
async updateKey(
userId: string,
id: number,
update: {
enabled?: boolean;
label?: string | null;
credentialId?: number | null;
},
): Promise<KeyRow | null> {
if (!(await keyForUser(userId, id))) return null;
if (Object.keys(update).length > 0) {
await (
await client()
)
.update(keys)
.set(update)
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
await ctx.db.persist();
}
return keyForUser(userId, id);
},
async deleteKey(userId: string, id: number): Promise<KeyRow | null> {
const existing = await keyForUser(userId, id);
if (!existing) return null;
await (
await client()
)
.delete(keys)
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
await ctx.db.persist();
return existing;
},
async linkedCredentialIds(identityId: number): Promise<number[]> {
const rows: Array<{ credentialId: number | null }> = await (
await client()
)
.select({ credentialId: keys.credentialId })
.from(keys)
.where(
and(
eq(keys.identityId, identityId),
eq(keys.enabled, true),
isNotNull(keys.credentialId),
),
);
return [
...new Set(
rows
.map((row) => Number(row.credentialId))
.filter((id) => Number.isInteger(id) && id > 0),
),
];
},
caForIdentity,
async createCa(input: {
identityId: number;
userId: string;
publicKey: string;
privateKey: string;
validityDays: number;
}): Promise<void> {
const now = new Date().toISOString();
await (
await client()
)
.insert(ca)
.values({ ...input, createdAt: now, updatedAt: now });
await ctx.db.persist();
},
async replaceCa(
identityId: number,
update: { publicKey: string; privateKey: string; validityDays: number },
): Promise<void> {
await (
await client()
)
.update(ca)
.set({ ...update, updatedAt: new Date().toISOString() })
.where(eq(ca.identityId, identityId));
await ctx.db.persist();
},
async deleteCa(identityId: number): Promise<boolean> {
if (!(await caForIdentity(identityId))) return false;
await (await client()).delete(ca).where(eq(ca.identityId, identityId));
await ctx.db.persist();
return true;
},
};
}
@@ -0,0 +1,78 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/** One public handle per user. Adopted from core's termix_identities. */
export const identities = adoptLegacyTable(
"termix_identities",
defineTable("identities", {
id: id(),
userId: refUser().unique(),
handle: varchar().notNull().unique(),
description: text(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* Published public keys, plaintext because the resolver serves them without
* auth. Adopted from core's termix_identity_keys. The migrations keep the
* legacy links to identities (cascade) and ssh_credentials (set null) by hand.
*/
export const keys = adoptLegacyTable(
"termix_identity_keys",
defineTable(
"keys",
{
id: id(),
identityId: integer().notNull(),
userId: refUser(),
publicKey: text().notNull(),
keyType: text().notNull(),
algorithm: text().notNull(),
label: text(),
comment: text(),
source: text().notNull().default("manual"),
credentialId: integer(),
enabled: boolean().notNull().default(true),
createdAt: text().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_termix_identity_keys_identity",
columns: ["identityId"],
},
],
},
),
);
/**
* One certificate authority per identity. The private key is sealed with
* ctx.secrets.seal; 2.8 rows are resealed by core's
* termix-identity-ca-migration. Adopted from core's termix_identity_ca.
*/
export const ca = adoptLegacyTable(
"termix_identity_ca",
defineTable("ca", {
id: id(),
identityId: integer().notNull().unique(),
userId: refUser(),
publicKey: text().notNull(),
privateKey: text().notNull(),
validityDays: integer().notNull().default(90),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
export const tables = [identities, keys, ca];
@@ -1,6 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Copy,
@@ -15,43 +13,31 @@ import {
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
getMyTermixId,
checkTermixIdHandle,
createTermixId,
deleteTermixId,
addTermixIdKey,
generateTermixIdKey,
setTermixIdKeyEnabled,
deleteTermixIdKey,
getCredentials,
getMyCa,
createCa,
rotateCa,
deleteCa,
issueCertificate,
getLinkedCredentialIds,
Button,
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
Textarea,
} from "@termix/plugin-sdk/ui";
import type { TermixIdApi } from "./api";
import type { LinkedStore } from "./linked-store";
import {
errorMessage,
type CredentialOption,
type TermixIdCa,
type TermixIdentity,
type TermixIdentityKey,
type TermixIdCa,
} from "@/main-axios";
import { Select2 } from "@/components/select2";
} from "./types";
const DOCS_URL = "https://docs.termix.site/features/authentication/termix-id";
const accentBtn =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand";
function resolverUrl(handle: string): string {
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://your-termix";
return `${origin}/termix-id/u/${handle}`;
}
function CopyRow({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -88,7 +74,13 @@ function CopyRow({ label, value }: { label: string; value: string }) {
);
}
export function TermixIdPanel() {
export function TermixIdPanel({
api,
linked,
}: {
api: TermixIdApi;
linked: LinkedStore;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [identity, setIdentity] = useState<TermixIdentity | null>(null);
@@ -101,9 +93,9 @@ export function TermixIdPanel() {
const refresh = useCallback(async () => {
try {
const [data, caData, linkedData] = await Promise.all([
getMyTermixId(),
getMyCa().catch(() => ({ ca: null })),
getLinkedCredentialIds().catch(() => ({ credentialIds: [] })),
api.me(),
api.ca().catch(() => ({ ca: null })),
api.linkedCredentialIds().catch(() => ({ credentialIds: [] })),
]);
setIdentity(data.identity);
setKeys(data.keys);
@@ -114,7 +106,13 @@ export function TermixIdPanel() {
} finally {
setLoading(false);
}
}, [t]);
}, [api, t]);
// Credential badges elsewhere follow what this panel changes.
const changed = useCallback(async () => {
await refresh();
void linked.refresh();
}, [refresh, linked]);
useEffect(() => {
refresh();
@@ -132,22 +130,29 @@ export function TermixIdPanel() {
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-3 p-3">
{!identity ? (
<ClaimHandle onCreated={refresh} />
<ClaimHandle api={api} onCreated={changed} />
) : (
<>
<IdentityCard identity={identity} onChanged={refresh} />
<IdentityCard api={api} identity={identity} onChanged={changed} />
<AddKey
api={api}
handle={identity.handle}
linkedCredentialIds={linkedCredentialIds}
onAdded={refresh}
onAdded={changed}
/>
<KeyList
api={api}
keys={keys}
handle={identity.handle}
caEnabled={!!ca}
onChanged={refresh}
onChanged={changed}
/>
<CaCard
api={api}
handle={identity.handle}
ca={ca}
onChanged={changed}
/>
<CaCard handle={identity.handle} ca={ca} onChanged={refresh} />
</>
)}
</div>
@@ -155,7 +160,13 @@ export function TermixIdPanel() {
);
}
function ClaimHandle({ onCreated }: { onCreated: () => void }) {
function ClaimHandle({
api,
onCreated,
}: {
api: TermixIdApi;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
@@ -177,7 +188,7 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
setChecking(true);
const timer = setTimeout(async () => {
try {
const res = await checkTermixIdHandle(h);
const res = await api.checkHandle(h);
if (cancelled) return;
setStatus(
!res.valid ? "invalid" : res.available ? "available" : "taken",
@@ -192,19 +203,19 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
cancelled = true;
clearTimeout(timer);
};
}, [handle]);
}, [api, handle]);
async function submit() {
setSubmitting(true);
try {
await createTermixId(
await api.create(
handle.trim().toLowerCase(),
description.trim() || undefined,
);
toast.success(t("termixId.created"));
onCreated();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.createFailed")));
toast.error(errorMessage(e, t("termixId.createFailed")));
} finally {
setSubmitting(false);
}
@@ -216,12 +227,12 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
icon={<Fingerprint className="size-3.5" />}
action={
<a
href="https://docs.termix.site/features/authentication/termix-id"
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
{t("termixId.docsLink")}
</a>
}
>
@@ -294,26 +305,30 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
}
function IdentityCard({
api,
identity,
onChanged,
}: {
api: TermixIdApi;
identity: TermixIdentity;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const url = resolverUrl(identity.handle);
const url =
identity.resolverUrl ??
`${window.location.origin}${identity.resolverPath ?? ""}`;
const curl = `curl -fsSL ${url} >> ~/.ssh/authorized_keys`;
async function remove() {
setBusy(true);
try {
await deleteTermixId();
await api.remove();
toast.success(t("termixId.deleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.deleteFailed")));
toast.error(errorMessage(e, t("termixId.deleteFailed")));
} finally {
setBusy(false);
setConfirming(false);
@@ -345,18 +360,18 @@ function IdentityCard({
size="sm"
onClick={() => setConfirming(false)}
>
{t("nav.cancel")}
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<a
href="https://docs.termix.site/features/authentication/termix-id"
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline px-1"
>
{t("hosts.docsLink")}
{t("termixId.docsLink")}
</a>
<Button
variant="ghost"
@@ -388,11 +403,6 @@ function IdentityCard({
);
}
interface CredentialOption {
id: number;
name: string;
}
function downloadText(filename: string, text: string) {
// octet-stream so the browser/OS keeps the given extension instead of
// appending .txt to a text/plain download.
@@ -455,10 +465,12 @@ function CredentialImportRow({
}
function AddKey({
api,
handle,
linkedCredentialIds,
onAdded,
}: {
api: TermixIdApi;
handle: string;
linkedCredentialIds: Set<number>;
onAdded: () => void;
@@ -473,17 +485,11 @@ function AddKey({
const loadCredentials = useCallback(async () => {
try {
const data = await getCredentials();
const list = Array.isArray(data) ? data : [];
setCredentials(
list
.filter((c) => c.authType === "key")
.map((c) => ({ id: Number(c.id), name: String(c.name) })),
);
setCredentials((await api.credentials()).credentials);
} catch {
// non-fatal — manual paste still works
// Pasting a key still works without the list.
}
}, []);
}, [api]);
useEffect(() => {
loadCredentials();
@@ -493,7 +499,7 @@ function AddKey({
if (!publicKey.trim()) return;
setSubmitting(true);
try {
await addTermixIdKey({
await api.addKey({
publicKey: publicKey.trim(),
label: label.trim() || undefined,
});
@@ -502,7 +508,7 @@ function AddKey({
setLabel("");
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.addKeyFailed")));
toast.error(errorMessage(e, t("termixId.addKeyFailed")));
} finally {
setSubmitting(false);
}
@@ -511,7 +517,7 @@ function AddKey({
async function generate() {
setGenerating(true);
try {
const result = await generateTermixIdKey("ed25519", saveToVault);
const result = await api.generateKey("ed25519", saveToVault);
downloadText(`termix-${handle}-ed25519.key`, result.privateKey);
toast.success(
saveToVault
@@ -521,7 +527,7 @@ function AddKey({
if (saveToVault) loadCredentials();
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.generateFailed")));
toast.error(errorMessage(e, t("termixId.generateFailed")));
} finally {
setGenerating(false);
}
@@ -530,11 +536,11 @@ function AddKey({
async function importCredential(id: number) {
setSubmitting(true);
try {
await addTermixIdKey({ credentialId: id });
await api.addKey({ credentialId: id });
toast.success(t("termixId.imported"));
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.importFailed")));
toast.error(errorMessage(e, t("termixId.importFailed")));
} finally {
setSubmitting(false);
}
@@ -618,11 +624,13 @@ function AddKey({
}
function KeyList({
api,
keys,
handle,
caEnabled,
onChanged,
}: {
api: TermixIdApi;
keys: TermixIdentityKey[];
handle: string;
caEnabled: boolean;
@@ -635,27 +643,27 @@ function KeyList({
>({});
async function toggle(k: TermixIdentityKey) {
try {
await setTermixIdKeyEnabled(k.id, !k.enabled);
await api.setKeyEnabled(k.id, !k.enabled);
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.updateKeyFailed")));
toast.error(errorMessage(e, t("termixId.updateKeyFailed")));
}
}
async function remove(k: TermixIdentityKey) {
try {
await deleteTermixIdKey(k.id);
await api.removeKey(k.id);
toast.success(t("termixId.keyRemoved"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.removeKeyFailed")));
toast.error(errorMessage(e, t("termixId.removeKeyFailed")));
}
}
async function issueCert(k: TermixIdentityKey) {
setIssuingId(k.id);
try {
const res = await issueCertificate(k.id, {
const res = await api.issueCertificate(k.id, {
principals: (principalsByKey[k.id] ?? "")
.split(",")
.map((principal) => principal.trim())
@@ -664,7 +672,7 @@ function KeyList({
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
toast.success(t("termixId.certIssued"));
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.certIssueFailed")));
toast.error(errorMessage(e, t("termixId.certIssueFailed")));
} finally {
setIssuingId(null);
}
@@ -723,8 +731,8 @@ function KeyList({
[k.id]: e.target.value,
}))
}
placeholder="SSH principal(s), e.g. root"
aria-label="SSH principal(s)"
placeholder={t("termixId.principalsPlaceholder")}
aria-label={t("termixId.principalsLabel")}
className="h-7 min-w-0 flex-1 text-xs"
spellCheck={false}
/>
@@ -764,10 +772,12 @@ function KeyList({
}
function CaCard({
api,
handle,
ca,
onChanged,
}: {
api: TermixIdApi;
handle: string;
ca: TermixIdCa | null;
onChanged: () => void;
@@ -777,20 +787,17 @@ function CaCard({
const [confirmingRotate, setConfirmingRotate] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const caUrl =
typeof window !== "undefined"
? `${window.location.origin}/termix-id/u/${handle}/ca`
: `/termix-id/u/${handle}/ca`;
const caUrl = ca?.resolverUrl ?? "";
const trustCmd = `curl -fsSL ${caUrl} | sudo tee /etc/ssh/${handle}-ca.pub && echo "TrustedUserCAKeys /etc/ssh/${handle}-ca.pub" | sudo tee -a /etc/ssh/sshd_config && sudo systemctl reload sshd`;
async function enable() {
setBusy(true);
try {
await createCa();
await api.createCa();
toast.success(t("termixId.caEnabled"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caCreateFailed")));
toast.error(errorMessage(e, t("termixId.caCreateFailed")));
} finally {
setBusy(false);
}
@@ -799,11 +806,11 @@ function CaCard({
async function rotate() {
setBusy(true);
try {
await rotateCa();
await api.rotateCa();
toast.success(t("termixId.caRotated"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caRotateFailed")));
toast.error(errorMessage(e, t("termixId.caRotateFailed")));
} finally {
setBusy(false);
setConfirmingRotate(false);
@@ -813,11 +820,11 @@ function CaCard({
async function remove() {
setBusy(true);
try {
await deleteCa();
await api.removeCa();
toast.success(t("termixId.caDeleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caDeleteFailed")));
toast.error(errorMessage(e, t("termixId.caDeleteFailed")));
} finally {
setBusy(false);
setConfirmingDelete(false);
@@ -863,7 +870,7 @@ function CaCard({
size="sm"
onClick={() => setConfirmingRotate(false)}
>
{t("nav.cancel")}
{t("common.cancel")}
</Button>
</div>
) : confirmingDelete ? (
@@ -886,7 +893,7 @@ function CaCard({
size="sm"
onClick={() => setConfirmingDelete(false)}
>
{t("nav.cancel")}
{t("common.cancel")}
</Button>
</div>
) : (
@@ -0,0 +1,57 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
CredentialOption,
GeneratedKey,
IssuedCertificate,
TermixIdCa,
TermixIdentity,
TermixIdentityKey,
TermixIdMe,
} from "./types";
/** The Termix ID routes, relative to /plugin-api/termix-identity/. */
export function createTermixIdApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
me: () => data(api.get<TermixIdMe>("/me")),
checkHandle: (handle: string) =>
data(
api.get<{ available: boolean; valid: boolean }>(
`/check/${encodeURIComponent(handle)}`,
),
),
create: (handle: string, description?: string) =>
data(api.post<TermixIdentity>("/", { handle, description })),
update: (body: { handle?: string; description?: string }) =>
data(api.put<TermixIdentity>("/", body)),
remove: () => data(api.delete<{ success: boolean }>("/")),
addKey: (body: {
publicKey?: string;
credentialId?: number;
label?: string;
}) => data(api.post<TermixIdentityKey>("/keys", body)),
generateKey: (type: "ed25519" | "rsa", saveCredential: boolean) =>
data(api.post<GeneratedKey>("/keys/generate", { type, saveCredential })),
setKeyEnabled: (id: number, enabled: boolean) =>
data(api.patch<TermixIdentityKey>(`/keys/${id}`, { enabled })),
removeKey: (id: number) =>
data(api.delete<{ success: boolean }>(`/keys/${id}`)),
ca: () => data(api.get<{ ca: TermixIdCa | null }>("/ca")),
createCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca", { validityDays })),
rotateCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca/rotate", { validityDays })),
removeCa: () => data(api.delete<{ success: boolean }>("/ca")),
issueCertificate: (
keyId: number,
body: { principals?: string[]; validityDays?: number } = {},
) => data(api.post<IssuedCertificate>(`/keys/${keyId}/certificate`, body)),
linkedCredentialIds: () =>
data(api.get<{ credentialIds: number[] }>("/linked-credentials")),
credentials: () =>
data(api.get<{ credentials: CredentialOption[] }>("/credentials")),
};
}
export type TermixIdApi = ReturnType<typeof createTermixIdApi>;
@@ -0,0 +1,101 @@
import { useEffect, useReducer, type ComponentType } from "react";
import { Fingerprint } from "lucide-react";
import {
useTranslation,
type Disposer,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TermixIdPanel } from "./TermixIdPanel";
import { createTermixIdApi } from "./api";
import { createLinkedStore, type LinkedStore } from "./linked-store";
const VIEW = "termix-id";
function createCredentialBadge(linked: LinkedStore) {
/** The "ID" badge on a saved credential whose key is published. */
return function CredentialBadge({ credentialId }: { credentialId?: number }) {
const { t } = useTranslation();
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
linked.ensure();
return linked.subscribe(rerender);
}, []);
if (credentialId === undefined || !linked.has(Number(credentialId))) {
return null;
}
return (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
{t("credentials.idBadge")}
</span>
);
};
}
export function activate(app: TermixApp): void {
const api = createTermixIdApi(app.api);
const linked = createLinkedStore(api, () => app.hasPermission("use"));
const Panel = () => (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel api={api} linked={linked} />
</div>
);
// Publishing keys under a public handle means nothing to a standalone
// desktop install, so the rail item waits for a connected remote server.
let removeRailItem: Disposer | null = null;
let hidden: boolean | null = null;
const showRailItem = (hide: boolean) => {
if (hide === hidden) return;
hidden = hide;
removeRailItem?.();
removeRailItem = app.registerRailItem({
id: VIEW,
icon: Fingerprint,
titleKey: "nav.termixId",
after: "credentials",
promotable: true,
separatorAfter: true,
permission: "use",
hidden: hide,
});
};
if (app.desktop.available) {
let alive = true;
const check = () =>
void app.desktop.remoteServerUrl().then((url) => {
if (alive) showRailItem(!url);
});
showRailItem(true);
check();
app.desktop.onRemoteServerChange(check);
app.onDispose(() => {
alive = false;
});
} else {
showRailItem(false);
}
app.registerPanel(VIEW, Panel);
app.registerTab(VIEW, Panel, {
icon: Fingerprint,
titleKey: "nav.termixId",
hostless: true,
singleton: true,
panelFrame: true,
});
app.registerSlotContribution("credentials.badges", {
actionId: "termix-identity.credentialBadge",
titleKey: "credentials.idBadge",
kind: "component",
component: createCredentialBadge(linked) as unknown as ComponentType<
Record<string, unknown>
>,
});
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -0,0 +1,48 @@
import type { TermixIdApi } from "./api";
/**
* Which credentials have a published key, shared by every credential badge
* so the list fetches once instead of once per row. The panel calls
* refresh() after it publishes or removes a key.
*/
export function createLinkedStore(
api: TermixIdApi,
allowed: () => Promise<boolean>,
) {
let ids = new Set<number>();
let loading: Promise<void> | null = null;
let loaded = false;
const listeners = new Set<() => void>();
const refresh = (): Promise<void> => {
loading = (async () => {
try {
ids = (await allowed())
? new Set((await api.linkedCredentialIds()).credentialIds)
: new Set();
} catch {
// Badges are a hint; a failed lookup just shows none.
}
loaded = true;
listeners.forEach((listener) => listener());
})();
return loading;
};
return {
has: (id: number) => ids.has(id),
ids: () => ids,
ensure: () => {
if (!loaded && !loading) void refresh();
},
refresh,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export type LinkedStore = ReturnType<typeof createLinkedStore>;
@@ -0,0 +1,64 @@
export interface TermixIdentity {
id: number;
userId: string;
handle: string;
description: string | null;
resolverPath?: string;
resolverUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface TermixIdentityKey {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface TermixIdMe {
identity: TermixIdentity | null;
keys: TermixIdentityKey[];
}
export interface GeneratedKey {
key: TermixIdentityKey;
privateKey: string;
publicKey: string;
credentialId: number | null;
}
export interface TermixIdCa {
publicKey: string;
validityDays: number;
resolverPath: string;
resolverUrl: string;
}
export interface IssuedCertificate {
certificate: string;
keyId: string;
validBefore: number;
principals: string[];
validityDays: number;
}
export interface CredentialOption {
id: number;
name: string;
}
/** The server's error message when it sent one, else the fallback. */
export function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}
@@ -8,7 +8,7 @@ import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "../../../database/routes/ssh-certificate.js";
} from "../../src/backend/certificate.js";
function publicKeyObjectFromLine(line: string) {
const raw = ed25519RawFromLine(line);
@@ -92,7 +92,7 @@ describe("signUserCertificate", () => {
void sshKeygen;
} catch (e) {
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
// fine — it means the binary exists. Only skip if it's truly missing.
// fine: it means the binary exists. Only skip if it's truly missing.
if ((e as { code?: string }).code === "ENOENT") return;
}
@@ -0,0 +1,144 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
type TestDbOptions,
} from "@termix/plugin-sdk/testing";
import type { PluginSshKeyCredential } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
/** Core's ssh_credentials, which the keys table points at. */
export const CREDENTIALS_DDL =
"CREATE TABLE IF NOT EXISTS ssh_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT)";
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: {
user?: string | null;
body?: unknown;
headers?: Record<string, string>;
},
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any; text: string; headers: Headers }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed, the acting user taken from the request,
* and a request without one refused unless its path is public.
*/
export async function startServer(
options: {
permissions?: string[];
capabilities?: string[];
sshKeyCredentials?: PluginSshKeyCredential[];
before?: TestDbOptions["before"];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
options.before?.(sqlite);
},
});
for (const user of ["user-1", "user-2"]) {
db.sqlite.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)").run(user);
}
// Credential ids the fake ctx.credentials hands out.
for (let id = 1; id <= 10; id++) {
db.sqlite
.prepare("INSERT OR IGNORE INTO ssh_credentials (id) VALUES (?)")
.run(id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
sshKeyCredentials: options.sshKeyCredentials,
actor: "user-1",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const publicPaths = mock.httpRouters[0]?.public ?? [];
const isPublic = (path: string) =>
publicPaths.some((pattern) => {
const want = pattern.split("/");
const got = path.split("/");
return (
want.length === got.length &&
want.every((part, i) => part.startsWith(":") || part === got[i])
);
});
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const user = req.header("x-test-user") || undefined;
if (!user && !isPublic(req.path)) {
res.status(401).json({ error: "Authentication required" });
return;
}
mock.setActor(user);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body, headers } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
redirect: "manual",
headers: {
...(user ? { "x-test-user": user } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
return {
status: response.status,
body: parsed,
text,
headers: response.headers,
};
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -4,7 +4,7 @@ import {
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "../../../database/routes/termix-id-keys.js";
} from "../../src/backend/keys.js";
// Build a valid OpenSSH public-key line for a given type by encoding a wire
// blob whose first string field equals the type (what parsePublicKey checks).
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { CREDENTIALS_DDL, pluginDir } from "./helpers";
// The three tables as core's SQLite bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE termix_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
handle TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_termix_identities_user ON termix_identities(user_id);
CREATE TABLE termix_identity_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
algorithm TEXT NOT NULL,
label TEXT,
comment TEXT,
source TEXT NOT NULL DEFAULT 'manual',
credential_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
CREATE INDEX idx_termix_identity_keys_identity ON termix_identity_keys(identity_id);
CREATE TABLE termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexes = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?",
)
.all(table) as Array<{ name: string }>
).map((row) => row.name);
const count = (table: string) =>
(
db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
function seed() {
db!.sqlite.exec(`
INSERT INTO p_termix_identity_identities (id, user_id, handle) VALUES (1, 'user-1', 'alice');
INSERT INTO p_termix_identity_keys (identity_id, user_id, public_key, key_type, algorithm, credential_id)
VALUES (1, 'user-1', 'ssh-ed25519 AAAA', 'ssh-ed25519', 'ED25519', 3);
INSERT INTO p_termix_identity_ca (identity_id, user_id, public_key, private_key)
VALUES (1, 'user-1', 'ssh-ed25519 BBBB', 'sealed');
`);
}
describe("adopting the termix identity tables", () => {
it("keeps every existing row, index and link when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_credentials (id) VALUES (3)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO termix_identities (id, user_id, handle, description) VALUES (1, 'user-1', 'alice', 'me');
INSERT INTO termix_identity_keys (identity_id, user_id, public_key, key_type, algorithm, label, credential_id, enabled)
VALUES (1, 'user-1', 'ssh-ed25519 AAAA', 'ssh-ed25519', 'ED25519', 'Laptop', 3, 0);
INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (1, 'user-1', 'ssh-ed25519 BBBB', 'dek-encrypted', 30);
`);
},
});
expect(tableExists("termix_identities")).toBe(false);
expect(tableExists("termix_identity_keys")).toBe(false);
expect(tableExists("termix_identity_ca")).toBe(false);
expect(
db.sqlite
.prepare("SELECT handle, description FROM p_termix_identity_identities")
.get(),
).toEqual({ handle: "alice", description: "me" });
expect(
db.sqlite
.prepare(
"SELECT label, credential_id, enabled FROM p_termix_identity_keys",
)
.get(),
).toEqual({ label: "Laptop", credential_id: 3, enabled: 0 });
expect(
db.sqlite
.prepare("SELECT private_key, validity_days FROM p_termix_identity_ca")
.get(),
).toEqual({ private_key: "dek-encrypted", validity_days: 30 });
expect(indexes("p_termix_identity_identities")).toContain(
"idx_termix_identities_user",
);
expect(
indexes("p_termix_identity_keys").filter(
(name) => name === "idx_termix_identity_keys_identity",
),
).toHaveLength(1);
// Links follow the rename.
db.sqlite.exec("DELETE FROM ssh_credentials WHERE id = 3");
expect(
db.sqlite
.prepare("SELECT credential_id FROM p_termix_identity_keys")
.get(),
).toEqual({ credential_id: null });
db.sqlite.exec("DELETE FROM p_termix_identity_identities WHERE id = 1");
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
it("creates fresh tables with the same links when there is nothing to adopt", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_credentials (id) VALUES (3)");
},
});
expect(tableExists("termix_identities")).toBe(false);
seed();
expect(indexes("p_termix_identity_keys")).toContain(
"idx_termix_identity_keys_identity",
);
db.sqlite.exec("DELETE FROM ssh_credentials WHERE id = 3");
expect(
db.sqlite
.prepare("SELECT credential_id FROM p_termix_identity_keys")
.get(),
).toEqual({ credential_id: null });
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
expect(count("p_termix_identity_identities")).toBe(0);
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
});
@@ -0,0 +1,595 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import ssh2 from "ssh2";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { ed25519RawFromLine } from "../../src/backend/certificate.js";
import { manifest, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
function ed25519Line(comment = "") {
const pair = ssh2.utils.generateKeyPairSync("ed25519");
const parsed = ssh2.utils.parseKey(pair.public);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key instanceof Error) throw key;
const line = `${key.type} ${key.getPublicSSH().toString("base64")}`;
return comment ? `${line} ${comment}` : line;
}
/** Checks an OpenSSH user certificate's signature against a CA public key. */
function certificateVerifies(certLine: string, caPublicLine: string): boolean {
const blob = Buffer.from(certLine.split(" ")[1], "base64");
let offset = 0;
const skipString = () => {
offset += 4 + blob.readUInt32BE(offset);
};
// type, nonce, key, serial, cert type, key id, principals, valid after,
// valid before, critical options, extensions, reserved, signature key.
skipString();
skipString();
skipString();
offset += 8 + 4;
skipString();
skipString();
offset += 8 + 8;
skipString();
skipString();
skipString();
skipString();
const body = blob.subarray(0, offset);
const signature = blob.subarray(offset + 4);
const typeLength = signature.readUInt32BE(0);
const rawLength = signature.readUInt32BE(4 + typeLength);
const raw = signature.subarray(8 + typeLength, 8 + typeLength + rawLength);
const caRaw = ed25519RawFromLine(caPublicLine)!;
const publicKey = crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: caRaw.toString("base64url") },
format: "jwk",
});
return crypto.verify(null, body, publicKey, raw);
}
async function claim(handle = "alice", user = "user-1") {
const response = await server!.request("POST", "/", {
user,
body: { handle, description: "me" },
});
expect(response.status).toBe(201);
return response.body;
}
describe("identity", () => {
it("claims, checks, renames and deletes a handle", async () => {
server = await startServer();
expect((await server.request("GET", "/me")).body).toEqual({
identity: null,
keys: [],
});
expect((await server.request("GET", "/check/alice")).body).toEqual({
available: true,
valid: true,
});
expect((await server.request("GET", "/check/me")).body.valid).toBe(false);
expect((await server.request("GET", "/check/Bad!")).body.valid).toBe(false);
await claim("Alice");
const me = (await server.request("GET", "/me")).body;
expect(me.identity.handle).toBe("alice");
expect(me.identity.resolverPath).toBe(
"/plugin-api/termix-identity/u/alice",
);
expect(me.identity.resolverUrl).toBe(
"https://termix.test/plugin-api/termix-identity/u/alice",
);
expect((await server.request("GET", "/check/alice")).body.available).toBe(
false,
);
const renamed = await server.request("PUT", "/", {
body: { handle: "alice2" },
});
expect(renamed.status).toBe(200);
expect(renamed.body.handle).toBe("alice2");
expect((await server.request("DELETE", "/")).status).toBe(200);
expect((await server.request("GET", "/me")).body.identity).toBeNull();
expect(server.mock.audits.map((entry) => entry.action)).toEqual(
expect.arrayContaining([
"create_termix_id",
"update_termix_id",
"delete_termix_id",
]),
);
});
it("refuses a second handle, a taken one and an invalid one", async () => {
server = await startServer();
await claim("alice");
const again = await server.request("POST", "/", {
body: { handle: "bob" },
});
expect(again.status).toBe(409);
const taken = await server.request("POST", "/", {
user: "user-2",
body: { handle: "alice" },
});
expect(taken.status).toBe(409);
expect(taken.body.error).toBe("Handle already taken");
const invalid = await server.request("POST", "/", {
user: "user-2",
body: { handle: "-x" },
});
expect(invalid.status).toBe(400);
});
it("deletes the keys and CA with the identity", async () => {
server = await startServer();
await claim();
await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
await server.request("POST", "/ca", { body: {} });
await server.request("DELETE", "/");
const count = (table: string) =>
(
server!.db.sqlite
.prepare(`SELECT COUNT(*) AS n FROM ${table}`)
.get() as { n: number }
).n;
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
});
describe("key publishing", () => {
it("publishes a pasted key and serves it from the public resolver", async () => {
server = await startServer();
await claim();
const line = ed25519Line("laptop");
const added = await server.request("POST", "/keys", {
body: { publicKey: line, label: "Laptop" },
});
expect(added.status).toBe(201);
expect(added.body).toMatchObject({
algorithm: "ED25519",
keyType: "ssh-ed25519",
label: "Laptop",
comment: "laptop",
source: "manual",
enabled: true,
});
const duplicate = await server.request("POST", "/keys", {
body: { publicKey: line },
});
expect(duplicate.status).toBe(409);
const resolved = await server.request("GET", "/u/alice", { user: null });
expect(resolved.status).toBe(200);
expect(resolved.headers.get("cache-control")).toBe("no-store, max-age=0");
expect(resolved.headers.get("x-robots-tag")).toBe("noindex, nofollow");
expect(resolved.text).toBe(
`${line.split(" ").slice(0, 2).join(" ")} #termix-id @alice\n`,
);
});
it("rejects something that is not a public key", async () => {
server = await startServer();
await claim();
const response = await server.request("POST", "/keys", {
body: { publicKey: "not a key" },
});
expect(response.status).toBe(400);
});
it("needs a handle before a key", async () => {
server = await startServer();
const response = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(response.status).toBe(400);
});
it("filters by algorithm and hides disabled keys", async () => {
server = await startServer();
await claim();
const ed = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const rsa = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
await server.request("POST", "/keys", { body: { publicKey: rsa.public } });
const onlyEd = await server.request("GET", "/u/alice/ED25519", {
user: null,
});
expect(onlyEd.text.trim().split("\n")).toHaveLength(1);
expect(onlyEd.text).toContain("ssh-ed25519");
const all = await server.request("GET", "/u/alice", { user: null });
expect(all.text.trim().split("\n")).toHaveLength(2);
const disabled = await server.request("PATCH", `/keys/${ed.body.id}`, {
body: { enabled: false },
});
expect(disabled.body.enabled).toBe(false);
const after = await server.request("GET", "/u/alice", { user: null });
expect(after.text).not.toContain("ssh-ed25519");
});
it("serves an HTML viewer to browsers without an em dash", async () => {
server = await startServer();
await claim();
const page = await server.request("GET", "/u/alice", {
user: null,
headers: { accept: "text/html" },
});
expect(page.headers.get("content-type")).toContain("text/html");
expect(page.text).toContain(
"curl -fsSL https://termix.test/plugin-api/termix-identity/u/alice",
);
expect(page.text).not.toContain("—");
});
it("answers 404 for an unknown handle", async () => {
server = await startServer();
expect(
(await server.request("GET", "/u/nobody", { user: null })).status,
).toBe(404);
});
it("imports the public half of a saved key credential", async () => {
const line = ed25519Line();
server = await startServer({
sshKeyCredentials: [
{ id: 5, name: "Work key", username: "root", publicKey: line },
{ id: 6, name: "Broken", username: null, publicKey: null },
],
});
await claim();
const listed = await server.request("GET", "/credentials");
expect(listed.body.credentials).toEqual([
{ id: 5, name: "Work key" },
{ id: 6, name: "Broken" },
]);
const imported = await server.request("POST", "/keys", {
body: { credentialId: 5 },
});
expect(imported.status).toBe(201);
expect(imported.body).toMatchObject({
source: "credential",
credentialId: 5,
label: "Work key",
});
expect((await server.request("GET", "/linked-credentials")).body).toEqual({
credentialIds: [5],
});
expect(
(await server.request("POST", "/keys", { body: { credentialId: 6 } }))
.status,
).toBe(400);
expect(
(await server.request("POST", "/keys", { body: { credentialId: 99 } }))
.status,
).toBe(404);
});
it("generates a key pair, saves it as a credential and returns the private key once", async () => {
server = await startServer();
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: { type: "ed25519" },
});
expect(generated.status).toBe(201);
expect(generated.body.privateKey).toContain("PRIVATE KEY");
expect(generated.body.credentialId).toBe(1);
expect(server.mock.createdSshKeys).toHaveLength(1);
expect(server.mock.createdSshKeys[0]).toMatchObject({
name: "Termix ID @alice (ED25519)",
publicKey: generated.body.publicKey,
keyType: "ssh-ed25519",
});
const stored = server.db.sqlite
.prepare("SELECT * FROM p_termix_identity_keys")
.all() as Array<Record<string, unknown>>;
expect(stored).toHaveLength(1);
expect(stored[0].credential_id).toBe(1);
expect(JSON.stringify(stored)).not.toContain("PRIVATE KEY");
});
it("generates without saving when asked", async () => {
server = await startServer();
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: { saveCredential: false },
});
expect(generated.status).toBe(201);
expect(generated.body.credentialId).toBeNull();
expect(server.mock.createdSshKeys).toHaveLength(0);
});
it("removes the published key when saving the credential fails", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter(
(capability) => capability !== "credentials:write",
),
});
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: {},
});
expect(generated.status).toBe(500);
expect((await server.request("GET", "/me")).body.keys).toEqual([]);
});
it("only touches the caller's own keys", async () => {
server = await startServer();
await claim();
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(
(
await server.request("DELETE", `/keys/${key.body.id}`, {
user: "user-2",
})
).status,
).toBe(404);
expect(
(await server.request("DELETE", `/keys/${key.body.id}`)).status,
).toBe(200);
});
});
describe("certificate authority", () => {
it("creates a CA, stores its key sealed and publishes the public key", async () => {
server = await startServer();
await claim();
const created = await server.request("POST", "/ca", {
body: { validityDays: 30 },
});
expect(created.status).toBe(201);
expect(created.body.validityDays).toBe(30);
expect(created.body.resolverUrl).toBe(
"https://termix.test/plugin-api/termix-identity/u/alice/ca",
);
const row = server.db.sqlite
.prepare("SELECT private_key FROM p_termix_identity_ca")
.get() as { private_key: string };
expect(row.private_key).not.toContain("PRIVATE KEY");
expect(await server.mock.ctx.secrets.unseal(row.private_key)).toContain(
"PRIVATE KEY",
);
const published = await server.request("GET", "/u/alice/ca", {
user: null,
});
expect(published.status).toBe(200);
expect(published.text).toBe(
`${created.body.publicKey} termix-id-ca@alice\n`,
);
expect((await server.request("POST", "/ca", { body: {} })).status).toBe(
409,
);
});
it("issues a certificate the CA signed, with the principals asked for", async () => {
server = await startServer();
await claim();
const ca = (await server.request("POST", "/ca", { body: {} })).body;
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const issued = await server.request(
"POST",
`/keys/${key.body.id}/certificate`,
{ body: { principals: ["root", " deploy ", ""], validityDays: 2 } },
);
expect(issued.status).toBe(200);
expect(issued.body.principals).toEqual(["root", "deploy"]);
expect(issued.body.validityDays).toBe(2);
expect(issued.body.keyId).toBe(`termix:@alice:${key.body.id}`);
expect(issued.body.certificate).toMatch(
/^ssh-ed25519-cert-v01@openssh.com /,
);
const now = Math.floor(Date.now() / 1000);
expect(issued.body.validBefore).toBeGreaterThan(now + 86400);
expect(issued.body.validBefore).toBeLessThanOrEqual(now + 2 * 86400 + 5);
expect(certificateVerifies(issued.body.certificate, ca.publicKey)).toBe(
true,
);
expect(server.mock.audits.map((entry) => entry.action)).toContain(
"issue_termix_id_certificate",
);
});
it("refuses a certificate without a CA or for a non-Ed25519 key", async () => {
server = await startServer();
await claim();
const ed = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(
(
await server.request("POST", `/keys/${ed.body.id}/certificate`, {
body: {},
})
).status,
).toBe(400);
await server.request("POST", "/ca", { body: {} });
const rsa = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
const rsaKey = await server.request("POST", "/keys", {
body: { publicKey: rsa.public },
});
expect(
(
await server.request("POST", `/keys/${rsaKey.body.id}/certificate`, {
body: {},
})
).status,
).toBe(400);
});
it("rotation replaces the key, so old certificates stop verifying", async () => {
server = await startServer();
await claim();
const first = (await server.request("POST", "/ca", { body: {} })).body;
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const oldCert = (
await server.request("POST", `/keys/${key.body.id}/certificate`, {
body: {},
})
).body.certificate;
const rotated = await server.request("POST", "/ca/rotate", {
body: { validityDays: 7 },
});
expect(rotated.status).toBe(200);
expect(rotated.body.publicKey).not.toBe(first.publicKey);
expect(rotated.body.validityDays).toBe(7);
const published = (
await server.request("GET", "/u/alice/ca", { user: null })
).text;
expect(published).toContain(rotated.body.publicKey);
expect(certificateVerifies(oldCert, rotated.body.publicKey)).toBe(false);
const newCert = (
await server.request("POST", `/keys/${key.body.id}/certificate`, {
body: {},
})
).body;
expect(newCert.validityDays).toBe(7);
expect(
certificateVerifies(newCert.certificate, rotated.body.publicKey),
).toBe(true);
});
it("deletes the CA", async () => {
server = await startServer();
await claim();
await server.request("POST", "/ca", { body: {} });
expect((await server.request("DELETE", "/ca")).status).toBe(200);
expect((await server.request("GET", "/ca")).body).toEqual({ ca: null });
expect((await server.request("DELETE", "/ca")).status).toBe(404);
expect(
(await server.request("GET", "/u/alice/ca", { user: null })).status,
).toBe(404);
});
});
describe("access", () => {
const managementRoutes: Array<[string, string]> = [
["GET", "/me"],
["GET", "/check/alice"],
["POST", "/"],
["PUT", "/"],
["DELETE", "/"],
["POST", "/keys"],
["POST", "/keys/generate"],
["PATCH", "/keys/1"],
["DELETE", "/keys/1"],
["POST", "/keys/1/certificate"],
["GET", "/ca"],
["POST", "/ca"],
["POST", "/ca/rotate"],
["DELETE", "/ca"],
["GET", "/linked-credentials"],
["GET", "/credentials"],
];
it("serves the resolver without auth", async () => {
server = await startServer({ permissions: [] });
await server.db.sqlite
.prepare(
"INSERT INTO p_termix_identity_identities (user_id, handle) VALUES ('user-1', 'alice')",
)
.run();
for (const path of ["/u/alice", "/u/alice/ED25519"]) {
expect((await server.request("GET", path, { user: null })).status).toBe(
200,
);
}
expect(
(await server.request("GET", "/u/alice/ca", { user: null })).status,
).toBe(404);
});
it.each(managementRoutes)("needs a login for %s %s", async (method, path) => {
server = await startServer();
const response = await server.request(method, path, {
user: null,
body: method === "GET" ? undefined : {},
});
expect(response.status).toBe(401);
});
it.each(managementRoutes)(
"needs termix-identity.use for %s %s",
async (method, path) => {
server = await startServer({ permissions: [] });
const response = await server.request(method, path, {
body: method === "GET" ? undefined : {},
});
expect(response.status).toBe(403);
},
);
});
describe("activate", () => {
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(capability);
},
);
it("cannot seal a CA key without secrets:own", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
});
await claim();
expect((await server.request("POST", "/ca", { body: {} })).status).toBe(
500,
);
expect((await server.request("GET", "/ca")).body).toEqual({ ca: null });
});
it("cannot read saved keys without credentials:use", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter(
(c) => c !== "credentials:use",
),
sshKeyCredentials: [{ id: 5, name: "k", username: null, publicKey: "x" }],
});
expect((await server.request("GET", "/credentials")).status).toBe(500);
});
});
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
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 manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
type ElectronWindow = Window & { electronAPI?: unknown };
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
delete (window as ElectronWindow).electronAPI;
});
const IDENTITY = {
id: 1,
userId: "user-1",
handle: "alice",
description: null,
resolverPath: "/plugin-api/termix-identity/u/alice",
resolverUrl: "https://termix.test/plugin-api/termix-identity/u/alice",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const KEY = {
id: 4,
identityId: 1,
userId: "user-1",
publicKey: "ssh-ed25519 AAAAC3Nza",
keyType: "ssh-ed25519",
algorithm: "ED25519",
label: "Laptop",
comment: null,
source: "credential",
credentialId: 9,
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
};
function stubApi(options: { identity?: boolean; linked?: number[] } = {}) {
const routes: Record<string, unknown> = {
"/me":
options.identity === false
? { identity: null, keys: [] }
: { identity: IDENTITY, keys: [KEY] },
"/ca": { ca: null },
"/linked-credentials": { credentialIds: options.linked ?? [9] },
"/credentials": { credentials: [{ id: 9, name: "Work key" }] },
"/check/bob": { available: true, valid: true },
};
return {
get: vi.fn(async (url: string) => ({ data: routes[url] })),
post: vi.fn(async () => ({ data: IDENTITY })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: KEY })),
delete: vi.fn(async () => ({ data: { success: true } })),
};
}
describe(`${manifest.id} activate`, () => {
it("registers the rail item, panel, tab and credential badge", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({
id: "termix-id",
hidden: false,
permission: "termix-identity.use",
}),
]);
expect(rendered.registered.panels()).toEqual(["termix-id"]);
expect(rendered.registered.tabs()).toEqual(["termix-id"]);
expect(rendered.registered.slot("credentials.badges")).toEqual([
"termix-identity.credentialBadge",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.slot("credentials.badges")).toEqual([]);
});
it("hides the rail item in a standalone desktop app", async () => {
(window as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => null),
onRemoteSyncStatusChanged: () => () => {},
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
expect(rendered!.registered.railItems()).toEqual([
expect.objectContaining({ id: "termix-id", hidden: true }),
]),
);
});
it("shows the rail item once the desktop app has a remote server", async () => {
let notify = () => {};
let serverUrl: string | null = null;
(window as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)),
onRemoteSyncStatusChanged: (callback: () => void) => {
notify = callback;
return () => {};
},
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
expect(rendered!.registered.railItems()[0]?.hidden).toBe(true),
);
serverUrl = "https://termix.example";
notify();
await waitFor(() =>
expect(rendered!.registered.railItems()).toEqual([
expect.objectContaining({ id: "termix-id", hidden: false }),
]),
);
});
});
describe("termix id panel", () => {
it("offers to claim a handle when the user has none", async () => {
const api = stubApi({ identity: false });
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
rendered.renderPanel("termix-id");
expect(await screen.findByText(locales.termixId.claimIntro)).toBeTruthy();
fireEvent.change(
screen.getByPlaceholderText(locales.termixId.handlePlaceholder),
{ target: { value: "bob" } },
);
expect(await screen.findByText(locales.termixId.available)).toBeTruthy();
fireEvent.click(screen.getByText(locales.termixId.create));
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith("/", {
handle: "bob",
description: undefined,
}),
);
});
it("shows the handle, the resolver URL and the published keys", async () => {
const api = stubApi();
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
rendered.renderPanel("termix-id");
expect(await screen.findByText("@alice")).toBeTruthy();
expect(screen.getByText(IDENTITY.resolverUrl)).toBeTruthy();
expect(screen.getByText("Laptop")).toBeTruthy();
expect(screen.getByText(KEY.publicKey)).toBeTruthy();
expect(api.get).toHaveBeenCalledWith("/credentials");
});
});
describe("credential badge", () => {
it("marks a credential whose key is published", async () => {
const api = stubApi({ linked: [9] });
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
permissions: ["termix-identity.use"],
});
const linked = rendered.renderSlot("credentials.badges", {
credentialId: 9,
});
await waitFor(() =>
expect(linked.textContent).toContain(locales.credentials.idBadge),
);
expect(api.get).toHaveBeenCalledWith("/linked-credentials");
});
it("shows nothing for a credential that is not published", async () => {
const api = stubApi({ linked: [9] });
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
permissions: ["termix-identity.use"],
});
const other = rendered.renderSlot("credentials.badges", {
credentialId: 3,
});
await waitFor(() =>
expect(api.get).toHaveBeenCalledWith("/linked-credentials"),
);
expect(other.textContent).not.toContain(locales.credentials.idBadge);
});
it("does not ask without termix-identity.use", async () => {
const api = stubApi();
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
permissions: [],
});
rendered.renderSlot("credentials.badges", { credentialId: 9 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(api.get).not.toHaveBeenCalledWith("/linked-credentials");
});
});
+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);
+7 -3
View File
@@ -7,7 +7,10 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import { registerVaultCompatRoutes } from "./routes/host-compat-routes.js";
import {
registerTermixIdCompatRoutes,
registerVaultCompatRoutes,
} from "./routes/host-compat-routes.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
@@ -17,7 +20,6 @@ import userPreferencesRoutes from "./routes/user-preferences.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
@@ -1793,7 +1795,9 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/termix-id", termixIdRoutes);
const termixIdCompatRoutes = express.Router();
registerTermixIdCompatRoutes(termixIdCompatRoutes);
app.use("/termix-id", termixIdCompatRoutes);
registerAuditLogRoutes(app, authenticateJWT);
const vaultCompatRoutes = express.Router();
registerVaultCompatRoutes(vaultCompatRoutes);
-105
View File
@@ -1204,111 +1204,6 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
try {
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
handle TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identities table", {
operation: "schema_migration",
error: createError,
});
}
}
// Enforce one-Termix-ID-per-user on databases where the table predates the
// UNIQUE(user_id) constraint above.
try {
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
);
} catch (indexError) {
databaseLogger.warn("Failed to create termix_identities user_id unique index", {
operation: "schema_migration",
error: indexError,
});
}
try {
sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identity_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
algorithm TEXT NOT NULL,
label TEXT,
comment TEXT,
source TEXT NOT NULL DEFAULT 'manual',
credential_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identity_keys table", {
operation: "schema_migration",
error: createError,
});
}
}
// The public resolver fetches keys by identity_id on every request; index it.
try {
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
);
} catch (indexError) {
databaseLogger.warn("Failed to create termix_identity_keys identity index", {
operation: "schema_migration",
error: indexError,
});
}
try {
sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identity_ca table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
+2 -72
View File
@@ -772,78 +772,8 @@ export const uiPreferences = mysqlTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = mysqlTable("termix_identities", {
id: int("id").autoincrement().primaryKey(),
// One Termix ID per user — enforced in schema, not just in code.
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
id: int("id").autoincrement().primaryKey(),
identityId: int("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key").notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = mysqlTable("termix_identity_ca", {
id: int("id").autoincrement().primaryKey(),
identityId: int("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: int("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
+2 -72
View File
@@ -773,78 +773,8 @@ export const uiPreferences = pgTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = pgTable("termix_identities", {
id: serial("id").primaryKey(),
// One Termix ID per user — enforced in schema, not just in code.
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const termixIdentityKeys = pgTable("termix_identity_keys", {
id: serial("id").primaryKey(),
identityId: integer("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key").notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = pgTable("termix_identity_ca", {
id: serial("id").primaryKey(),
identityId: integer("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: integer("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
+2 -72
View File
@@ -769,78 +769,8 @@ export const uiPreferences = sqliteTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = sqliteTable("termix_identities", {
id: integer("id").primaryKey({ autoIncrement: true }),
// One Termix ID per user — enforced in schema, not just in code.
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: text("handle").notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const termixIdentityKeys = sqliteTable("termix_identity_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
identityId: integer("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key", { length: 8192 }).notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = sqliteTable("termix_identity_ca", {
id: integer("id").primaryKey({ autoIncrement: true }),
identityId: integer("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key", { length: 4096 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
validityDays: integer("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
@@ -36,8 +36,6 @@ import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-re
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -349,20 +347,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
);
}
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
return new TermixIdentityCaRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
);
}
export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
return new TermixIdentityRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("termix_identity_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
@@ -1,223 +0,0 @@
import { eq } from "drizzle-orm";
import { termixIdentityCa } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
insertedId,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { updateReturning } from "./returning.js";
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
export type TermixIdentityCaUpdate = Partial<
Pick<
NewTermixIdentityCaRecord,
"publicKey" | "privateKey" | "validityDays" | "updatedAt"
>
>;
export class TermixIdentityCaRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findPublicByIdentityId(
identityId: number,
): Promise<Pick<
TermixIdentityCaRecord,
"publicKey" | "validityDays"
> | null> {
const rows = await this.context.drizzle
.select({
publicKey: termixIdentityCa.publicKey,
validityDays: termixIdentityCa.validityDays,
})
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.limit(1);
return rows[0] ?? null;
}
async findDecryptedByIdentityId(
userId: string,
identityId: number,
): Promise<TermixIdentityCaRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.limit(1);
return this.decryptOne(rows[0] ?? null, userId);
}
async createEncryptedForUser(
userId: string,
ca: NewTermixIdentityCaRecord,
): Promise<TermixIdentityCaRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
await this.afterWrite();
return DataCrypto.decryptRecord(
"termix_identity_ca",
result,
userId,
userDataKey,
);
}
/**
* Writes a CA in two steps, because the ciphertext depends on the id.
*
* The private key is encrypted with the row's own id as context, which does
* not exist until the row does. So: insert with an empty key, encrypt, update.
* The empty key must never be observable, hence the transaction.
*
* Two branches because better-sqlite3 rejects an async transaction callback —
* see the same note in UserRepository.
*/
private async insertThenEncrypt(
userId: string,
ca: NewTermixIdentityCaRecord,
userDataKey: Buffer,
): Promise<TermixIdentityCaRecord> {
const draft = { ...ca, privateKey: "" };
const seal = (id: number) =>
DataCrypto.encryptRecord(
"termix_identity_ca",
{ id, privateKey: ca.privateKey },
userId,
userDataKey,
).privateKey;
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 needs the synchronous
.all() form, which has no async equivalent. */
return this.context.drizzle.transaction((tx) => {
const row = tx
.insert(termixIdentityCa)
.values(draft)
.returning()
.all()[0];
return tx
.update(termixIdentityCa)
.set({ privateKey: seal(row.id) })
.where(eq(termixIdentityCa.id, row.id))
.returning()
.all()[0];
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
let id: number | null;
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check above
const rows = await tx
.insert(termixIdentityCa)
.values(draft)
.returning();
id = rows[0]?.id ?? null;
} else {
id = insertedId(await tx.insert(termixIdentityCa).values(draft));
}
if (id === null) {
throw new Error("Insert into termix_identity_ca returned no id.");
}
await tx
.update(termixIdentityCa)
.set({ privateKey: seal(id) })
.where(eq(termixIdentityCa.id, id));
const [row] = await tx
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.id, id));
return row;
});
}
async updateEncryptedForIdentity(
userId: string,
identityId: number,
update: TermixIdentityCaUpdate,
): Promise<TermixIdentityCaRecord | null> {
const existing = await this.findDecryptedByIdentityId(userId, identityId);
if (!existing) return null;
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedPrivateKey = update.privateKey
? DataCrypto.encryptRecord(
"termix_identity_ca",
{ id: existing.id, privateKey: update.privateKey },
userId,
userDataKey,
).privateKey
: undefined;
const rows = await updateReturning(
this.context,
termixIdentityCa,
{
...update,
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
},
eq(termixIdentityCa.identityId, identityId),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
}
async deleteByIdentityId(identityId: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private decryptOne<T extends Record<string, unknown>>(
record: T | null,
userId: string,
): T | null {
if (!record) return null;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return null;
return DataCrypto.decryptRecord(
"termix_identity_ca",
record,
userId,
userDataKey,
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,237 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
export type TermixIdentityUpdate = Partial<
Pick<NewTermixIdentityRecord, "handle" | "description" | "updatedAt">
>;
export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect;
export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert;
export type TermixIdentityKeyUpdate = Partial<
Pick<NewTermixIdentityKeyRecord, "enabled" | "label">
>;
export class TermixIdentityRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findIdentityForUser(
userId: string,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async findIdentityByHandle(
handle: string,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
return rows[0] ?? null;
}
async isHandleTaken(handle: string): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
return rows.length > 0;
}
async createIdentity(
identity: NewTermixIdentityRecord,
): Promise<TermixIdentityRecord> {
const rows = await insertReturning(
this.context,
termixIdentities,
identity,
);
await this.afterWrite();
return rows[0];
}
async updateIdentityForUser(
userId: string,
update: TermixIdentityUpdate,
): Promise<TermixIdentityRecord | null> {
const rows = await updateReturning(
this.context,
termixIdentities,
update,
eq(termixIdentities.userId, userId),
);
if (rows.length > 0) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async deleteIdentityForUser(userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<{
identitiesDeleted: number;
keysDeleted: number;
}> {
const keyResult = await this.context.drizzle
.delete(termixIdentityKeys)
.where(eq(termixIdentityKeys.userId, userId));
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId));
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
identitiesDeleted: rowsAffected(result),
keysDeleted: rowsAffected(keyResult),
};
}
async listKeysByIdentityId(
identityId: number,
): Promise<TermixIdentityKeyRecord[]> {
return this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(eq(termixIdentityKeys.identityId, identityId))
.orderBy(asc(termixIdentityKeys.id));
}
async listEnabledKeysByIdentityId(
identityId: number,
): Promise<TermixIdentityKeyRecord[]> {
return this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identityId),
eq(termixIdentityKeys.enabled, true),
),
)
.orderBy(asc(termixIdentityKeys.id));
}
async listLinkedCredentialIds(identityId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ credentialId: termixIdentityKeys.credentialId })
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identityId),
eq(termixIdentityKeys.enabled, true),
),
);
return Array.from(
new Set(
rows
.map((row) => row.credentialId)
.filter(
(credentialId): credentialId is number => credentialId !== null,
),
),
);
}
async createKey(
key: NewTermixIdentityKeyRecord,
): Promise<TermixIdentityKeyRecord> {
const rows = await insertReturning(this.context, termixIdentityKeys, key);
await this.afterWrite();
return rows[0];
}
async updateKeyForUser(
userId: string,
id: number,
update: TermixIdentityKeyUpdate,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await updateReturning(
this.context,
termixIdentityKeys,
update,
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
);
if (rows.length > 0) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async findKeyForUser(
userId: string,
id: number,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -18,8 +18,6 @@ import {
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
@@ -95,10 +93,8 @@ export async function deleteUserAndRelatedData(
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way.
// plugins' tables cascade the same way, and so do termix-identity's.
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -69,3 +69,33 @@ export function registerVaultCompatRoutes(router: Router): void {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
/**
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
* scripts. Permanently redirected to the termix-identity plugin's public
* resolver; curl -L follows them.
*/
export function registerTermixIdCompatRoutes(router: Router): void {
/**
* @openapi
* /termix-id/u/{handle}:
* get:
* summary: Termix ID resolver (2.8 URL)
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
* tags:
* - Termix ID
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 308:
* description: Redirect to the termix-identity plugin's resolver.
*/
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
const rest = req.path.slice("/u".length);
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
});
}
File diff suppressed because it is too large. Load diff
+125
View File
@@ -13,13 +13,19 @@
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*
* listSshKeys and createSshKey reach the acting user's own saved keys: the
* public half only (credentials:use), and saving a new key pair
* (credentials:write). Termix Identity publishes and generates keys this way.
*/
import type {
PluginCredentials,
PluginHostProtocol,
PluginProtocolTarget,
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
import ssh2 from "ssh2";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
@@ -157,6 +163,47 @@ async function resolveRecipientAuth(
};
}
function derivePublicKey(
privateKey: string | null | undefined,
passphrase: string | null | undefined,
): string | null {
if (!privateKey) return null;
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (!key || key instanceof Error) return null;
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function requireCorePermission(
userId: string,
permission: string,
): Promise<void> {
const { PermissionManager } = await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(userId, permission))
) {
throw new Error(`The acting user lacks ${permission}`);
}
}
function actingUser(): string {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
}
export function createPluginCredentials({
manifest,
bag,
@@ -166,6 +213,84 @@ export function createPluginCredentials({
const declared = manifest.capabilities;
return {
listSshKeys: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const keys: PluginSshKeyCredential[] = rows
.filter((row) => row.authType === "key")
.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
publicKey:
(typeof row.publicKey === "string" && row.publicKey.trim()) ||
derivePublicKey(row.privateKey || row.key, row.keyPassword),
}));
await audit("credentials_list_keys", `${keys.length} keys`, {
success: true,
});
return keys;
} catch (error) {
await audit("credentials_list_keys", "saved SSH keys", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
await assertCapability(pluginId, "credentials:write", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.create");
if (!input?.name || !input.privateKey || !input.publicKey) {
throw new Error("name, privateKey and publicKey are required");
}
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
{
userId,
name: input.name,
description: input.description ?? null,
folder: null,
tags: "",
authType: "key",
username: input.username || null,
password: null,
key: input.privateKey,
privateKey: input.privateKey,
publicKey: input.publicKey,
keyPassword: null,
keyType: null,
detectedKeyType: input.keyType,
usageCount: 0,
lastUsed: null,
},
);
await audit("credentials_create_key", details, { success: true });
return { id: created.id };
} catch (error) {
await audit("credentials_create_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
+6
View File
@@ -392,6 +392,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runTotpMigration } =
await import("./utils/crypto-migration/totp-migration.js");
await runTotpMigration();
// Also after the plugin moves, for a CA whose owner key was not open
// when the table was adopted.
const { runTermixIdentityCaMigration } =
await import("./utils/crypto-migration/termix-identity-ca-migration.js");
await runTermixIdentityCaMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -69,8 +69,9 @@ describe("generated schemas", () => {
expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema));
expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema));
// Guard against a generator that silently emits nothing.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40);
// Guard against a generator that silently emits nothing. Core keeps
// shrinking as feature tables move into plugins, so this stays loose.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(25);
});
it("maps each column to the right storage type per dialect", () => {
@@ -1,224 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js";
describe("TermixIdentityCaRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.restoreAllMocks();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityCaRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO termix_identities (id, user_id, handle)
VALUES (7, 'user-1', 'alice');
`);
return {
repo: new TermixIdentityCaRepository(context, onWrite),
onWrite,
};
}
function mockCrypto(): void {
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey: "encrypted-ca-private",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey:
record.privateKey === "encrypted-ca-private"
? "decrypted-ca-private"
: record.privateKey,
}) as typeof record,
);
}
it("creates CA private keys with the real row id before encryption", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
const created = await repo.createEncryptedForUser("user-1", {
identityId: 7,
userId: "user-1",
publicKey: "ssh-ed25519 public",
privateKey: "plain-ca-private",
validityDays: 120,
});
const [raw] = (await adapter!.query(
sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
id: number;
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(created.privateKey).toBe("decrypted-ca-private");
expect(raw.private_key).toBe("encrypted-ca-private");
expect(raw.public_key).toBe("ssh-ed25519 public");
expect(raw.validity_days).toBe(120);
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
{ id: raw.id, privateKey: "plain-ca-private" },
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("reads public CA metadata without decrypting private key material", async () => {
const { repo } = await createRepository();
const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord");
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({
publicKey: "ssh-ed25519 public",
validityDays: 45,
});
expect(decryptSpy).not.toHaveBeenCalled();
});
it("decrypts CA private keys through the user data boundary", async () => {
const { repo } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
const ca = await repo.findDecryptedByIdentityId("user-1", 7);
expect(ca).toMatchObject({
identityId: 7,
publicKey: "ssh-ed25519 public",
privateKey: "decrypted-ca-private",
validityDays: 45,
});
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({ identityId: 7 }),
"user-1",
Buffer.from("user-key"),
);
});
it("updates CA private keys through encrypted writes", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
const updated = await repo.updateEncryptedForIdentity("user-1", 7, {
publicKey: "ssh-ed25519 new",
privateKey: "plain-updated-ca-private",
validityDays: 90,
});
const [raw] = (await adapter!.query(
sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(updated).toMatchObject({
publicKey: "ssh-ed25519 new",
privateKey: "decrypted-ca-private",
validityDays: 90,
});
expect(raw).toEqual({
public_key: "ssh-ed25519 new",
private_key: "encrypted-ca-private",
validity_days: 90,
});
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({
privateKey: "plain-updated-ca-private",
}),
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows through the write boundary", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
await expect(repo.deleteByIdentityId(7)).resolves.toBe(true);
await expect(repo.deleteByIdentityId(7)).resolves.toBe(false);
expect(
(
await adapter!.query(
sql`SELECT COUNT(*) AS count FROM termix_identity_ca`,
)
).map((row) => Number((row as { count: unknown }).count)),
).toEqual([0]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows for a user", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`,
);
await adapter!.run(
sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`,
);
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(
await adapter!.query(
sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`,
),
).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,170 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TermixIdentityRepository } from "../../../database/repositories/termix-identity-repository.js";
describe("TermixIdentityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(10, 'user-1', 'cred-10', 'root', 'password'),
(20, 'user-1', 'cred-20', 'root', 'password');
`);
return {
repo: new TermixIdentityRepository(context, onWrite),
onWrite,
};
}
it("creates, updates, finds, and deletes identities", async () => {
const { repo, onWrite } = await createRepository();
const created = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: "workstation keys",
});
expect(created.id).toBeGreaterThan(0);
expect(await repo.isHandleTaken("alice")).toBe(true);
expect(await repo.findIdentityForUser("user-1")).toMatchObject({
handle: "alice",
});
expect(await repo.findIdentityByHandle("alice")).toMatchObject({
userId: "user-1",
});
const updated = await repo.updateIdentityForUser("user-1", {
handle: "alice-renamed",
description: null,
});
expect(updated).toMatchObject({
handle: "alice-renamed",
description: null,
});
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true);
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false);
await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(3);
});
it("creates, lists, updates, deletes, and links public keys", async () => {
const { repo, onWrite } = await createRepository();
const identity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
onWrite.mockClear();
const first = await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
label: "laptop",
comment: null,
source: "credential",
credentialId: 10,
});
await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-rsa AAAA2",
keyType: "ssh-rsa",
algorithm: "RSA",
label: "disabled",
comment: null,
source: "manual",
credentialId: 20,
enabled: false,
});
expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2);
expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([
{ id: first.id, publicKey: "ssh-ed25519 AAAA1" },
]);
expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]);
const updated = await repo.updateKeyForUser("user-1", first.id, {
enabled: false,
label: "revoked",
});
expect(updated).toMatchObject({ enabled: false, label: "revoked" });
await expect(
repo.findKeyForUser("user-1", first.id),
).resolves.toMatchObject({ label: "revoked" });
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true);
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(
false,
);
expect(onWrite).toHaveBeenCalledTimes(4);
});
it("deletes identities and keys for a user", async () => {
const { repo, onWrite } = await createRepository();
const userIdentity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
const otherIdentity = await repo.createIdentity({
userId: "user-2",
handle: "bob",
description: null,
});
await repo.createKey({
identityId: userIdentity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
await repo.createKey({
identityId: otherIdentity.id,
userId: "user-2",
publicKey: "ssh-ed25519 AAAA2",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
identitiesDeleted: 1,
keysDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
identitiesDeleted: 0,
keysDeleted: 0,
});
expect(await repo.findIdentityForUser("user-1")).toBeNull();
expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]);
expect(await repo.findIdentityForUser("user-2")).toMatchObject({
handle: "bob",
});
expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,7 @@
/**
* The OPKSSH, Step CA and Vault 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, and the Termix
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
*/
import http from "node:http";
@@ -8,7 +9,7 @@ import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes } =
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
@@ -19,6 +20,9 @@ beforeEach(async () => {
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
const termixId = express.Router();
registerTermixIdCompatRoutes(termixId);
app.use("/termix-id", termixId);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
@@ -88,3 +92,25 @@ describe("the old Vault callback", () => {
}
});
});
describe("the old Termix ID resolver URLs", () => {
it.each([
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
[
"/termix-id/u/alice/ED25519",
"/plugin-api/termix-identity/u/alice/ED25519",
],
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
])("permanently redirects %s", async (path, target) => {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(target);
});
it("does not forward the old management routes", async () => {
const response = await fetch(`${base}/termix-id/me`, {
redirect: "manual",
});
expect(response.status).toBe(404);
});
});
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more