feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

+5 -2
View File
@@ -88,6 +88,7 @@ FROM node:26-slim
WORKDIR /app WORKDIR /app
ENV DATA_DIR=/app/data \ ENV DATA_DIR=/app/data \
OPKSSH_BUNDLED_DIR=/app/opkssh-bundled \
PORT=8080 \ PORT=8080 \
NODE_ENV=production \ NODE_ENV=production \
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
@@ -95,9 +96,9 @@ ENV DATA_DIR=/app/data \
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \ RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
update-ca-certificates && \ update-ca-certificates && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \ mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
chown -R node:node /app /tmp/nginx && \ chown -R node:node /app /tmp/nginx && \
chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx chmod 755 /app/data /app/uploads /app/nginx /tmp/nginx
COPY docker/nginx.conf /app/nginx/nginx.conf.template COPY docker/nginx.conf /app/nginx/nginx.conf.template
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template
@@ -112,6 +113,8 @@ COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
# Target of node_modules/@termix/plugin-sdk, which core and the plugins import. # Target of node_modules/@termix/plugin-sdk, which core and the plugins import.
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
# Pre-baked OPKSSH binary. The opkssh plugin uses it when its checksum
# matches, so an offline install never downloads one.
COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled
COPY --chown=node:node package.json ./ COPY --chown=node:node package.json ./
# Schema for Postgres and MySQL. Unused by the default SQLite deployment, which # Schema for Postgres and MySQL. Unused by the default SQLite deployment, which
+3 -18
View File
@@ -73,31 +73,16 @@ if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then
sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf
fi fi
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge mkdir -p /app/data /app/uploads /app/data/acme-webroot/.well-known/acme-challenge
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true chmod 755 /app/data /app/uploads 2>/dev/null || true
if [ -w /app/data ]; then if [ -w /app/data ]; then
echo "Data directory is writable" echo "Data directory is writable"
else else
echo "WARNING: Data directory is not writable. OPKSSH may fail." echo "WARNING: Data directory is not writable. Plugins that keep files (OPKSSH, recordings) may fail."
ls -ld /app/data ls -ld /app/data
fi fi
if [ -w /app/data/.opk ]; then
echo "OPKSSH directory is writable"
else
echo "WARNING: OPKSSH directory is not writable. OPKSSH authentication will fail."
ls -ld /app/data/.opk
fi
OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh"
if [ ! -d "$OPKSSH_DIR" ]; then
echo "OPKSSH binary directory not found at $OPKSSH_DIR"
echo "OPKSSH will be installed from the bundled copy on first use (falls back to downloading if unavailable)."
else
echo "OPKSSH binary directory found at $OPKSSH_DIR"
fi
if [ "$ENABLE_SSL" = "true" ]; then if [ "$ENABLE_SSL" = "true" ]; then
echo "Checking SSL certificate configuration..." echo "Checking SSL certificate configuration..."
mkdir -p /app/data/ssl mkdir -p /app/data/ssl
-34
View File
@@ -482,40 +482,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/host/opkssh-chooser(/.*)?$ {
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_cache_bypass 1;
proxy_no_cache 1;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
}
location ~ ^/host/opkssh-callback(/.*)?$ {
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_cache_bypass 1;
proxy_no_cache 1;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
}
location /host/ { location /host/ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
-32
View File
@@ -462,38 +462,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/host/opkssh-chooser(/.*)?$ {
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_cache_bypass 1;
proxy_no_cache 1;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
}
location ~ ^/host/opkssh-callback(/.*)?$ {
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_cache_bypass 1;
proxy_no_cache 1;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
}
location /host/ { location /host/ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+5
View File
@@ -0,0 +1,5 @@
-- opkssh_tokens moves to the opkssh plugin's adopted table at activation,
-- and ssh_data.use_warpgate is copied into the warpgate plugin's host
-- settings by a boot migration after this runs. Dropping either here would
-- lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -372,6 +372,13 @@
"when": 1790287990810, "when": 1790287990810,
"tag": "0052_illegal_gladiator", "tag": "0052_illegal_gladiator",
"breakpoints": true "breakpoints": true
},
{
"idx": 53,
"version": "5",
"when": 1790295071302,
"tag": "0053_magical_groot",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,5 @@
-- opkssh_tokens moves to the opkssh plugin's adopted table at activation,
-- and ssh_data.use_warpgate is copied into the warpgate plugin's host
-- settings by a boot migration after this runs. Dropping either here would
-- lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -379,6 +379,13 @@
"when": 1790287990260, "when": 1790287990260,
"tag": "0053_keen_thanos", "tag": "0053_keen_thanos",
"breakpoints": true "breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1790295070561,
"tag": "0054_cloudy_galactus",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,5 @@
-- opkssh_tokens moves to the opkssh plugin's adopted table at activation,
-- and ssh_data.use_warpgate is copied into the warpgate plugin's host
-- settings by a boot migration after this runs. Dropping either here would
-- lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -351,6 +351,13 @@
"when": 1790287989611, "when": 1790287989611,
"tag": "0049_glamorous_hemingway", "tag": "0049_glamorous_hemingway",
"breakpoints": true "breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1790295069807,
"tag": "0050_clear_white_queen",
"breakpoints": true
} }
] ]
} }
+26 -2
View File
@@ -29,7 +29,6 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"js-yaml": "^5.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2", "jszip": "^3.10.2",
"motion": "^13.1.1", "motion": "^13.1.1",
@@ -89,7 +88,6 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/guacamole-common-js": "^1.5.5", "@types/guacamole-common-js": "^1.5.5",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
@@ -7003,6 +7001,10 @@
"resolved": "plugins/network-topology", "resolved": "plugins/network-topology",
"link": true "link": true
}, },
"node_modules/@termix-plugin/opkssh": {
"resolved": "plugins/opkssh",
"link": true
},
"node_modules/@termix-plugin/proxmox": { "node_modules/@termix-plugin/proxmox": {
"resolved": "plugins/proxmox", "resolved": "plugins/proxmox",
"link": true "link": true
@@ -7059,6 +7061,10 @@
"resolved": "plugins/wake-on-lan", "resolved": "plugins/wake-on-lan",
"link": true "link": true
}, },
"node_modules/@termix-plugin/warpgate": {
"resolved": "plugins/warpgate",
"link": true
},
"node_modules/@termix-plugin/web-endpoint": { "node_modules/@termix-plugin/web-endpoint": {
"resolved": "plugins/web-endpoint", "resolved": "plugins/web-endpoint",
"link": true "link": true
@@ -18632,6 +18638,17 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/opkssh": {
"name": "@termix-plugin/opkssh",
"version": "1.0.0",
"dependencies": {
"js-yaml": "^5.2.3"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/js-yaml": "^4.0.9"
}
},
"plugins/proxmox": { "plugins/proxmox": {
"name": "@termix-plugin/proxmox", "name": "@termix-plugin/proxmox",
"version": "1.0.0", "version": "1.0.0",
@@ -18749,6 +18766,13 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/warpgate": {
"name": "@termix-plugin/warpgate",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/web-endpoint": { "plugins/web-endpoint": {
"name": "@termix-plugin/web-endpoint", "name": "@termix-plugin/web-endpoint",
"version": "1.0.0", "version": "1.0.0",
-2
View File
@@ -71,7 +71,6 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"js-yaml": "^5.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2", "jszip": "^3.10.2",
"motion": "^13.1.1", "motion": "^13.1.1",
@@ -131,7 +130,6 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/guacamole-common-js": "^1.5.5", "@types/guacamole-common-js": "^1.5.5",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
+143 -48
View File
@@ -38,6 +38,7 @@ dependencies. Entry points:
| `@termix/plugin-sdk/capabilities` | The capability catalog | | `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator | | `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` | | `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` |
| `@termix/plugin-sdk/ssh-certs` | `applyCertificateAuth()`: OpenSSH certificate auth on an ssh2 client, for plugins that issue short-lived certificates |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more | | `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from Plugins never import from `src/` or `@/`, and core never imports from
@@ -217,7 +218,9 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
audited when the router is registered and logged on every request, because audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. It matches the "this plugin opened a hole in auth" should be findable later. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token` full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
deliberate prefix (**C3**): `/chooser/:id/*` opens that path and everything
below it, for a page the plugin proxies whose assets it cannot list.
2. **The actor**, from the user core authenticated, never from the request body. 2. **The actor**, from the user core authenticated, never from the request body.
3. **An enabled check** returning 503 while the plugin is disabled. That is a 3. **An enabled check** returning 503 while the plugin is disabled. That is a
different fact from 404 ("no such plugin") and a caller can act on it. different fact from 404 ("no such plugin") and a caller can act on it.
@@ -476,7 +479,10 @@ bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`, Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9** `terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot, renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`, where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay
components `send(type, data)` for the session socket and `connectPayload()`,
the data an `<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`, `onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and `shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and
`dashboard.secondaryView`. **B16** added the middle two, component slots in `dashboard.secondaryView`. **B16** added the middle two, component slots in
@@ -577,12 +583,15 @@ tooltip, sheet, section-card, metric-card, charts, the card grid,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog` (its `MFAPromptMode` type lives in `TOTPDialog` (its `MFAPromptMode` type lives in
`@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`), `@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`),
`SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`, `SSHAuthDialog`, `BrowserSignInDialog`, `PassphraseDialog`,
`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`, `HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and `useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from). `FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
**B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor **B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor
plugin's session tree and its new-session/kill popovers. plugin's session tree and its new-session/kill popovers. **C3** removed
`OPKSSHDialog` (the opkssh plugin draws its own) and replaced `WarpgateDialog`
with `BrowserSignInDialog` (`label`, `url`, `code`), the one dialog every
transport shows for a keyboard-interactive browser round.
**B9** added what a terminal-like surface needs from the shell: `cn`, **B9** added what a terminal-like surface needs from the shell: `cn`,
`useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look `useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look
@@ -999,7 +1008,7 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`. SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, OPKSSH, Step-CA, Vault, Tailscale and Warpgate still live in core. In 2.9.0, Step-CA and Vault still live in core.
They register through the same interfaces from They register through the same interfaces from
`src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx` `src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx`
with `pluginId: "core"`. Phase C moves each one into its plugin by moving its with `pluginId: "core"`. Phase C moves each one into its plugin by moving its
@@ -1089,6 +1098,50 @@ Nothing else in core branches on those type names.
live in `database/routes/auth-compat-routes.ts` and only forward to the live in `database/routes/auth-compat-routes.ts` and only forward to the
registered methods, so with the plugins off they answer 404. registered methods, so with the plugins off they answer 404.
**C3** moved OPKSSH and Warpgate out:
- `opkssh` registers the `opkssh` SSH auth type. It adopts `opkssh_tokens` as
`p_opkssh_tokens`, the certificate and key sealed with `ctx.secrets.seal`.
Rows 2.8 wrote were encrypted with the user's data key, which a plugin cannot
open: they read as expired and the user signs in once, which is what a
cached certificate is for (at most 24 hours old).
- The binary comes from `ctx.process.ensureBinary`: the copy the Docker image
bakes into `/app/opkssh-bundled` (`OPKSSH_BUNDLED_DIR`) when its checksum
matches, else the one in the plugin's data folder, else a download pinned to
the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is
killed on cancel, close and deactivate. The config lives at
`<DATA_DIR>/plugins/opkssh/config.yml`; `redirect_uris` there are still the
localhost listener candidates only, and the public callback still goes to
OPKSSH as `--remote-redirect-uri`.
- The chooser and callback pages are public plugin routes
(`/plugin-api/opkssh/chooser/:id/*`, `/callback`, `/callback/:id/*`).
New installs register `<base>/plugin-api/opkssh/callback` with their
identity provider. `utils/crypto-migration/opkssh-config-migration.ts`
(run from `plugin-data-moves.ts`) copies a 2.8 `.opk/config.yml` in and
turns on the admin setting `legacyCallback`, so an upgraded install keeps
sending `<base>/host/opkssh-callback`, which core answers with a 307 to the
plugin (`database/routes/host-compat-routes.ts`). The admin page shows the
URI to register.
- The sign-in dialog is a `terminal.overlay` contribution. The terminal knows
no sign-in by name: it forwards `<interaction>_start_auth`, `_cancel` and
`_auth_completed` to `ctx.ssh` for any provider.
- `warpgate` registers a keyboard-interactive handler
(`ctx.auth.registerKeyboardInteractiveHandler`) and the host setting
`useWarpgate`, which `warpgate-settings-migration.ts` fills from
`ssh_data.use_warpgate` (and from hosts still on auth type `warpgate`).
The column is out of `schema.ts`; its drizzle drop is a no-op.
- A handler's browser round is the generic `browser` decision
(`{ id, label, url, code, instructions }`). The terminal sends it as
`<id>_auth_required` and takes `<id>_auth_continue`, so Warpgate keeps its
2.8 message names for Termix-Mobile; the file manager and docker answer
`requires_browser_sign_in` and continue on `connect-browser-sign-in`.
- `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's
`opkssh-cert-auth.ts`: opkssh, core key auth with a CA certificate, and the
Step-CA and Vault providers still in core use it.
- Step-CA no longer shares anything with OPKSSH: its interaction is
`stepca`, its messages `stepca_*`, and it keeps issued certificates in
memory until it becomes a plugin.
#### One SSH connect pipeline #### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through Every SSH connection, core's and plugins', goes through
@@ -1107,8 +1160,8 @@ Every SSH connection, core's and plugins', goes through
stored password for password prompts and nothing else. stored password for password prompts and nothing else.
Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`): Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`):
an interceptor first (Warpgate), then push MFA, TOTP, and plain input. The a plugin's keyboard-interactive handler first, then push MFA, TOTP, and plain
transport decides how to ask. input. The transport decides how to ask.
A host whose `authType` has no provider fails with A host whose `authType` has no provider fails with
`This host uses <type>, which needs the <plugin> plugin` (the plugin is found `This host uses <type>, which needs the <plugin> plugin` (the plugin is found
@@ -1159,6 +1212,28 @@ instead draw its own editor with `app.registerSshAuthEditor`.
A transport that can show a browser step sends `<interaction>_auth_required` A transport that can show a browser step sends `<interaction>_auth_required`
and later calls the provider's `startInteraction` for `<interaction>_start_auth`. and later calls the provider's `startInteraction` for `<interaction>_start_auth`.
A server that speaks its own prompt style on any host, whatever its auth type
(an SSH bastion asking for a browser approval), is a keyboard-interactive
handler instead (**C3**):
```ts
ctx.auth.registerKeyboardInteractiveHandler({
id: "gate", // listed in contributes.auth.keyboardInteractive
label: "Gate", // shown in the sign-in dialog title
detect: (round, host, settings) =>
/gate/i.test(round.name)
? { kind: "browser", url, code, instructions: round.instructions }
: null,
autoAnswerPasswords: (host, settings) => settings.enabled === true,
});
```
`settings` is the plugin's own host settings for the host, defaults filled
in: the host resolver puts every plugin's host settings on the connect host
(`pluginSettings`) because both hooks run synchronously inside ssh2's
callback, and the handler only sees its own slice. Needs `auth:provide`; the
declaration is checked at registration, and deactivate removes the handler.
#### Login methods and second factors #### Login methods and second factors
```ts ```ts
@@ -1427,6 +1502,7 @@ not do.
"loginMethods": ["corp-sso"], "loginMethods": ["corp-sso"],
"secondFactors": ["pin"], "secondFactors": ["pin"],
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves "secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
"keyboardInteractive": ["gate"], // keyboard-interactive handler ids
}, },
"settings": { "settings": {
"admin": [ "admin": [
@@ -1594,6 +1670,8 @@ deciding, not the mechanism.
- A capability is checked where the privileged thing happens, not at - A capability is checked where the privileged thing happens, not at
activation. Activation-time checks say what a plugin might do; call-time activation. Activation-time checks say what a plugin might do; call-time
checks say what it did. checks say what it did.
- `process:spawn` is checked by `ctx.process` (**C3**); before that it was
only a statement of intent.
- `events:core` is the exception that proves the shape: without it a plugin may - `events:core` is the exception that proves the shape: without it a plugin may
only emit under `plugin.<its id>.`, because otherwise it could publish only emit under `plugin.<its id>.`, because otherwise it could publish
`host.status` and drive the automations engine as though core had. `host.status` and drive the automations engine as though core had.
@@ -1604,42 +1682,44 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status | | Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ----------------------- | | ------------------------------------------------ | -------------------------------------------------- | ----------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** | | `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** | | `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | | `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** | | `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** | | `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | | `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | | `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | | `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** | | `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** | | `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** | | `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** | | `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | | `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** | | `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** | | `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.http.baseUrl(req)` | none | **C2** | | `ctx.http.baseUrl(req)` | none | **C2** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | | `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | | `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | | `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** | | `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** | | `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** | | `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** | | `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | | `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | | `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | | `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** | | `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | | `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other generic check for a capability no other ctx member wraps. Unlike every other
@@ -1670,11 +1750,11 @@ connect pipeline instead of importing ssh2 helpers from core:
- `prepare(host, { client, purpose })`, `openTransport`, - `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a `classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (**B6**'s interactive file-manager transport with its own prompt flow (**B6**'s interactive file-manager
connect with its TOTP/Warpgate parking flow). connect with its TOTP and browser sign-in parking flow).
- **B15** showed the higher level covers a multi-request HTTP handshake too: - **B15** showed the higher level covers a multi-request HTTP handshake too:
the Docker panel passes `connect` a `prompt` channel whose `ask` answers the the Docker panel passes `connect` a `prompt` channel whose `ask` answers the
waiting request with what to ask (a code, a Warpgate sign-in, credentials) waiting request with what to ask (a code, a browser sign-in, credentials)
and resolves when the next request (`connect-totp`, `connect-warpgate`) and resolves when the next request (`connect-totp`, `connect-browser-sign-in`)
brings the answer. A retry TOTP round re-asks instead of failing. docker brings the answer. A retry TOTP round re-asks instead of failing. docker
has no auth code of its own left. has no auth code of its own left.
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the - `requiresSecret(authType)` and `supportsBackground(authType)` ask the
@@ -1796,6 +1876,19 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused. audited as `plugin_credentials_read`, allowed or refused.
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
returns `{ pid, onStdout, onStderr, exited, kill }`. Output arrives as UTF-8
text. Every program still running is killed on deactivate. Audited as
`process_run`.
- `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of
a verified executable: the first `prebuilt` path whose SHA-256 matches (a
copy baked into the Docker image, used in place), else
`<DATA_DIR>/plugins/<id>/bin/<name>` when it matches, else a download over
https (redirects followed, `network:outbound` checked only then), written
atomically with mode 755. A mismatch throws and keeps nothing.
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`, **B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
expands `"<scheme>://..."` references in a host's secret fields into the expands `"<scheme>://..."` references in a host's secret fields into the
@@ -2151,6 +2244,7 @@ upgrade on all three engines, and the checks in step 15 are green.
| `ctx.http.router`, `ctx.ws.*` | `network:serve` | | `ctx.http.router`, `ctx.ws.*` | `network:serve` |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` |
| `ctx.auth.*` | `auth:provide` | | `ctx.auth.*` | `auth:provide` |
| `ctx.process.*` | `process:spawn` |
| `ctx.settings.readCore` | `settings:read-core` | | `ctx.settings.readCore` | `settings:read-core` |
| `ctx.events.emit` outside `plugin.<id>.` | `events:core` | | `ctx.events.emit` outside `plugin.<id>.` | `events:core` |
| anything registered through `app` | `ui:surface` | | anything registered through `app` | `ui:surface` |
@@ -2425,6 +2519,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations (B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2), (B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3),
which import nothing from core. The others still reach core by relative which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin, path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
@@ -2502,4 +2597,4 @@ Known specifics:
owns no module state, and its guacamole-lite server is built in `activate` owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service, SIGINT handlers in its constructor). guacd is always an external service,
so there is no `ctx.process`; its address is an admin setting. so it does not use `ctx.process`; its address is an admin setting.
+50 -4
View File
@@ -168,10 +168,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on `setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on
the typed host bridge, which is also where B7's `app.logActivity` line the typed host bridge, which is also where B7's `app.logActivity` line
above lands. Owner: D1. above lands. Owner: D1.
- **B9 (ssh-terminal):** `OPKSSHDialog` is exported from
`@termix/plugin-sdk/ui` so the terminal can show the opkssh sign-in. It
leaves the ui entry when opkssh becomes a plugin that draws its own UI in
`terminal.overlay`. Owner: Phase C (opkssh).
- **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars - **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars
(`TerminalMetricsStatus`) poll the local backend only; on the desktop app a (`TerminalMetricsStatus`) poll the local backend only; on the desktop app a
host that lives on a connected remote server shows no bars. Same fix as the host that lives on a connected remote server shows no bars. Same fix as the
@@ -487,6 +483,47 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before (`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
C2. Owner: D0. C2. Owner: D0.
- **C3 (opkssh), for the Step-CA step:** Step-CA is still core and no longer
shares OPKSSH's pieces. Its interaction is `stepca` and its socket messages
`stepca_*`, but nothing draws them yet: until its plugin contributes a
`terminal.overlay` (the opkssh plugin's `OpksshOverlay` and `OpksshDialog`
are the model), a Step-CA host asks for a sign-in the terminal cannot show.
Its issued certificates are cached in memory (`step-ca-auth.ts`), so they
are lost on restart; its plugin should own a table the way opkssh owns
`p_opkssh_tokens`. Step-CA certificates 2.8 cached in `opkssh_tokens` stay
in the adopted `p_opkssh_tokens`, unused, until they expire. Owner: the
Phase C step that moves Step-CA.
- **C3 (opkssh):** `database/routes/host-compat-routes.ts` answers the 2.8
redirect URI `/host/opkssh-callback` with a 307 to the plugin and so names
the plugin's path. Keep it until identity providers have moved to
`/plugin-api/opkssh/callback` (the admin page shows it, and the
`legacyCallback` setting switches over). Owner: 3.0.0.
- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder
under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the
config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0.
- **C3 (opkssh):** the Termix docs still describe `DATA_DIR/.opk/config.yml`
and `/host/opkssh-callback`. They need the new config path
(`DATA_DIR/plugins/opkssh/config.yml`), the new redirect URI and the
`legacyCallback` setting. Owner: D0 (docs repo).
- **C3 (warpgate):** Termix-Mobile's file manager and docker clients call
`/ssh/connect-warpgate` and read `requiresWarpgate`; the plugins answer
`connect-browser-sign-in` and `requires_browser_sign_in` now (both clients
were already on paths B6 and B15 moved). The terminal socket keeps
`warpgate_auth_required` / `warpgate_auth_continue`. Owner: D1, with the
rest of the mobile client changes.
- **C3:** `host-bulk-routes.ts` still validates imported auth types against a
fixed list that names plugin types (`opkssh`, `stepca`, `tailscale`,
`vault`), and the host create/update routes no longer accept a
`useWarpgate` field inline (the host editor sends plugin settings). The
bulk list should come from the registered providers plus
`contributes.auth.sshAuthTypes`. Owner: D1.
- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's
`OPKSSH_VERSION` build arg is not passed on at runtime, so building with
another version gives a prebaked binary the plugin's pinned checksum
rejects (it then downloads the pinned version instead). Pass
`OPKSSH_VERSION` and the matching `OPKSSH_SHA256` as `ENV` in the final
stage. Owner: D0.
## Manual checks after 2.9.0 ## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase - SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -572,3 +609,12 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
sign-in; a second factor after an external login with the admin setting sign-in; a second factor after an external login with the admin setting
on and off; disable each plugin and its buttons go away and its URLs stop on and off; disable each plugin and its buttons go away and its URLs stop
signing anyone in, then enable it again. signing anyone in, then enable it again.
- OPKSSH and Warpgate: on a 2.8 Docker install with OPKSSH set up, the config
shows up under the plugin, the admin page shows the old redirect URI and a
terminal sign-in to an OPKSSH host works through the provider chooser
without touching the identity provider; switch to the new URI, register
it and sign in again; a second connect within 24 hours skips the browser;
an offline Docker install uses the baked binary; disable the opkssh plugin
and the host says it needs it. A Warpgate host's toggle is on in the host
editor's Plugins group after the upgrade, and the terminal, file manager
and docker each show the sign-in dialog and connect after it.
+4
View File
@@ -56,6 +56,10 @@
"types": "./dist/host-commands.d.ts", "types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js" "default": "./dist/host-commands.js"
}, },
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json", "./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json" "./schema/manifest.schema.json": "./schema/manifest.schema.json"
}, },
@@ -296,6 +296,10 @@
"secretSchemes": { "secretSchemes": {
"type": "array", "type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"keyboardInteractive": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
} }
} }
}, },
+119 -13
View File
@@ -281,7 +281,9 @@ export type PluginMiddleware = (
export interface PluginRouterOptions { export interface PluginRouterOptions {
/** /**
* Paths served without authentication, relative to the plugin's mount point * Paths served without authentication, relative to the plugin's mount point
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). * and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
* ":name" segment matches one segment, and a trailing "/*" matches any
* number of further segments, for a proxied page ("/chooser/:id/*").
* *
* For the handful of routes an unauthenticated third party has to reach: an * For the handful of routes an unauthenticated third party has to reach: an
* OIDC callback, an inbound webhook. Every entry is audited when the router * OIDC callback, an inbound webhook. Every entry is audited when the router
@@ -775,26 +777,69 @@ export interface PluginKeyboardInteractivePrompt {
echo?: boolean; echo?: boolean;
} }
/**
* A round the user finishes in a browser: open a URL, confirm a code there,
* then continue. Transports show one generic dialog for it and name their
* messages after `id` (the terminal sends "<id>_auth_required" and waits for
* "<id>_auth_continue").
*/
export interface PluginBrowserSignInRound {
kind: "browser";
/** The keyboard-interactive handler that claimed the round. */
id: string;
/** Product name for the dialog title. */
label: string;
url: string | null;
/** A code to compare in the browser, or "N/A". */
code: string;
instructions: string;
}
export type PluginKeyboardInteractiveDecision = export type PluginKeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] } | { kind: "auto"; responses: string[] }
| { | PluginBrowserSignInRound
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| { kind: "totp"; promptIndex: number } | { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean }; | { kind: "input"; promptIndex: number; isPush: boolean };
export type PluginSshPromptRequest = export type PluginSshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean } | { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean } | { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| { | PluginBrowserSignInRound;
kind: "warpgate";
url: string | null; /** What a keyboard-interactive handler's detect may return. */
securityKey: string; export type PluginKeyboardInteractiveDetection =
| Exclude<PluginKeyboardInteractiveDecision, PluginBrowserSignInRound>
| Omit<PluginBrowserSignInRound, "id" | "label">;
/**
* Claims keyboard-interactive rounds on any host, whatever its auth type,
* for a server that speaks its own prompt style (a bastion asking for a
* browser sign-in). Handlers run before core's TOTP and push detection.
*
* `settings` is this plugin's host-scope settings for the host, with manifest
* defaults filled in. Both hooks are synchronous; they run inside ssh2's
* keyboard-interactive callback.
*/
export interface PluginKeyboardInteractiveHandler {
/** Listed in contributes.auth.keyboardInteractive. */
id: string;
/** Product name shown when a browser round is claimed. */
label: string;
detect: (
round: {
name: string;
instructions: string; instructions: string;
}; prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
settings: Record<string, unknown>,
) => PluginKeyboardInteractiveDetection | null;
/** Answer password prompts with the stored password instead of asking. */
autoAnswerPasswords?: (
host: PluginSshHost,
settings: Record<string, unknown>,
) => boolean;
}
/** Answers keyboard-interactive prompts. Resolve null to give up. */ /** Answers keyboard-interactive prompts. Resolve null to give up. */
export interface PluginSshPromptChannel { export interface PluginSshPromptChannel {
@@ -1213,6 +1258,10 @@ export interface PluginSecondFactor {
*/ */
export interface PluginAuth { export interface PluginAuth {
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void; registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
/** A host-independent keyboard-interactive handler. */
registerKeyboardInteractiveHandler: (
handler: PluginKeyboardInteractiveHandler,
) => void;
registerLoginMethod: (method: PluginLoginMethod) => void; registerLoginMethod: (method: PluginLoginMethod) => void;
registerSecondFactor: (factor: PluginSecondFactor) => void; registerSecondFactor: (factor: PluginSecondFactor) => void;
/** /**
@@ -1459,7 +1508,8 @@ export type PluginFetch = (
* hardware (a serial port, a USB device) is the first caller. Core cannot * hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the * mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent * capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate, matching process:spawn. * rather than a technical gate. Running programs has its own member,
* ctx.process.
*/ */
export interface PluginCapabilities { export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */ /** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
@@ -1468,6 +1518,60 @@ export interface PluginCapabilities {
require: (capability: string) => Promise<void>; require: (capability: string) => Promise<void>;
} }
export interface PluginProcessOptions {
/** Added to the server's environment. */
env?: Record<string, string>;
cwd?: string;
/** Kills the program with SIGKILL after this long. */
timeoutMs?: number;
}
/** A running program started with ctx.process.run. */
export interface PluginProcessHandle {
readonly pid: number | undefined;
/** Output as UTF-8 text, in the chunks it arrives in. */
onStdout: (listener: (chunk: string) => void) => void;
onStderr: (listener: (chunk: string) => void) => void;
/** Settles when the program ends, or rejects when it cannot start. */
readonly exited: Promise<{ code: number | null; signal: string | null }>;
kill: (signal?: "SIGTERM" | "SIGKILL") => void;
}
/** A program a plugin downloads and runs, pinned to one checksum. */
export interface PluginBinarySpec {
/** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */
name: string;
version: string;
/** https URL of the release asset. Redirects are followed. */
url: string;
/** Hex SHA-256 the file must have. */
sha256: string;
/**
* Paths checked before downloading, such as a copy baked into the Docker
* image. One whose checksum matches is used in place.
*/
prebuilt?: readonly string[];
}
/**
* Programs on the Termix server. Needs process:spawn. Every program still
* running is killed on deactivate.
*/
export interface PluginProcess {
/** Starts `file` with `args`, no shell. Audited. */
run: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => Promise<PluginProcessHandle>;
/**
* Returns the path of a verified executable: a matching prebuilt copy, the
* copy downloaded earlier, or a fresh download (which also needs
* network:outbound). Throws when the checksum does not match.
*/
ensureBinary: (spec: PluginBinarySpec) => Promise<string>;
}
/** /**
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that * Timers owned by the plugin. Every timer is cleared on deactivate, a run that
* throws is logged instead of crashing the server, and a repeating job never * throws is logged instead of crashing the server, and a repeating job never
@@ -1527,6 +1631,8 @@ export interface PluginContext {
readonly notify: PluginNotify; readonly notify: PluginNotify;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch; readonly fetch: PluginFetch;
/** Programs on the Termix server. Needs process:spawn. */
readonly process: PluginProcess;
/** /**
* Runs `fn` with `userId` as the acting user, for background work that has * Runs `fn` with `userId` as the acting user, for background work that has
+12 -2
View File
@@ -259,6 +259,8 @@ export interface PluginAuthContribution {
secondFactors?: string[]; secondFactors?: string[];
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */ /** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
secretSchemes?: string[]; secretSchemes?: string[];
/** Keyboard-interactive handler ids, e.g. "warpgate". */
keyboardInteractive?: string[];
} }
export interface PluginContributions { export interface PluginContributions {
@@ -792,7 +794,13 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
} }
rejectUnknown( rejectUnknown(
value, value,
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"], [
"sshAuthTypes",
"loginMethods",
"secondFactors",
"secretSchemes",
"keyboardInteractive",
],
"contributes.auth", "contributes.auth",
errors, errors,
); );
@@ -801,6 +809,7 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
"loginMethods", "loginMethods",
"secondFactors", "secondFactors",
"secretSchemes", "secretSchemes",
"keyboardInteractive",
]) { ]) {
const list = value[key]; const list = value[key];
if (list === undefined) continue; if (list === undefined) continue;
@@ -1351,7 +1360,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
(auth?.sshAuthTypes?.length ?? 0) + (auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) + (auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) + (auth?.secondFactors?.length ?? 0) +
(auth?.secretSchemes?.length ?? 0) > (auth?.secretSchemes?.length ?? 0) +
(auth?.keyboardInteractive?.length ?? 0) >
0; 0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) { if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push( errors.push(
@@ -1,12 +1,14 @@
import { getErrorMessage } from "../utils/error-message.js"; /**
// SSH certificate authentication workarounds for ssh2. * OpenSSH certificate auth for ssh2, which has no native support for it.
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts *
// the certificate onto the parsed key, wraps ECDSA signing to convert * Grafts the certificate onto the parsed key, converts ECDSA signatures from
// DER → SSH wire format, and patches Protocol.authPK to use the base * DER to SSH wire format, and patches Protocol.authPK so the signature
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). * wrapper carries the base algorithm (OpenSSH's sshkey_check_sigtype wants
// * that). Pure: no core state, it only touches the config and client it gets.
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase) *
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported) * Used by core key auth with a CA-signed certificate and by every plugin that
* issues short-lived certificates (opkssh, step-ca, vault).
*/
import type { import type {
AnyAuthMethod, AnyAuthMethod,
@@ -17,11 +19,6 @@ import type {
PublicKeyAuthMethod, PublicKeyAuthMethod,
} from "ssh2"; } from "ssh2";
interface OPKSSHToken {
privateKey: string;
sshCert: string;
}
type SignCallback = ( type SignCallback = (
data: Buffer, data: Buffer,
callback: (signature: Buffer) => void, callback: (signature: Buffer) => void,
@@ -34,7 +31,7 @@ interface ParsedPrivateKey {
[key: symbol]: unknown; [key: symbol]: unknown;
} }
interface OPKSSHProtocol { interface PatchedProtocol {
authPK: ( authPK: (
user: string, user: string,
pubKey: ParsedPrivateKey, pubKey: ParsedPrivateKey,
@@ -58,19 +55,18 @@ interface OPKSSHProtocol {
}; };
} }
type OPKSSHClient = Client & { type PatchedClient = Client & {
_protocol?: OPKSSHProtocol; _protocol?: PatchedProtocol;
}; };
type OPKSSHNextAuthHandler = ( type NextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false, authInfo: AuthenticationType | AnyAuthMethod | false,
) => void; ) => void;
// ── Internal implementation ──────────────────────────────────────────────────
// Grafts an OpenSSH certificate onto an already-parsed private key object and // Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds. // patches the ssh2 client so that certificate-based publickey auth succeeds.
async function _applyCertToConnection( async function applyCertToConnection(
config: ConnectConfig, config: ConnectConfig,
client: Client, client: Client,
privKey: ParsedPrivateKey, privKey: ParsedPrivateKey,
@@ -136,7 +132,7 @@ async function _applyCertToConnection(
_partialSuccess: boolean, _partialSuccess: boolean,
callback, callback,
) => { ) => {
const next = callback as OPKSSHNextAuthHandler; const next = callback as NextAuthHandler;
if ( if (
!certAuthAttempted && !certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey")) (!methodsLeft || methodsLeft.includes("publickey"))
@@ -157,7 +153,7 @@ async function _applyCertToConnection(
// wrapper algorithm for cert types. // wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, ""); const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client); const origConnect = client.connect.bind(client);
const patchedClient = client as OPKSSHClient; const patchedClient = client as PatchedClient;
patchedClient.connect = (cfg: ConnectConfig) => { patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg); const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol; const proto = patchedClient._protocol;
@@ -185,7 +181,7 @@ async function _applyCertToConnection(
const baseAlgoLen = Buffer.byteLength(baseAlgo); const baseAlgoLen = Buffer.byteLength(baseAlgo);
const pubKeyLen = pubSSH.length; const pubKeyLen = pubSSH.length;
// Build data to sign (uses cert algo — matches server verification) // Build data to sign (uses the cert algo, which the server verifies against)
const sigDataLen = const sigDataLen =
4 + 4 +
sesLen + sesLen +
@@ -296,16 +292,23 @@ async function _applyCertToConnection(
}; };
} }
// ── Public API ─────────────────────────────────────────────────────────────── export interface CertificateCredentials {
/** OpenSSH or PEM private key. */
privateKey: Buffer | string;
/** The certificate line, e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...". */
certificate: string;
passphrase?: string;
}
/** /**
* Set up OPKSSH certificate authentication on an ssh2 Client. * Sets up certificate publickey auth on an ssh2 client. Call before
* The OPKSSH private key is assumed to be unencrypted (no passphrase). * `client.connect(config)`; it sets `config.authHandler` and
* `config.username`.
*/ */
export async function setupOPKSSHCertAuth( export async function applyCertificateAuth(
config: ConnectConfig, config: ConnectConfig,
client: Client, client: Client,
token: OPKSSHToken, credentials: CertificateCredentials,
username: string, username: string,
): Promise<void> { ): Promise<void> {
const { createRequire } = await import("node:module"); const { createRequire } = await import("node:module");
@@ -314,56 +317,24 @@ export async function setupOPKSSHCertAuth(
utils: { parseKey }, utils: { parseKey },
} = esmRequire("ssh2"); } = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username; (config as Record<string, unknown>).username = username;
const parsed = parseKey(Buffer.from(token.privateKey)); const keyBuf = Buffer.isBuffer(credentials.privateKey)
? credentials.privateKey
: Buffer.from(credentials.privateKey, "utf8");
const parsed = credentials.passphrase
? parseKey(keyBuf, credentials.passphrase)
: parseKey(keyBuf);
if (parsed instanceof Error || !parsed) { if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key"); const reason = parsed instanceof Error ? parsed.message : "unknown error";
throw new Error(
`Failed to parse private key for certificate auth: ${reason}`,
);
} }
const privKey = ( const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey; ) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, token.sshCert); await applyCertToConnection(config, client, privKey, credentials.certificate);
}
/**
* Set up CA-signed certificate authentication on an ssh2 Client.
* Supports passphrase-protected private keys.
* The cert content is the full text of the -cert.pub file
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
*/
export async function setupCACertAuth(
config: ConnectConfig,
client: Client,
privateKey: Buffer | string,
certPublicKey: string,
username: string,
passphrase?: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(privateKey)
? privateKey
: Buffer.from(privateKey, "utf8");
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const errMsg = getErrorMessage(parsed, "unknown error");
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, certPublicKey);
} }
+114 -3
View File
@@ -68,6 +68,10 @@ import type {
PluginNotification, PluginNotification,
PluginNotificationChannel, PluginNotificationChannel,
PluginFetchInit, PluginFetchInit,
PluginBinarySpec,
PluginKeyboardInteractiveHandler,
PluginProcessHandle,
PluginProcessOptions,
} from "./backend.js"; } from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */ /** A timer registered through ctx.schedule, run by hand with runScheduled. */
@@ -127,6 +131,11 @@ export interface FakeContextOptions {
* reaches the network by accident. * reaches the network by accident.
*/ */
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>; fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/**
* Answers ctx.process. Without run every ctx.process.run rejects; without
* ensureBinary it answers "/tmp/<pluginId>/bin/<name>".
*/
process?: FakeProcessOptions;
/** /**
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted * Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
* proxy login on, password login off), with this message and a 409. * proxy login on, password login off), with this message and a 409.
@@ -140,8 +149,55 @@ export interface FakeContextOptions {
loginAttemptLimit?: number; loginAttemptLimit?: number;
} }
export interface FakeProcessOptions {
run?: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => PluginProcessHandle | Promise<PluginProcessHandle>;
ensureBinary?: (spec: PluginBinarySpec) => Promise<string>;
}
/** A ctx.process handle a test drives by hand. */
export interface FakeProcess extends PluginProcessHandle {
emitStdout: (chunk: string) => void;
emitStderr: (chunk: string) => void;
exit: (code: number | null, signal?: string | null) => void;
killed: string[];
}
export function createFakeProcess(pid = 4242): FakeProcess {
const stdout: Array<(chunk: string) => void> = [];
const stderr: Array<(chunk: string) => void> = [];
const killed: string[] = [];
let resolveExit: (value: {
code: number | null;
signal: string | null;
}) => void = () => {};
const exited = new Promise<{ code: number | null; signal: string | null }>(
(resolve) => {
resolveExit = resolve;
},
);
return {
pid,
onStdout: (listener) => void stdout.push(listener),
onStderr: (listener) => void stderr.push(listener),
exited,
kill: (signal = "SIGTERM") => {
killed.push(signal);
resolveExit({ code: null, signal });
},
emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)),
emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)),
exit: (code, signal = null) => resolveExit({ code, signal }),
killed,
};
}
export interface FakeAuthRegistrations { export interface FakeAuthRegistrations {
sshAuthProviders: PluginSshAuthProvider[]; sshAuthProviders: PluginSshAuthProvider[];
keyboardInteractiveHandlers: PluginKeyboardInteractiveHandler[];
loginMethods: PluginLoginMethod[]; loginMethods: PluginLoginMethod[];
secondFactors: PluginSecondFactor[]; secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */ /** "<userId>:<factorId>" for every recorded enrolment. */
@@ -227,6 +283,14 @@ export interface FakePluginContext {
secretStore: Map<string, string>; secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */ /** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>; fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.process.run call, in order. */
processRuns: Array<{
file: string;
args: readonly string[];
options?: PluginProcessOptions;
}>;
/** Every ctx.process.ensureBinary call, in order. */
binaries: PluginBinarySpec[];
/** Every ctx.audit.record entry, in order. */ /** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */ /** Every ctx.hosts.recordActivity call, in order. */
@@ -330,6 +394,8 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = []; const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = []; const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
const secretStore = new Map<string, string>(); const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = []; const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = []; const activities: FakePluginContext["activities"] = [];
@@ -371,6 +437,7 @@ export function createFakeContext(
const registryProviders = new Map<string, unknown>(); const registryProviders = new Map<string, unknown>();
const auth: FakeAuthRegistrations = { const auth: FakeAuthRegistrations = {
sshAuthProviders: [], sshAuthProviders: [],
keyboardInteractiveHandlers: [],
loginMethods: [], loginMethods: [],
secondFactors: [], secondFactors: [],
enrollments: new Set(), enrollments: new Set(),
@@ -811,13 +878,20 @@ export function createFakeContext(
requiresSecret: (authType) => requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType), ["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) => supportsBackground: (authType) =>
!["none", "opkssh", "stepca"].includes(authType), !["none", "stepca"].includes(authType) &&
!auth.sshAuthProviders.some(
(provider) =>
provider.type === authType && provider.supportsBackground === false,
),
}, },
auth: { auth: {
registerSshAuthProvider: (provider) => { registerSshAuthProvider: (provider) => {
auth.sshAuthProviders.push(provider); auth.sshAuthProviders.push(provider);
}, },
registerKeyboardInteractiveHandler: (handler) => {
auth.keyboardInteractiveHandlers.push(handler);
},
registerLoginMethod: (method) => { registerLoginMethod: (method) => {
auth.loginMethods.push(method); auth.loginMethods.push(method);
}, },
@@ -927,6 +1001,23 @@ export function createFakeContext(
return options.fetch(url, init); return options.fetch(url, init);
}, },
process: {
run: async (file, args, runOptions) => {
processRuns.push({ file, args, options: runOptions });
if (!options.process?.run) {
throw new Error("ctx.process.run is not stubbed");
}
return options.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
binaries.push(spec);
if (options.process?.ensureBinary) {
return options.process.ensureBinary(spec);
}
return `/tmp/${pluginId}/bin/${spec.name}`;
},
},
asUser: async (userId, fn) => { asUser: async (userId, fn) => {
const previous = actor; const previous = actor;
actor = userId; actor = userId;
@@ -960,6 +1051,8 @@ export function createFakeContext(
credentialReads, credentialReads,
notifications, notifications,
fetches, fetches,
processRuns,
binaries,
secretStore, secretStore,
audits, audits,
activities, activities,
@@ -1024,6 +1117,8 @@ export interface MockContextOptions {
*/ */
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>; fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */ /** See FakeContextOptions. */
process?: FakeProcessOptions;
/** See FakeContextOptions. */
refuseEnrollment?: string; refuseEnrollment?: string;
/** See FakeContextOptions. */ /** See FakeContextOptions. */
baseUrl?: string; baseUrl?: string;
@@ -1046,8 +1141,8 @@ export interface MockPluginContext extends FakePluginContext {
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a * ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on * topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify, network:outbound on ctx.fetch, and * notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately * ctx.process, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape. * ungated. As the SDK grows a member, add its gate here in the same shape.
*/ */
export function createMockCtx( export function createMockCtx(
@@ -1073,6 +1168,7 @@ export function createMockCtx(
hostStatuses: options.hostStatuses, hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels, notificationChannels: options.notificationChannels,
fetch: options.fetch, fetch: options.fetch,
process: options.process,
refuseEnrollment: options.refuseEnrollment, refuseEnrollment: options.refuseEnrollment,
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers, linkedUsers: options.linkedUsers,
@@ -1309,6 +1405,10 @@ export function createMockCtx(
require("auth:provide"); require("auth:provide");
ctx.auth.registerSshAuthProvider(provider); ctx.auth.registerSshAuthProvider(provider);
}, },
registerKeyboardInteractiveHandler: (handler) => {
require("auth:provide");
ctx.auth.registerKeyboardInteractiveHandler(handler);
},
registerLoginMethod: (method) => { registerLoginMethod: (method) => {
require("auth:provide"); require("auth:provide");
ctx.auth.registerLoginMethod(method); ctx.auth.registerLoginMethod(method);
@@ -1388,6 +1488,17 @@ export function createMockCtx(
return ctx.fetch(url, init); return ctx.fetch(url, init);
}, },
process: {
run: async (file, args, runOptions) => {
require("process:spawn");
return ctx.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
require("process:spawn");
return ctx.process.ensureBinary(spec);
},
},
secrets: { secrets: {
...ctx.secrets, ...ctx.secrets,
get: async (key) => { get: async (key) => {
+1 -1
View File
@@ -78,7 +78,7 @@
"containerMustBeRunning": "Container must be running to access console", "containerMustBeRunning": "Container must be running to access console",
"verificationCodePrompt": "Enter verification code", "verificationCodePrompt": "Enter verification code",
"totpVerificationFailed": "TOTP verification failed. Please try again.", "totpVerificationFailed": "TOTP verification failed. Please try again.",
"warpgateVerificationFailed": "Warpgate authentication failed. Please try again.", "browserSignInFailed": "Sign-in failed. Please try again.",
"connectedTo": "Connected to {{containerName}}", "connectedTo": "Connected to {{containerName}}",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"consoleError": "Console error", "consoleError": "Console error",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Die houer moet loop om toegang tot die konsole te verkry", "containerMustBeRunning": "Die houer moet loop om toegang tot die konsole te verkry",
"verificationCodePrompt": "Voer verifikasiekode in", "verificationCodePrompt": "Voer verifikasiekode in",
"totpVerificationFailed": "TOTP-verifikasie het misluk. Probeer asseblief weer.", "totpVerificationFailed": "TOTP-verifikasie het misluk. Probeer asseblief weer.",
"warpgateVerificationFailed": "Warpgate-verifikasie het misluk. Probeer asseblief weer.",
"connectedTo": "Gekoppel aan {{containerName}}", "connectedTo": "Gekoppel aan {{containerName}}",
"disconnected": "Ontkoppel", "disconnected": "Ontkoppel",
"consoleError": "Konsolefout", "consoleError": "Konsolefout",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "يجب أن تكون الحاوية قيد التشغيل للوصول إلى وحدة التحكم", "containerMustBeRunning": "يجب أن تكون الحاوية قيد التشغيل للوصول إلى وحدة التحكم",
"verificationCodePrompt": "أدخل رمز التحقق", "verificationCodePrompt": "أدخل رمز التحقق",
"totpVerificationFailed": "فشل التحقق من TOTP. يرجى المحاولة مرة أخرى.", "totpVerificationFailed": "فشل التحقق من TOTP. يرجى المحاولة مرة أخرى.",
"warpgateVerificationFailed": "فشلت مصادقة Warpgate. يرجى المحاولة مرة أخرى.",
"connectedTo": "متصل بـ {{containerName}}", "connectedTo": "متصل بـ {{containerName}}",
"disconnected": "تم قطع الاتصال", "disconnected": "تم قطع الاتصال",
"consoleError": "خطأ في وحدة التحكم", "consoleError": "خطأ في وحدة التحكم",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Контейнерът трябва да е работещ, за да има достъп до конзолата", "containerMustBeRunning": "Контейнерът трябва да е работещ, за да има достъп до конзолата",
"verificationCodePrompt": "Въведете код за потвърждение", "verificationCodePrompt": "Въведете код за потвърждение",
"totpVerificationFailed": "Проверката на TOTP не бе успешна. Моля, опитайте отново.", "totpVerificationFailed": "Проверката на TOTP не бе успешна. Моля, опитайте отново.",
"warpgateVerificationFailed": "Удостоверяването на Warpgate не бе успешно. Моля, опитайте отново.",
"connectedTo": "Свързано с {{containerName}}", "connectedTo": "Свързано с {{containerName}}",
"disconnected": "Изключен", "disconnected": "Изключен",
"consoleError": "Грешка в конзолата", "consoleError": "Грешка в конзолата",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "কনসোল অ্যাক্সেস করতে হলে কন্টেইনারটি অবশ্যই চালু থাকতে হবে।", "containerMustBeRunning": "কনসোল অ্যাক্সেস করতে হলে কন্টেইনারটি অবশ্যই চালু থাকতে হবে।",
"verificationCodePrompt": "যাচাইকরণ কোড প্রবেশ করান", "verificationCodePrompt": "যাচাইকরণ কোড প্রবেশ করান",
"totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"warpgateVerificationFailed": "ওয়ার্পগেট প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"connectedTo": "{{containerName}} এর সাথে সংযুক্ত", "connectedTo": "{{containerName}} এর সাথে সংযুক্ত",
"disconnected": "বিচ্ছিন্ন", "disconnected": "বিচ্ছিন্ন",
"consoleError": "কনসোল ত্রুটি", "consoleError": "কনসোল ত্রুটি",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "El contenidor ha d'estar en execució per accedir a la consola.", "containerMustBeRunning": "El contenidor ha d'estar en execució per accedir a la consola.",
"verificationCodePrompt": "Introduïu el codi de verificació", "verificationCodePrompt": "Introduïu el codi de verificació",
"totpVerificationFailed": "La verificació TOTP ha fallat. Torna-ho a provar.", "totpVerificationFailed": "La verificació TOTP ha fallat. Torna-ho a provar.",
"warpgateVerificationFailed": "L'autenticació de Warpgate ha fallat. Torna-ho a intentar.",
"connectedTo": "Connectat a {{containerName}}", "connectedTo": "Connectat a {{containerName}}",
"disconnected": "Desconnectat", "disconnected": "Desconnectat",
"consoleError": "Error de consola", "consoleError": "Error de consola",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Pro přístup ke konzoli musí být kontejner spuštěný.", "containerMustBeRunning": "Pro přístup ke konzoli musí být kontejner spuštěný.",
"verificationCodePrompt": "Zadejte ověřovací kód", "verificationCodePrompt": "Zadejte ověřovací kód",
"totpVerificationFailed": "Ověření TOTP se nezdařilo. Zkuste to prosím znovu.", "totpVerificationFailed": "Ověření TOTP se nezdařilo. Zkuste to prosím znovu.",
"warpgateVerificationFailed": "Ověření Warpgate se nezdařilo. Zkuste to prosím znovu.",
"connectedTo": "Připojeno k {{containerName}}", "connectedTo": "Připojeno k {{containerName}}",
"disconnected": "Odpojeno", "disconnected": "Odpojeno",
"consoleError": "Chyba konzole", "consoleError": "Chyba konzole",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Containeren skal køre for at få adgang til konsollen", "containerMustBeRunning": "Containeren skal køre for at få adgang til konsollen",
"verificationCodePrompt": "Indtast bekræftelseskode", "verificationCodePrompt": "Indtast bekræftelseskode",
"totpVerificationFailed": "TOTP-bekræftelse mislykkedes. Prøv igen.", "totpVerificationFailed": "TOTP-bekræftelse mislykkedes. Prøv igen.",
"warpgateVerificationFailed": "Warpgate-godkendelse mislykkedes. Prøv igen.",
"connectedTo": "Forbundet til {{containerName}}", "connectedTo": "Forbundet til {{containerName}}",
"disconnected": "Afbrudt", "disconnected": "Afbrudt",
"consoleError": "Konsolfejl", "consoleError": "Konsolfejl",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Der Container muss ausgeführt werden, um auf die Konsole zuzugreifen", "containerMustBeRunning": "Der Container muss ausgeführt werden, um auf die Konsole zuzugreifen",
"verificationCodePrompt": "Bestätigungscode eingeben", "verificationCodePrompt": "Bestätigungscode eingeben",
"totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", "totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"connectedTo": "Verbunden mit {{containerName}}", "connectedTo": "Verbunden mit {{containerName}}",
"disconnected": "Getrennt", "disconnected": "Getrennt",
"consoleError": "Konsolenfehler", "consoleError": "Konsolenfehler",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Το κοντέινερ πρέπει να εκτελείται για πρόσβαση στην κονσόλα", "containerMustBeRunning": "Το κοντέινερ πρέπει να εκτελείται για πρόσβαση στην κονσόλα",
"verificationCodePrompt": "Εισαγάγετε τον κωδικό επαλήθευσης", "verificationCodePrompt": "Εισαγάγετε τον κωδικό επαλήθευσης",
"totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε. Δοκιμάστε ξανά.", "totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε. Δοκιμάστε ξανά.",
"warpgateVerificationFailed": "Ο έλεγχος ταυτότητας Warpgate απέτυχε. Δοκιμάστε ξανά.",
"connectedTo": "Συνδεδεμένο με {{containerName}}", "connectedTo": "Συνδεδεμένο με {{containerName}}",
"disconnected": "Ασύνδετος", "disconnected": "Ασύνδετος",
"consoleError": "Σφάλμα κονσόλας", "consoleError": "Σφάλμα κονσόλας",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "El contenedor debe estar en ejecución para acceder a la consola", "containerMustBeRunning": "El contenedor debe estar en ejecución para acceder a la consola",
"verificationCodePrompt": "Introduzca el código de verificación", "verificationCodePrompt": "Introduzca el código de verificación",
"totpVerificationFailed": "La verificación TOTP falló. Inténtelo de nuevo.", "totpVerificationFailed": "La verificación TOTP falló. Inténtelo de nuevo.",
"warpgateVerificationFailed": "La autenticación Warpgate falló. Inténtelo de nuevo.",
"connectedTo": "Conectado a {{containerName}}", "connectedTo": "Conectado a {{containerName}}",
"disconnected": "Desconectado", "disconnected": "Desconectado",
"consoleError": "Error de consola", "consoleError": "Error de consola",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Kontin on oltava käynnissä, jotta konsoliin pääsee käsiksi.", "containerMustBeRunning": "Kontin on oltava käynnissä, jotta konsoliin pääsee käsiksi.",
"verificationCodePrompt": "Syötä vahvistuskoodi", "verificationCodePrompt": "Syötä vahvistuskoodi",
"totpVerificationFailed": "TOTP-vahvistus epäonnistui. Yritä uudelleen.", "totpVerificationFailed": "TOTP-vahvistus epäonnistui. Yritä uudelleen.",
"warpgateVerificationFailed": "Warpgate-todennus epäonnistui. Yritä uudelleen.",
"connectedTo": "Yhdistetty kohteeseen {{containerName}}", "connectedTo": "Yhdistetty kohteeseen {{containerName}}",
"disconnected": "Yhteys katkennut", "disconnected": "Yhteys katkennut",
"consoleError": "Konsolivirhe", "consoleError": "Konsolivirhe",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Le conteneur doit être en cours d'exécution pour accéder à la console", "containerMustBeRunning": "Le conteneur doit être en cours d'exécution pour accéder à la console",
"verificationCodePrompt": "Saisissez le code de vérification", "verificationCodePrompt": "Saisissez le code de vérification",
"totpVerificationFailed": "La vérification TOTP a échoué. Veuillez réessayer.", "totpVerificationFailed": "La vérification TOTP a échoué. Veuillez réessayer.",
"warpgateVerificationFailed": "L'authentification Warpgate a échoué. Veuillez réessayer.",
"connectedTo": "Connecté à {{containerName}}", "connectedTo": "Connecté à {{containerName}}",
"disconnected": "Déconnecté", "disconnected": "Déconnecté",
"consoleError": "Erreur de la console", "consoleError": "Erreur de la console",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "יש לפעול על מנת לגשת למסוף.", "containerMustBeRunning": "יש לפעול על מנת לגשת למסוף.",
"verificationCodePrompt": "הזן קוד אימות", "verificationCodePrompt": "הזן קוד אימות",
"totpVerificationFailed": "אימות TOTP נכשל. אנא נסה שוב.", "totpVerificationFailed": "אימות TOTP נכשל. אנא נסה שוב.",
"warpgateVerificationFailed": "אימות Warpgate נכשל. אנא נסה שוב.",
"connectedTo": "מחובר אל {{containerName}}", "connectedTo": "מחובר אל {{containerName}}",
"disconnected": "מְנוּתָק", "disconnected": "מְנוּתָק",
"consoleError": "שגיאת קונסולה", "consoleError": "שגיאת קונסולה",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "कंसोल तक पहुंचने के लिए कंटेनर चालू होना चाहिए", "containerMustBeRunning": "कंसोल तक पहुंचने के लिए कंटेनर चालू होना चाहिए",
"verificationCodePrompt": "सत्यापन कोड दर्ज करें", "verificationCodePrompt": "सत्यापन कोड दर्ज करें",
"totpVerificationFailed": "TOTP सत्यापन विफल। कृपया पुनः प्रयास करें।", "totpVerificationFailed": "TOTP सत्यापन विफल। कृपया पुनः प्रयास करें।",
"warpgateVerificationFailed": "Warpgate प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।",
"connectedTo": "{{containerName}} से जुड़ा", "connectedTo": "{{containerName}} से जुड़ा",
"disconnected": "डिस्कनेक्टेड", "disconnected": "डिस्कनेक्टेड",
"consoleError": "कंसोल त्रुटि", "consoleError": "कंसोल त्रुटि",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "A konténernek futnia kell a konzol eléréséhez", "containerMustBeRunning": "A konténernek futnia kell a konzol eléréséhez",
"verificationCodePrompt": "Írja be az ellenőrző kódot", "verificationCodePrompt": "Írja be az ellenőrző kódot",
"totpVerificationFailed": "A TOTP ellenőrzése sikertelen. Próbáld újra.", "totpVerificationFailed": "A TOTP ellenőrzése sikertelen. Próbáld újra.",
"warpgateVerificationFailed": "A Warpgate hitelesítése sikertelen. Próbáld újra.",
"connectedTo": "Csatlakoztatva ehhez: {{containerName}}", "connectedTo": "Csatlakoztatva ehhez: {{containerName}}",
"disconnected": "Szétkapcsolt", "disconnected": "Szétkapcsolt",
"consoleError": "Konzolhiba", "consoleError": "Konzolhiba",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Kontainer harus berjalan agar dapat mengakses konsol.", "containerMustBeRunning": "Kontainer harus berjalan agar dapat mengakses konsol.",
"verificationCodePrompt": "Masukkan kode verifikasi", "verificationCodePrompt": "Masukkan kode verifikasi",
"totpVerificationFailed": "Verifikasi TOTP gagal. Silakan coba lagi.", "totpVerificationFailed": "Verifikasi TOTP gagal. Silakan coba lagi.",
"warpgateVerificationFailed": "Autentikasi Warpgate gagal. Silakan coba lagi.",
"connectedTo": "Terhubung ke {{containerName}}", "connectedTo": "Terhubung ke {{containerName}}",
"disconnected": "Terputus", "disconnected": "Terputus",
"consoleError": "Kesalahan konsol", "consoleError": "Kesalahan konsol",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Il container deve essere in esecuzione per accedere alla console", "containerMustBeRunning": "Il container deve essere in esecuzione per accedere alla console",
"verificationCodePrompt": "Inserisci il codice di verifica", "verificationCodePrompt": "Inserisci il codice di verifica",
"totpVerificationFailed": "Verifica TOTP fallita. Riprova.", "totpVerificationFailed": "Verifica TOTP fallita. Riprova.",
"warpgateVerificationFailed": "Autenticazione Warpgate fallita. Riprova.",
"connectedTo": "Connesso a {{containerName}}", "connectedTo": "Connesso a {{containerName}}",
"disconnected": "Disconnesso", "disconnected": "Disconnesso",
"consoleError": "Errore console", "consoleError": "Errore console",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "コンソールにアクセスするにはコンテナが実行中である必要があります", "containerMustBeRunning": "コンソールにアクセスするにはコンテナが実行中である必要があります",
"verificationCodePrompt": "認証コードを入力してください", "verificationCodePrompt": "認証コードを入力してください",
"totpVerificationFailed": "TOTP認証に失敗しました。もう一度お試しください。", "totpVerificationFailed": "TOTP認証に失敗しました。もう一度お試しください。",
"warpgateVerificationFailed": "Warpgate認証に失敗しました。もう一度お試しください。",
"connectedTo": "{{containerName}}に接続しました", "connectedTo": "{{containerName}}に接続しました",
"disconnected": "切断されました", "disconnected": "切断されました",
"consoleError": "コンソールエラー", "consoleError": "コンソールエラー",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "컨테이너가 실행 중이어야 콘솔에 접근할 수 있습니다", "containerMustBeRunning": "컨테이너가 실행 중이어야 콘솔에 접근할 수 있습니다",
"verificationCodePrompt": "인증 코드를 입력하세요", "verificationCodePrompt": "인증 코드를 입력하세요",
"totpVerificationFailed": "TOTP 인증에 실패했습니다. 다시 시도하세요.", "totpVerificationFailed": "TOTP 인증에 실패했습니다. 다시 시도하세요.",
"warpgateVerificationFailed": "Warpgate 인증에 실패했습니다. 다시 시도하세요.",
"connectedTo": "{{containerName}}에 연결됨", "connectedTo": "{{containerName}}에 연결됨",
"disconnected": "연결 끊김", "disconnected": "연결 끊김",
"consoleError": "콘솔 오류", "consoleError": "콘솔 오류",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "De container moet actief zijn om toegang te krijgen tot de console.", "containerMustBeRunning": "De container moet actief zijn om toegang te krijgen tot de console.",
"verificationCodePrompt": "Voer de verificatiecode in.", "verificationCodePrompt": "Voer de verificatiecode in.",
"totpVerificationFailed": "TOTP-verificatie mislukt. Probeer het opnieuw.", "totpVerificationFailed": "TOTP-verificatie mislukt. Probeer het opnieuw.",
"warpgateVerificationFailed": "Authenticatie via Warpgate is mislukt. Probeer het opnieuw.",
"connectedTo": "Verbonden met {{containerName}}", "connectedTo": "Verbonden met {{containerName}}",
"disconnected": "Verbinding verbroken", "disconnected": "Verbinding verbroken",
"consoleError": "Consolefout", "consoleError": "Consolefout",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Containeren må kjøre for å få tilgang til konsollen", "containerMustBeRunning": "Containeren må kjøre for å få tilgang til konsollen",
"verificationCodePrompt": "Skriv inn bekreftelseskoden", "verificationCodePrompt": "Skriv inn bekreftelseskoden",
"totpVerificationFailed": "TOTP-verifisering mislyktes. Prøv på nytt.", "totpVerificationFailed": "TOTP-verifisering mislyktes. Prøv på nytt.",
"warpgateVerificationFailed": "Warpgate-autentisering mislyktes. Prøv på nytt.",
"connectedTo": "Koblet til {{containerName}}", "connectedTo": "Koblet til {{containerName}}",
"disconnected": "Frakoblet", "disconnected": "Frakoblet",
"consoleError": "Konsollfeil", "consoleError": "Konsollfeil",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Aby uzyskać dostęp do konsoli, kontener musi być uruchomiony", "containerMustBeRunning": "Aby uzyskać dostęp do konsoli, kontener musi być uruchomiony",
"verificationCodePrompt": "Wprowadź kod weryfikacyjny", "verificationCodePrompt": "Wprowadź kod weryfikacyjny",
"totpVerificationFailed": "Weryfikacja TOTP nie powiodła się. Spróbuj ponownie.", "totpVerificationFailed": "Weryfikacja TOTP nie powiodła się. Spróbuj ponownie.",
"warpgateVerificationFailed": "Uwierzytelnianie Warpgate nie powiodło się. Spróbuj ponownie.",
"connectedTo": "Połączono z {{containerName}}", "connectedTo": "Połączono z {{containerName}}",
"disconnected": "Rozłączono", "disconnected": "Rozłączono",
"consoleError": "Błąd konsoli", "consoleError": "Błąd konsoli",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "O contêiner precisa estar em execução para acessar o console.", "containerMustBeRunning": "O contêiner precisa estar em execução para acessar o console.",
"verificationCodePrompt": "Insira o código de verificação", "verificationCodePrompt": "Insira o código de verificação",
"totpVerificationFailed": "A verificação TOTP falhou. Tente novamente.", "totpVerificationFailed": "A verificação TOTP falhou. Tente novamente.",
"warpgateVerificationFailed": "A autenticação no portal de guerra falhou. Por favor, tente novamente.",
"connectedTo": "Conectado a {{containerName}}", "connectedTo": "Conectado a {{containerName}}",
"disconnected": "Desconectado", "disconnected": "Desconectado",
"consoleError": "Erro no console", "consoleError": "Erro no console",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "O contentor tem de estar em execução para aceder à consola", "containerMustBeRunning": "O contentor tem de estar em execução para aceder à consola",
"verificationCodePrompt": "Introduza o código de verificação", "verificationCodePrompt": "Introduza o código de verificação",
"totpVerificationFailed": "A verificação TOTP falhou. Por favor, tente novamente.", "totpVerificationFailed": "A verificação TOTP falhou. Por favor, tente novamente.",
"warpgateVerificationFailed": "A autenticação Warpgate falhou. Por favor, tente novamente.",
"connectedTo": "Ligado a {{containerName}}", "connectedTo": "Ligado a {{containerName}}",
"disconnected": "Desligado", "disconnected": "Desligado",
"consoleError": "Erro da consola", "consoleError": "Erro da consola",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Containerul trebuie să ruleze pentru a accesa consola.", "containerMustBeRunning": "Containerul trebuie să ruleze pentru a accesa consola.",
"verificationCodePrompt": "Introduceți codul de verificare", "verificationCodePrompt": "Introduceți codul de verificare",
"totpVerificationFailed": "Verificarea TOTP a eșuat. Vă rugăm să încercați din nou.", "totpVerificationFailed": "Verificarea TOTP a eșuat. Vă rugăm să încercați din nou.",
"warpgateVerificationFailed": "Autentificarea Warpgate a eșuat. Vă rugăm să încercați din nou.",
"connectedTo": "Conectat la {{containerName}}", "connectedTo": "Conectat la {{containerName}}",
"disconnected": "Deconectat", "disconnected": "Deconectat",
"consoleError": "Eroare consolă", "consoleError": "Eroare consolă",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Для доступа к консоли контейнер должен быть запущен", "containerMustBeRunning": "Для доступа к консоли контейнер должен быть запущен",
"verificationCodePrompt": "Введите код подтверждения", "verificationCodePrompt": "Введите код подтверждения",
"totpVerificationFailed": "Ошибка проверки TOTP. Попробуйте еще раз.", "totpVerificationFailed": "Ошибка проверки TOTP. Попробуйте еще раз.",
"warpgateVerificationFailed": "Ошибка аутентификации Warpgate. Попробуйте еще раз.",
"connectedTo": "Подключено к {{containerName}}", "connectedTo": "Подключено к {{containerName}}",
"disconnected": "Отключено", "disconnected": "Отключено",
"consoleError": "Ошибка консоли", "consoleError": "Ошибка консоли",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Контејнер мора бити покренут да би се приступило конзоли", "containerMustBeRunning": "Контејнер мора бити покренут да би се приступило конзоли",
"verificationCodePrompt": "Унесите верификациони код", "verificationCodePrompt": "Унесите верификациони код",
"totpVerificationFailed": "Верификација TOTP-а није успела. Молимо покушајте поново.", "totpVerificationFailed": "Верификација TOTP-а није успела. Молимо покушајте поново.",
"warpgateVerificationFailed": "Аутентификација преко Ворпгејта није успела. Молимо покушајте поново.",
"connectedTo": "Повезано са {{containerName}}", "connectedTo": "Повезано са {{containerName}}",
"disconnected": "Искључено", "disconnected": "Искључено",
"consoleError": "Грешка конзоле", "consoleError": "Грешка конзоле",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Behållaren måste köras för att komma åt konsolen", "containerMustBeRunning": "Behållaren måste köras för att komma åt konsolen",
"verificationCodePrompt": "Ange verifieringskod", "verificationCodePrompt": "Ange verifieringskod",
"totpVerificationFailed": "TOTP-verifieringen misslyckades. Försök igen.", "totpVerificationFailed": "TOTP-verifieringen misslyckades. Försök igen.",
"warpgateVerificationFailed": "Warpgate-autentiseringen misslyckades. Försök igen.",
"connectedTo": "Ansluten till {{containerName}}", "connectedTo": "Ansluten till {{containerName}}",
"disconnected": "Osammanhängande", "disconnected": "Osammanhängande",
"consoleError": "Konsolfel", "consoleError": "Konsolfel",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "ต้องเปิดใช้งานคอนเทนเนอร์ก่อนจึงจะสามารถเข้าถึงคอนโซลได้", "containerMustBeRunning": "ต้องเปิดใช้งานคอนเทนเนอร์ก่อนจึงจะสามารถเข้าถึงคอนโซลได้",
"verificationCodePrompt": "ป้อนรหัสยืนยัน", "verificationCodePrompt": "ป้อนรหัสยืนยัน",
"totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว โปรดลองอีกครั้ง", "totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว โปรดลองอีกครั้ง",
"warpgateVerificationFailed": "การตรวจสอบสิทธิ์ผ่านวาร์ปไม่สำเร็จ โปรดลองอีกครั้ง",
"connectedTo": "เชื่อมต่อกับ {{containerName}}", "connectedTo": "เชื่อมต่อกับ {{containerName}}",
"disconnected": "ตัดการเชื่อมต่อ", "disconnected": "ตัดการเชื่อมต่อ",
"consoleError": "ข้อผิดพลาดของคอนโซล", "consoleError": "ข้อผิดพลาดของคอนโซล",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Konsola erişmek için konteyner çalışıyor olmalıdır", "containerMustBeRunning": "Konsola erişmek için konteyner çalışıyor olmalıdır",
"verificationCodePrompt": "Doğrulama kodunu girin", "verificationCodePrompt": "Doğrulama kodunu girin",
"totpVerificationFailed": "TOTP doğrulaması başarısız oldu. Lütfen tekrar deneyin.", "totpVerificationFailed": "TOTP doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
"warpgateVerificationFailed": "Warpgate kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
"connectedTo": "{{containerName}} ile bağlantı kuruldu", "connectedTo": "{{containerName}} ile bağlantı kuruldu",
"disconnected": "Bağlantı kesildi", "disconnected": "Bağlantı kesildi",
"consoleError": "Konsol hatası", "consoleError": "Konsol hatası",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Для доступу до консолі контейнер має бути запущений", "containerMustBeRunning": "Для доступу до консолі контейнер має бути запущений",
"verificationCodePrompt": "Введіть код підтвердження", "verificationCodePrompt": "Введіть код підтвердження",
"totpVerificationFailed": "Не вдалося перевірити TOTP. Спробуйте ще раз.", "totpVerificationFailed": "Не вдалося перевірити TOTP. Спробуйте ще раз.",
"warpgateVerificationFailed": "Помилка автентифікації Warpgate. Будь ласка, спробуйте ще раз.",
"connectedTo": "Підключено до {{containerName}}", "connectedTo": "Підключено до {{containerName}}",
"disconnected": "Відключено", "disconnected": "Відключено",
"consoleError": "Помилка консолі", "consoleError": "Помилка консолі",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "Container phải đang chạy để truy cập bảng điều khiển", "containerMustBeRunning": "Container phải đang chạy để truy cập bảng điều khiển",
"verificationCodePrompt": "Nhập mã xác minh", "verificationCodePrompt": "Nhập mã xác minh",
"totpVerificationFailed": "Xác minh TOTP thất bại. Vui lòng thử lại.", "totpVerificationFailed": "Xác minh TOTP thất bại. Vui lòng thử lại.",
"warpgateVerificationFailed": "Xác thực Warpgate thất bại. Vui lòng thử lại.",
"connectedTo": "Đã kết nối đến {{containerName}}", "connectedTo": "Đã kết nối đến {{containerName}}",
"disconnected": "Đã ngắt kết nối", "disconnected": "Đã ngắt kết nối",
"consoleError": "Lỗi bảng điều khiển", "consoleError": "Lỗi bảng điều khiển",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "访问控制台需要容器处于运行状态", "containerMustBeRunning": "访问控制台需要容器处于运行状态",
"verificationCodePrompt": "输入验证码", "verificationCodePrompt": "输入验证码",
"totpVerificationFailed": "TOTP 验证失败,请重试。", "totpVerificationFailed": "TOTP 验证失败,请重试。",
"warpgateVerificationFailed": "Warpgate 认证失败,请重试。",
"connectedTo": "已连接到 {{containerName}}", "connectedTo": "已连接到 {{containerName}}",
"disconnected": "已断开连接", "disconnected": "已断开连接",
"consoleError": "控制台错误", "consoleError": "控制台错误",
@@ -73,7 +73,6 @@
"containerMustBeRunning": "容器必須在執行中才能存取主控台", "containerMustBeRunning": "容器必須在執行中才能存取主控台",
"verificationCodePrompt": "請輸入驗證碼", "verificationCodePrompt": "請輸入驗證碼",
"totpVerificationFailed": "TOTP 驗證失敗,請再試一次。", "totpVerificationFailed": "TOTP 驗證失敗,請再試一次。",
"warpgateVerificationFailed": "Warpgate 驗證失敗,請再試一次。",
"connectedTo": "已連線至 {{containerName}}", "connectedTo": "已連線至 {{containerName}}",
"disconnected": "已中斷連線", "disconnected": "已中斷連線",
"consoleError": "主控台錯誤", "consoleError": "主控台錯誤",
+15 -14
View File
@@ -2,9 +2,9 @@
* The Docker panel's interactive connect. * The Docker panel's interactive connect.
* *
* Core's pipeline does the handshake; this only bridges its prompt channel to * Core's pipeline does the handshake; this only bridges its prompt channel to
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a * HTTP. A connect that needs a person (a TOTP code, a browser sign-in, a
* password the host does not store) answers the request that is waiting with * password the host does not store) answers the request that is waiting with
* what to ask, and the next request (connect-totp, connect-warpgate) carries * what to ask, and the next request (connect-totp, connect-browser-sign-in) carries
* the answer back and waits for whatever comes after it: the session, another * the answer back and waits for whatever comes after it: the session, another
* prompt, or an error. * prompt, or an error.
*/ */
@@ -31,10 +31,10 @@ export interface ConnectStep {
} }
const TOTP_WAIT_MS = 3 * 60 * 1000; const TOTP_WAIT_MS = 3 * 60 * 1000;
const WARPGATE_WAIT_MS = 5 * 60 * 1000; const BROWSER_SIGN_IN_WAIT_MS = 5 * 60 * 1000;
const STEP_WAIT_MS = 90 * 1000; const STEP_WAIT_MS = 90 * 1000;
// Covers the longest a person may take on a prompt, plus the handshake. // Covers the longest a person may take on a prompt, plus the handshake.
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000; const CONNECT_TIMEOUT_MS = BROWSER_SIGN_IN_WAIT_MS + 30 * 1000;
const PASSWORD_PROMPT = /password/i; const PASSWORD_PROMPT = /password/i;
@@ -47,7 +47,7 @@ export class PendingConnect {
expiresAt = Date.now() + TOTP_WAIT_MS; expiresAt = Date.now() + TOTP_WAIT_MS;
/** What the open prompt expects, or null when nothing is being asked. */ /** What the open prompt expects, or null when nothing is being asked. */
awaiting: "totp" | "warpgate" | null = null; awaiting: "totp" | "browser" | null = null;
abandoned = false; abandoned = false;
private waiter: ((step: ConnectStep) => void) | null = null; private waiter: ((step: ConnectStep) => void) | null = null;
private queued: ConnectStep | null = null; private queued: ConnectStep | null = null;
@@ -87,7 +87,7 @@ export class PendingConnect {
}); });
} }
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> { ask(kind: "totp" | "browser", waitMs: number): Promise<string | null> {
this.awaiting = kind; this.awaiting = kind;
this.expiresAt = Date.now() + waitMs; this.expiresAt = Date.now() + waitMs;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -203,22 +203,23 @@ export async function startConnect(
switch (request.kind) { switch (request.kind) {
case "totp": case "totp":
return parkTotp(request.prompt, request.retry ? { retry: true } : {}); return parkTotp(request.prompt, request.retry ? { retry: true } : {});
case "warpgate": { case "browser": {
logs.push( logs.push(
connectionLog( connectionLog(
"info", "info",
"docker_auth", "docker_auth",
"Warpgate authentication required", `${request.label} sign-in required`,
), ),
); );
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS); const answer = pending.ask("browser", BROWSER_SIGN_IN_WAIT_MS);
pending.emit({ pending.emit({
status: 200, status: 200,
body: { body: {
requires_warpgate: true, requires_browser_sign_in: true,
sessionId, sessionId,
label: request.label,
url: request.url, url: request.url,
securityKey: request.securityKey, code: request.code,
connectionLogs: logs, connectionLogs: logs,
}, },
}); });
@@ -378,7 +379,7 @@ export async function answerConnect(
sessions: DockerSessions, sessions: DockerSessions,
sessionId: string, sessionId: string,
userId: string, userId: string,
kind: "totp" | "warpgate", kind: "totp" | "browser",
value: string, value: string,
): Promise<ConnectStep> { ): Promise<ConnectStep> {
const pending = sessions.getPending(sessionId); const pending = sessions.getPending(sessionId);
@@ -393,8 +394,8 @@ export async function answerConnect(
status: 400, status: 400,
body: { body: {
error: error:
kind === "warpgate" kind === "browser"
? "Session is not a Warpgate session" ? "Session is not waiting for a browser sign-in"
: "Session is not waiting for a code", : "Session is not waiting for a code",
}, },
}; };
+7 -7
View File
@@ -104,7 +104,7 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
* /plugin-api/docker/ssh/connect: * /plugin-api/docker/ssh/connect:
* post: * post:
* summary: Establish SSH session for Docker * summary: Establish SSH session for Docker
* description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a Warpgate sign-in or a password, the reply says so and the session waits for connect-totp or connect-warpgate. * description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a browser sign-in or a password, the reply says so and the session waits for connect-totp or connect-browser-sign-in.
* tags: * tags:
* - Docker * - Docker
* requestBody: * requestBody:
@@ -311,10 +311,10 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
/** /**
* @openapi * @openapi
* /plugin-api/docker/ssh/connect-warpgate: * /plugin-api/docker/ssh/connect-browser-sign-in:
* post: * post:
* summary: Complete Warpgate authentication * summary: Continue after a browser sign-in
* description: Continues a connect after the user finished the Warpgate sign-in in the browser. * description: Continues a connect after the user finished signing in in the browser (an SSH gateway's approval, for example).
* tags: * tags:
* - Docker * - Docker
* requestBody: * requestBody:
@@ -332,13 +332,13 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
* 200: * 200:
* description: Connected, or another prompt. * description: Connected, or another prompt.
* 400: * 400:
* description: Session ID required, or the session is not a Warpgate session. * description: Session ID required, or the session is not waiting for a browser sign-in.
* 404: * 404:
* description: The connect expired. * description: The connect expired.
* 408: * 408:
* description: The host did not answer in time. * description: The host did not answer in time.
*/ */
router.post("/ssh/connect-warpgate", async (req, res) => { router.post("/ssh/connect-browser-sign-in", async (req, res) => {
const userId = userOf(res); const userId = userOf(res);
if (!userId) return; if (!userId) return;
const { sessionId } = (req.body ?? {}) as Record<string, unknown>; const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
@@ -349,7 +349,7 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
sessions, sessions,
sessionId, sessionId,
userId, userId,
"warpgate", "browser",
"", "",
); );
return res.status(step.status).json(step.body); return res.status(step.status).json(step.body);
+1 -1
View File
@@ -20,7 +20,7 @@ export interface DockerSession {
cancelIdle?: () => void; cancelIdle?: () => void;
} }
/** A connect still waiting on a person (TOTP, Warpgate). */ /** A connect still waiting on a person (TOTP, a browser sign-in). */
export interface PendingEntry { export interface PendingEntry {
userId: string; userId: string;
hostId: number; hostId: number;
+23 -16
View File
@@ -26,7 +26,7 @@ import {
Select2, Select2,
Separator, Separator,
TOTPDialog, TOTPDialog,
WarpgateDialog, BrowserSignInDialog,
logActivity, logActivity,
useAdaptivePolling, useAdaptivePolling,
useAreaPreferences, useAreaPreferences,
@@ -57,12 +57,13 @@ interface DockerManagerProps {
} }
interface PromptState { interface PromptState {
kind: "totp" | "warpgate"; kind: "totp" | "browser";
sessionId: string; sessionId: string;
prompt?: string; prompt?: string;
isPassword?: boolean; isPassword?: boolean;
url?: string; url?: string;
securityKey?: string; code?: string;
label?: string;
} }
function newSessionId(): string { function newSessionId(): string {
@@ -193,12 +194,13 @@ function DockerManagerInner({
) => { ) => {
if (result.connectionLogs) setLogs(result.connectionLogs); if (result.connectionLogs) setLogs(result.connectionLogs);
if (result.requires_warpgate) { if (result.requires_browser_sign_in) {
setPrompt({ setPrompt({
kind: "warpgate", kind: "browser",
sessionId: sid, sessionId: sid,
url: result.url || "", url: result.url || "",
securityKey: result.securityKey || "", code: result.code || "",
label: result.label || "",
}); });
return; return;
} }
@@ -357,15 +359,19 @@ function DockerManagerInner({
} }
}; };
const handleWarpgateContinue = async () => { const handleBrowserSignInContinue = async () => {
if (!prompt || prompt.kind !== "warpgate") return; if (!prompt || prompt.kind !== "browser") return;
const sid = prompt.sessionId; const sid = prompt.sessionId;
setPrompt(null); setPrompt(null);
setIsConnecting(true); setIsConnecting(true);
try { try {
await applyConnectResult(sid, await docker.continueWarpgate(sid), false); await applyConnectResult(
sid,
await docker.continueBrowserSignIn(sid),
false,
);
} catch (error) { } catch (error) {
reportFailure(error, t("docker.warpgateVerificationFailed")); reportFailure(error, t("docker.browserSignInFailed"));
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -379,7 +385,7 @@ function DockerManagerInner({
closeTab(); closeTab();
}; };
const handleWarpgateOpenUrl = () => { const handleBrowserSignInOpenUrl = () => {
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer"); if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
}; };
@@ -475,13 +481,14 @@ function DockerManagerInner({
onSubmit={handleTotpSubmit} onSubmit={handleTotpSubmit}
onCancel={handlePromptCancel} onCancel={handlePromptCancel}
/> />
<WarpgateDialog <BrowserSignInDialog
isOpen={prompt?.kind === "warpgate"} isOpen={prompt?.kind === "browser"}
label={prompt?.label ?? ""}
url={prompt?.url ?? ""} url={prompt?.url ?? ""}
securityKey={prompt?.securityKey ?? ""} code={prompt?.code ?? ""}
onContinue={handleWarpgateContinue} onContinue={handleBrowserSignInContinue}
onCancel={handlePromptCancel} onCancel={handlePromptCancel}
onOpenUrl={handleWarpgateOpenUrl} onOpenUrl={handleBrowserSignInOpenUrl}
/> />
{currentHost && ( {currentHost && (
<SSHAuthDialog <SSHAuthDialog
+6 -5
View File
@@ -28,9 +28,10 @@ export interface ConnectResult {
prompt?: string; prompt?: string;
isPassword?: boolean; isPassword?: boolean;
retry?: boolean; retry?: boolean;
requires_warpgate?: boolean; requires_browser_sign_in?: boolean;
url?: string | null; url?: string | null;
securityKey?: string; code?: string;
label?: string;
connectionLogs?: ApiConnectionLog[]; connectionLogs?: ApiConnectionLog[];
} }
@@ -101,9 +102,9 @@ export function createDockerApi(api: PluginApiClient) {
api.post("/ssh/connect-totp", { sessionId, totpCode }), api.post("/ssh/connect-totp", { sessionId, totpCode }),
), ),
continueWarpgate: (sessionId: string) => continueBrowserSignIn: (sessionId: string) =>
call<ConnectResult>("complete Warpgate authentication", () => call<ConnectResult>("continue after a browser sign-in", () =>
api.post("/ssh/connect-warpgate", { sessionId }), api.post("/ssh/connect-browser-sign-in", { sessionId }),
), ),
disconnect: (sessionId: string) => disconnect: (sessionId: string) =>
+12 -9
View File
@@ -44,7 +44,7 @@ describe("docker routes", () => {
const calls: Array<[string, string]> = [ const calls: Array<[string, string]> = [
["POST", "/ssh/connect"], ["POST", "/ssh/connect"],
["POST", "/ssh/connect-totp"], ["POST", "/ssh/connect-totp"],
["POST", "/ssh/connect-warpgate"], ["POST", "/ssh/connect-browser-sign-in"],
["POST", "/ssh/disconnect"], ["POST", "/ssh/disconnect"],
["POST", "/ssh/keepalive"], ["POST", "/ssh/keepalive"],
["GET", "/ssh/status?sessionId=s1"], ["GET", "/ssh/status?sessionId=s1"],
@@ -204,29 +204,32 @@ describe("docker routes", () => {
expect((await server.request("GET", "/containers/s1")).status).toBe(200); expect((await server.request("GET", "/containers/s1")).status).toBe(200);
}); });
it("continues a Warpgate sign-in and refuses a code for it", async () => { it("continues a browser sign-in and refuses a code for it", async () => {
server = await startServer(); server = await startServer();
promptingConnect(server, async (ask) => { promptingConnect(server, async (ask) => {
await ask({ await ask({
kind: "warpgate", kind: "browser",
url: "https://warpgate.example/login", id: "gateway",
securityKey: "KEY", label: "Gateway",
url: "https://gateway.example/login",
code: "KEY",
instructions: "", instructions: "",
}); });
}); });
const first = await connect(server); const first = await connect(server);
expect(first.body).toMatchObject({ expect(first.body).toMatchObject({
requires_warpgate: true, requires_browser_sign_in: true,
url: "https://warpgate.example/login", label: "Gateway",
securityKey: "KEY", url: "https://gateway.example/login",
code: "KEY",
}); });
const wrongKind = await server.request("POST", "/ssh/connect-totp", { const wrongKind = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "1" }, body: { sessionId: "s1", totpCode: "1" },
}); });
expect(wrongKind.status).toBe(400); expect(wrongKind.status).toBe(400);
const done = await server.request("POST", "/ssh/connect-warpgate", { const done = await server.request("POST", "/ssh/connect-browser-sign-in", {
body: { sessionId: "s1" }, body: { sessionId: "s1" },
}); });
expect(done.body.success).toBe(true); expect(done.body.success).toBe(true);
+1 -1
View File
@@ -316,7 +316,7 @@
"connecting": "Connecting...", "connecting": "Connecting...",
"connectedSuccessfully": "Connected successfully", "connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed", "totpVerificationFailed": "TOTP verification failed",
"warpgateVerificationFailed": "Warpgate authentication failed", "browserSignInFailed": "Sign-in failed. Please try again.",
"authenticationFailed": "Authentication failed", "authenticationFailed": "Authentication failed",
"incorrectPassphrase": "Incorrect passphrase. Please try again.", "incorrectPassphrase": "Incorrect passphrase. Please try again.",
"verificationCodePrompt": "Verification code:", "verificationCodePrompt": "Verification code:",
@@ -290,7 +290,6 @@
"connecting": "Verbind...", "connecting": "Verbind...",
"connectedSuccessfully": "Suksesvol gekoppel", "connectedSuccessfully": "Suksesvol gekoppel",
"totpVerificationFailed": "TOTP-verifikasie het misluk", "totpVerificationFailed": "TOTP-verifikasie het misluk",
"warpgateVerificationFailed": "Warpgate-verifikasie het misluk",
"authenticationFailed": "Verifikasie het misluk", "authenticationFailed": "Verifikasie het misluk",
"incorrectPassphrase": "Verkeerde wagwoordfrase. Probeer asseblief weer.", "incorrectPassphrase": "Verkeerde wagwoordfrase. Probeer asseblief weer.",
"verificationCodePrompt": "Verifikasiekode:", "verificationCodePrompt": "Verifikasiekode:",
@@ -290,7 +290,6 @@
"connecting": "جارٍ الاتصال...", "connecting": "جارٍ الاتصال...",
"connectedSuccessfully": "تم الاتصال بنجاح", "connectedSuccessfully": "تم الاتصال بنجاح",
"totpVerificationFailed": "فشل التحقق من TOTP", "totpVerificationFailed": "فشل التحقق من TOTP",
"warpgateVerificationFailed": "فشلت مصادقة Warpgate",
"authenticationFailed": "فشلت المصادقة", "authenticationFailed": "فشلت المصادقة",
"incorrectPassphrase": "عبارة المرور غير صحيحة. يرجى المحاولة مرة أخرى.", "incorrectPassphrase": "عبارة المرور غير صحيحة. يرجى المحاولة مرة أخرى.",
"verificationCodePrompt": "رمز التحقق:", "verificationCodePrompt": "رمز التحقق:",
@@ -290,7 +290,6 @@
"connecting": "Свързване...", "connecting": "Свързване...",
"connectedSuccessfully": "Свързаването е осъществено успешно", "connectedSuccessfully": "Свързаването е осъществено успешно",
"totpVerificationFailed": "Проверката на TOTP не бе успешна", "totpVerificationFailed": "Проверката на TOTP не бе успешна",
"warpgateVerificationFailed": "Удостоверяването на Warpgate не беше успешно",
"authenticationFailed": "Удостоверяването не бе успешно", "authenticationFailed": "Удостоверяването не бе успешно",
"incorrectPassphrase": "Неправилна парола. Моля, опитайте отново.", "incorrectPassphrase": "Неправилна парола. Моля, опитайте отново.",
"verificationCodePrompt": "Код за потвърждение:", "verificationCodePrompt": "Код за потвърждение:",
@@ -290,7 +290,6 @@
"connecting": "সংযোগ স্থাপন...", "connecting": "সংযোগ স্থাপন...",
"connectedSuccessfully": "সফলভাবে সংযুক্ত হয়েছে", "connectedSuccessfully": "সফলভাবে সংযুক্ত হয়েছে",
"totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে", "totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে",
"warpgateVerificationFailed": "ওয়ার্পগেট প্রমাণীকরণ ব্যর্থ হয়েছে",
"authenticationFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে", "authenticationFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে",
"incorrectPassphrase": "ভুল পাসফ্রেজ। অনুগ্রহ করে আবার চেষ্টা করুন।", "incorrectPassphrase": "ভুল পাসফ্রেজ। অনুগ্রহ করে আবার চেষ্টা করুন।",
"verificationCodePrompt": "যাচাইকরণ কোড:", "verificationCodePrompt": "যাচাইকরণ কোড:",
@@ -290,7 +290,6 @@
"connecting": "Connectant...", "connecting": "Connectant...",
"connectedSuccessfully": "Connexió correcta", "connectedSuccessfully": "Connexió correcta",
"totpVerificationFailed": "La verificació TOTP ha fallat", "totpVerificationFailed": "La verificació TOTP ha fallat",
"warpgateVerificationFailed": "L'autenticació de Warpgate ha fallat",
"authenticationFailed": "L'autenticació ha fallat", "authenticationFailed": "L'autenticació ha fallat",
"incorrectPassphrase": "Contrasenya incorrecta. Si us plau, torneu-ho a intentar.", "incorrectPassphrase": "Contrasenya incorrecta. Si us plau, torneu-ho a intentar.",
"verificationCodePrompt": "Codi de verificació:", "verificationCodePrompt": "Codi de verificació:",
@@ -290,7 +290,6 @@
"connecting": "Spojovací...", "connecting": "Spojovací...",
"connectedSuccessfully": "Úspěšné připojení", "connectedSuccessfully": "Úspěšné připojení",
"totpVerificationFailed": "Ověření TOTP se nezdařilo", "totpVerificationFailed": "Ověření TOTP se nezdařilo",
"warpgateVerificationFailed": "Ověření Warpgate selhalo",
"authenticationFailed": "Ověření se nezdařilo", "authenticationFailed": "Ověření se nezdařilo",
"incorrectPassphrase": "Nesprávné heslo. Zkuste to prosím znovu.", "incorrectPassphrase": "Nesprávné heslo. Zkuste to prosím znovu.",
"verificationCodePrompt": "Ověřovací kód:", "verificationCodePrompt": "Ověřovací kód:",
@@ -290,7 +290,6 @@
"connecting": "Forbinder...", "connecting": "Forbinder...",
"connectedSuccessfully": "Forbindelsen er oprettet", "connectedSuccessfully": "Forbindelsen er oprettet",
"totpVerificationFailed": "TOTP-bekræftelse mislykkedes", "totpVerificationFailed": "TOTP-bekræftelse mislykkedes",
"warpgateVerificationFailed": "Warpgate-godkendelse mislykkedes",
"authenticationFailed": "Godkendelse mislykkedes", "authenticationFailed": "Godkendelse mislykkedes",
"incorrectPassphrase": "Forkert adgangskode. Prøv igen.", "incorrectPassphrase": "Forkert adgangskode. Prøv igen.",
"verificationCodePrompt": "Bekræftelseskode:", "verificationCodePrompt": "Bekræftelseskode:",
@@ -290,7 +290,6 @@
"connecting": "Verbindung wird hergestellt...", "connecting": "Verbindung wird hergestellt...",
"connectedSuccessfully": "Erfolgreich verbunden", "connectedSuccessfully": "Erfolgreich verbunden",
"totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen", "totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen",
"warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen",
"authenticationFailed": "Authentifizierung fehlgeschlagen", "authenticationFailed": "Authentifizierung fehlgeschlagen",
"incorrectPassphrase": "Falsche Passphrase. Bitte versuchen Sie es erneut.", "incorrectPassphrase": "Falsche Passphrase. Bitte versuchen Sie es erneut.",
"verificationCodePrompt": "Verifizierungscode:", "verificationCodePrompt": "Verifizierungscode:",
@@ -290,7 +290,6 @@
"connecting": "Συνδετικός...", "connecting": "Συνδετικός...",
"connectedSuccessfully": "Η σύνδεση ολοκληρώθηκε με επιτυχία", "connectedSuccessfully": "Η σύνδεση ολοκληρώθηκε με επιτυχία",
"totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε.", "totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε.",
"warpgateVerificationFailed": "Η πιστοποίηση Warpgate απέτυχε.",
"authenticationFailed": "Η επαλήθευση ταυτότητας απέτυχε", "authenticationFailed": "Η επαλήθευση ταυτότητας απέτυχε",
"incorrectPassphrase": "Λανθασμένη φράση πρόσβασης. Δοκιμάστε ξανά.", "incorrectPassphrase": "Λανθασμένη φράση πρόσβασης. Δοκιμάστε ξανά.",
"verificationCodePrompt": "Κωδικός επαλήθευσης:", "verificationCodePrompt": "Κωδικός επαλήθευσης:",
@@ -290,7 +290,6 @@
"connecting": "Conectando...", "connecting": "Conectando...",
"connectedSuccessfully": "Conectado correctamente", "connectedSuccessfully": "Conectado correctamente",
"totpVerificationFailed": "Error en la verificación TOTP", "totpVerificationFailed": "Error en la verificación TOTP",
"warpgateVerificationFailed": "Error de autenticación de Warpgate",
"authenticationFailed": "Error de autenticación", "authenticationFailed": "Error de autenticación",
"incorrectPassphrase": "Frase de contraseña incorrecta. Inténtelo de nuevo.", "incorrectPassphrase": "Frase de contraseña incorrecta. Inténtelo de nuevo.",
"verificationCodePrompt": "Código de verificación:", "verificationCodePrompt": "Código de verificación:",
@@ -290,7 +290,6 @@
"connecting": "Yhdistetään...", "connecting": "Yhdistetään...",
"connectedSuccessfully": "Yhteys onnistui", "connectedSuccessfully": "Yhteys onnistui",
"totpVerificationFailed": "TOTP-vahvistus epäonnistui", "totpVerificationFailed": "TOTP-vahvistus epäonnistui",
"warpgateVerificationFailed": "Warpgate-todennus epäonnistui",
"authenticationFailed": "Todennus epäonnistui", "authenticationFailed": "Todennus epäonnistui",
"incorrectPassphrase": "Virheellinen salasana. Yritä uudelleen.", "incorrectPassphrase": "Virheellinen salasana. Yritä uudelleen.",
"verificationCodePrompt": "Vahvistuskoodi:", "verificationCodePrompt": "Vahvistuskoodi:",
@@ -290,7 +290,6 @@
"connecting": "Connexion en cours...", "connecting": "Connexion en cours...",
"connectedSuccessfully": "Connecté avec succès", "connectedSuccessfully": "Connecté avec succès",
"totpVerificationFailed": "Échec de la vérification TOTP", "totpVerificationFailed": "Échec de la vérification TOTP",
"warpgateVerificationFailed": "Échec de l'authentification Warpgate",
"authenticationFailed": "Échec de l'authentification", "authenticationFailed": "Échec de l'authentification",
"incorrectPassphrase": "Phrase secrète incorrecte. Veuillez réessayer.", "incorrectPassphrase": "Phrase secrète incorrecte. Veuillez réessayer.",
"verificationCodePrompt": "Code de vérification :", "verificationCodePrompt": "Code de vérification :",
@@ -290,7 +290,6 @@
"connecting": "מְקַשֵׁר...", "connecting": "מְקַשֵׁר...",
"connectedSuccessfully": "התחבר בהצלחה", "connectedSuccessfully": "התחבר בהצלחה",
"totpVerificationFailed": "אימות TOTP נכשל", "totpVerificationFailed": "אימות TOTP נכשל",
"warpgateVerificationFailed": "אימות Warpgate נכשל",
"authenticationFailed": "האימות נכשל", "authenticationFailed": "האימות נכשל",
"incorrectPassphrase": "סיסמה שגויה. אנא נסה שוב.", "incorrectPassphrase": "סיסמה שגויה. אנא נסה שוב.",
"verificationCodePrompt": "קוד אימות:", "verificationCodePrompt": "קוד אימות:",
@@ -290,7 +290,6 @@
"connecting": "कनेक्ट हो रहा है...", "connecting": "कनेक्ट हो रहा है...",
"connectedSuccessfully": "सफलतापूर्वक कनेक्ट हुआ", "connectedSuccessfully": "सफलतापूर्वक कनेक्ट हुआ",
"totpVerificationFailed": "TOTP सत्यापन विफल", "totpVerificationFailed": "TOTP सत्यापन विफल",
"warpgateVerificationFailed": "Warpgate सत्यापन विफल",
"authenticationFailed": "प्रमाणीकरण विफल", "authenticationFailed": "प्रमाणीकरण विफल",
"incorrectPassphrase": "गलत पासफ़्रेज़। कृपया पुनः प्रयास करें।", "incorrectPassphrase": "गलत पासफ़्रेज़। कृपया पुनः प्रयास करें।",
"verificationCodePrompt": "सत्यापन कोड:", "verificationCodePrompt": "सत्यापन कोड:",
@@ -290,7 +290,6 @@
"connecting": "Kapcsolódás...", "connecting": "Kapcsolódás...",
"connectedSuccessfully": "Sikeres csatlakozás", "connectedSuccessfully": "Sikeres csatlakozás",
"totpVerificationFailed": "TOTP-ellenőrzés sikertelen", "totpVerificationFailed": "TOTP-ellenőrzés sikertelen",
"warpgateVerificationFailed": "Warpgate hitelesítés sikertelen",
"authenticationFailed": "Hitelesítés sikertelen", "authenticationFailed": "Hitelesítés sikertelen",
"incorrectPassphrase": "Hibás jelszó. Kérjük, próbálja újra.", "incorrectPassphrase": "Hibás jelszó. Kérjük, próbálja újra.",
"verificationCodePrompt": "Ellenőrző kód:", "verificationCodePrompt": "Ellenőrző kód:",
@@ -290,7 +290,6 @@
"connecting": "Menghubungkan...", "connecting": "Menghubungkan...",
"connectedSuccessfully": "Terhubung berhasil", "connectedSuccessfully": "Terhubung berhasil",
"totpVerificationFailed": "Verifikasi TOTP gagal", "totpVerificationFailed": "Verifikasi TOTP gagal",
"warpgateVerificationFailed": "Autentikasi Warpgate gagal",
"authenticationFailed": "Autentikasi gagal", "authenticationFailed": "Autentikasi gagal",
"incorrectPassphrase": "Kata sandi salah. Silakan coba lagi.", "incorrectPassphrase": "Kata sandi salah. Silakan coba lagi.",
"verificationCodePrompt": "Kode verifikasi:", "verificationCodePrompt": "Kode verifikasi:",
@@ -290,7 +290,6 @@
"connecting": "Connessione in corso...", "connecting": "Connessione in corso...",
"connectedSuccessfully": "Connesso con successo", "connectedSuccessfully": "Connesso con successo",
"totpVerificationFailed": "Verifica TOTP non riuscita", "totpVerificationFailed": "Verifica TOTP non riuscita",
"warpgateVerificationFailed": "Autenticazione Warpgate non riuscita",
"authenticationFailed": "Autenticazione non riuscita", "authenticationFailed": "Autenticazione non riuscita",
"incorrectPassphrase": "Frase di accesso non corretta. Riprova.", "incorrectPassphrase": "Frase di accesso non corretta. Riprova.",
"verificationCodePrompt": "Codice di verifica:", "verificationCodePrompt": "Codice di verifica:",
@@ -290,7 +290,6 @@
"connecting": "接続中...", "connecting": "接続中...",
"connectedSuccessfully": "接続しました", "connectedSuccessfully": "接続しました",
"totpVerificationFailed": "TOTP検証に失敗しました", "totpVerificationFailed": "TOTP検証に失敗しました",
"warpgateVerificationFailed": "Warpgate認証に失敗しました",
"authenticationFailed": "認証に失敗しました", "authenticationFailed": "認証に失敗しました",
"incorrectPassphrase": "パスフレーズが間違っています。もう一度お試しください。", "incorrectPassphrase": "パスフレーズが間違っています。もう一度お試しください。",
"verificationCodePrompt": "認証コード:", "verificationCodePrompt": "認証コード:",
@@ -290,7 +290,6 @@
"connecting": "연결 중...", "connecting": "연결 중...",
"connectedSuccessfully": "연결 성공", "connectedSuccessfully": "연결 성공",
"totpVerificationFailed": "TOTP 인증 실패", "totpVerificationFailed": "TOTP 인증 실패",
"warpgateVerificationFailed": "Warpgate 인증 실패",
"authenticationFailed": "인증 실패", "authenticationFailed": "인증 실패",
"incorrectPassphrase": "잘못된 암호입니다. 다시 시도하세요.", "incorrectPassphrase": "잘못된 암호입니다. 다시 시도하세요.",
"verificationCodePrompt": "인증 코드:", "verificationCodePrompt": "인증 코드:",
@@ -290,7 +290,6 @@
"connecting": "Verbinden...", "connecting": "Verbinden...",
"connectedSuccessfully": "Verbinding succesvol tot stand gebracht.", "connectedSuccessfully": "Verbinding succesvol tot stand gebracht.",
"totpVerificationFailed": "TOTP-verificatie mislukt", "totpVerificationFailed": "TOTP-verificatie mislukt",
"warpgateVerificationFailed": "Warpgate-authenticatie mislukt",
"authenticationFailed": "Authenticatie mislukt", "authenticationFailed": "Authenticatie mislukt",
"incorrectPassphrase": "Onjuiste wachtzin. Probeer het opnieuw.", "incorrectPassphrase": "Onjuiste wachtzin. Probeer het opnieuw.",
"verificationCodePrompt": "Verificatiecode:", "verificationCodePrompt": "Verificatiecode:",
@@ -290,7 +290,6 @@
"connecting": "Kobler til...", "connecting": "Kobler til...",
"connectedSuccessfully": "Tilkoblet", "connectedSuccessfully": "Tilkoblet",
"totpVerificationFailed": "TOTP-verifisering mislyktes", "totpVerificationFailed": "TOTP-verifisering mislyktes",
"warpgateVerificationFailed": "Warpgate-autentisering mislyktes",
"authenticationFailed": "Autentisering mislyktes", "authenticationFailed": "Autentisering mislyktes",
"incorrectPassphrase": "Feil passordfrase. Prøv på nytt.", "incorrectPassphrase": "Feil passordfrase. Prøv på nytt.",
"verificationCodePrompt": "Verifiseringskode:", "verificationCodePrompt": "Verifiseringskode:",
@@ -290,7 +290,6 @@
"connecting": "Łączenie...", "connecting": "Łączenie...",
"connectedSuccessfully": "Połączono pomyślnie", "connectedSuccessfully": "Połączono pomyślnie",
"totpVerificationFailed": "Weryfikacja TOTP nie powiodła się", "totpVerificationFailed": "Weryfikacja TOTP nie powiodła się",
"warpgateVerificationFailed": "Uwierzytelnianie Warpgate nie powiodło się",
"authenticationFailed": "Uwierzytelnianie nie powiodło się", "authenticationFailed": "Uwierzytelnianie nie powiodło się",
"incorrectPassphrase": "Nieprawidłowe hasło klucza. Spróbuj ponownie.", "incorrectPassphrase": "Nieprawidłowe hasło klucza. Spróbuj ponownie.",
"verificationCodePrompt": "Kod weryfikacyjny:", "verificationCodePrompt": "Kod weryfikacyjny:",
@@ -290,7 +290,6 @@
"connecting": "Conectando...", "connecting": "Conectando...",
"connectedSuccessfully": "Conexão estabelecida com sucesso.", "connectedSuccessfully": "Conexão estabelecida com sucesso.",
"totpVerificationFailed": "A verificação TOTP falhou.", "totpVerificationFailed": "A verificação TOTP falhou.",
"warpgateVerificationFailed": "A autenticação do portal de guerra falhou.",
"authenticationFailed": "A autenticação falhou.", "authenticationFailed": "A autenticação falhou.",
"incorrectPassphrase": "Senha incorreta. Tente novamente.", "incorrectPassphrase": "Senha incorreta. Tente novamente.",
"verificationCodePrompt": "Código de verificação:", "verificationCodePrompt": "Código de verificação:",
@@ -290,7 +290,6 @@
"connecting": "A ligar...", "connecting": "A ligar...",
"connectedSuccessfully": "Ligado com sucesso", "connectedSuccessfully": "Ligado com sucesso",
"totpVerificationFailed": "Falha na verificação TOTP", "totpVerificationFailed": "Falha na verificação TOTP",
"warpgateVerificationFailed": "Falha na autenticação Warpgate",
"authenticationFailed": "Falha na autenticação", "authenticationFailed": "Falha na autenticação",
"incorrectPassphrase": "Frase-passe incorreta. Tente novamente.", "incorrectPassphrase": "Frase-passe incorreta. Tente novamente.",
"verificationCodePrompt": "Código de verificação:", "verificationCodePrompt": "Código de verificação:",
@@ -290,7 +290,6 @@
"connecting": "Se conectează...", "connecting": "Se conectează...",
"connectedSuccessfully": "Conectare reușită", "connectedSuccessfully": "Conectare reușită",
"totpVerificationFailed": "Verificarea TOTP a eșuat", "totpVerificationFailed": "Verificarea TOTP a eșuat",
"warpgateVerificationFailed": "Autentificarea Warpgate a eșuat",
"authenticationFailed": "Autentificarea a eșuat", "authenticationFailed": "Autentificarea a eșuat",
"incorrectPassphrase": "Parolă incorectă. Vă rugăm să încercați din nou.", "incorrectPassphrase": "Parolă incorectă. Vă rugăm să încercați din nou.",
"verificationCodePrompt": "Cod de verificare:", "verificationCodePrompt": "Cod de verificare:",
@@ -290,7 +290,6 @@
"connecting": "Подключение...", "connecting": "Подключение...",
"connectedSuccessfully": "Подключено успешно", "connectedSuccessfully": "Подключено успешно",
"totpVerificationFailed": "Ошибка проверки TOTP", "totpVerificationFailed": "Ошибка проверки TOTP",
"warpgateVerificationFailed": "Ошибка аутентификации Warpgate",
"authenticationFailed": "Ошибка аутентификации", "authenticationFailed": "Ошибка аутентификации",
"incorrectPassphrase": "Неверная парольная фраза. Попробуйте еще раз.", "incorrectPassphrase": "Неверная парольная фраза. Попробуйте еще раз.",
"verificationCodePrompt": "Код подтверждения:", "verificationCodePrompt": "Код подтверждения:",
@@ -290,7 +290,6 @@
"connecting": "Повезивање...", "connecting": "Повезивање...",
"connectedSuccessfully": "Успешно повезано", "connectedSuccessfully": "Успешно повезано",
"totpVerificationFailed": "Верификација TOTP-а није успела", "totpVerificationFailed": "Верификација TOTP-а није успела",
"warpgateVerificationFailed": "Аутентификација Варпгејта није успела",
"authenticationFailed": "Аутентификација није успела", "authenticationFailed": "Аутентификација није успела",
"incorrectPassphrase": "Нетачна лозинка. Молимо покушајте поново.", "incorrectPassphrase": "Нетачна лозинка. Молимо покушајте поново.",
"verificationCodePrompt": "Верификациони код:", "verificationCodePrompt": "Верификациони код:",
@@ -290,7 +290,6 @@
"connecting": "Ansluter...", "connecting": "Ansluter...",
"connectedSuccessfully": "Ansluten", "connectedSuccessfully": "Ansluten",
"totpVerificationFailed": "TOTP-verifiering misslyckades", "totpVerificationFailed": "TOTP-verifiering misslyckades",
"warpgateVerificationFailed": "Warpgate-autentisering misslyckades",
"authenticationFailed": "Autentiseringen misslyckades", "authenticationFailed": "Autentiseringen misslyckades",
"incorrectPassphrase": "Felaktigt lösenord. Försök igen.", "incorrectPassphrase": "Felaktigt lösenord. Försök igen.",
"verificationCodePrompt": "Verifieringskod:", "verificationCodePrompt": "Verifieringskod:",
@@ -290,7 +290,6 @@
"connecting": "กำลังเชื่อมต่อ...", "connecting": "กำลังเชื่อมต่อ...",
"connectedSuccessfully": "เชื่อมต่อสำเร็จแล้ว", "connectedSuccessfully": "เชื่อมต่อสำเร็จแล้ว",
"totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว", "totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว",
"warpgateVerificationFailed": "การตรวจสอบสิทธิ์วาร์ปไม่สำเร็จ",
"authenticationFailed": "การตรวจสอบสิทธิ์ล้มเหลว", "authenticationFailed": "การตรวจสอบสิทธิ์ล้มเหลว",
"incorrectPassphrase": "รหัสผ่านไม่ถูกต้อง โปรดลองอีกครั้ง", "incorrectPassphrase": "รหัสผ่านไม่ถูกต้อง โปรดลองอีกครั้ง",
"verificationCodePrompt": "รหัสยืนยัน:", "verificationCodePrompt": "รหัสยืนยัน:",
@@ -290,7 +290,6 @@
"connecting": "Bağlanıyor...", "connecting": "Bağlanıyor...",
"connectedSuccessfully": "Bağlantı başarıyla kuruldu", "connectedSuccessfully": "Bağlantı başarıyla kuruldu",
"totpVerificationFailed": "TOTP doğrulaması başarısız oldu", "totpVerificationFailed": "TOTP doğrulaması başarısız oldu",
"warpgateVerificationFailed": "Warpgate kimlik doğrulaması başarısız oldu",
"authenticationFailed": "Kimlik doğrulama başarısız oldu", "authenticationFailed": "Kimlik doğrulama başarısız oldu",
"incorrectPassphrase": "Yanlış parola. Lütfen tekrar deneyin.", "incorrectPassphrase": "Yanlış parola. Lütfen tekrar deneyin.",
"verificationCodePrompt": "Doğrulama kodu:", "verificationCodePrompt": "Doğrulama kodu:",
@@ -290,7 +290,6 @@
"connecting": "Підключення...", "connecting": "Підключення...",
"connectedSuccessfully": "Підключено успішно", "connectedSuccessfully": "Підключено успішно",
"totpVerificationFailed": "Не вдалося перевірити TOTP", "totpVerificationFailed": "Не вдалося перевірити TOTP",
"warpgateVerificationFailed": "Автентифікація Warpgate не вдалася",
"authenticationFailed": "Помилка автентифікації", "authenticationFailed": "Помилка автентифікації",
"incorrectPassphrase": "Неправильна парольна фраза. Спробуйте ще раз.", "incorrectPassphrase": "Неправильна парольна фраза. Спробуйте ще раз.",
"verificationCodePrompt": "Код підтвердження:", "verificationCodePrompt": "Код підтвердження:",
@@ -290,7 +290,6 @@
"connecting": "Đang kết nối...", "connecting": "Đang kết nối...",
"connectedSuccessfully": "Đã kết nối thành công", "connectedSuccessfully": "Đã kết nối thành công",
"totpVerificationFailed": "Xác minh TOTP thất bại", "totpVerificationFailed": "Xác minh TOTP thất bại",
"warpgateVerificationFailed": "Xác thực Warpgate thất bại",
"authenticationFailed": "Xác thực thất bại", "authenticationFailed": "Xác thực thất bại",
"incorrectPassphrase": "Cụm mật khẩu không đúng. Vui lòng thử lại.", "incorrectPassphrase": "Cụm mật khẩu không đúng. Vui lòng thử lại.",
"verificationCodePrompt": "Mã xác minh:", "verificationCodePrompt": "Mã xác minh:",
@@ -290,7 +290,6 @@
"connecting": "连接中...", "connecting": "连接中...",
"connectedSuccessfully": "连接成功", "connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败", "totpVerificationFailed": "TOTP 验证失败",
"warpgateVerificationFailed": "Warpgate 认证失败",
"authenticationFailed": "认证失败", "authenticationFailed": "认证失败",
"incorrectPassphrase": "密码错误。请重试。", "incorrectPassphrase": "密码错误。请重试。",
"verificationCodePrompt": "验证码:", "verificationCodePrompt": "验证码:",
@@ -290,7 +290,6 @@
"connecting": "連線中...", "connecting": "連線中...",
"connectedSuccessfully": "已成功連線", "connectedSuccessfully": "已成功連線",
"totpVerificationFailed": "TOTP 驗證失敗", "totpVerificationFailed": "TOTP 驗證失敗",
"warpgateVerificationFailed": "Warpgate 驗證失敗",
"authenticationFailed": "驗證失敗", "authenticationFailed": "驗證失敗",
"incorrectPassphrase": "密碼片語錯誤。請再試一次。", "incorrectPassphrase": "密碼片語錯誤。請再試一次。",
"verificationCodePrompt": "驗證碼:", "verificationCodePrompt": "驗證碼:",
+28 -31
View File
@@ -200,7 +200,7 @@ export async function activate(ctx: PluginContext) {
); );
} }
// Pending TOTP/Warpgate parking sessions expire on their own; sweep stale // Pending TOTP and browser sign-in parking sessions expire on their own; sweep stale
// ones so a session left mid-handshake doesn't leak an open ssh2 Client. // ones so a session left mid-handshake doesn't leak an open ssh2 Client.
const pendingSweep = setInterval(() => { const pendingSweep = setInterval(() => {
const now = Date.now(); const now = Date.now();
@@ -386,7 +386,7 @@ export async function activate(ctx: PluginContext) {
if ( if (
req.path === "/connect" || req.path === "/connect" ||
req.path === "/connect-totp" || req.path === "/connect-totp" ||
req.path === "/connect-warpgate" req.path === "/connect-browser-sign-in"
) { ) {
return next(); return next();
} }
@@ -463,7 +463,7 @@ export async function activate(ctx: PluginContext) {
* type: array * type: array
* responses: * responses:
* 200: * 200:
* description: SSH connection established, or requires TOTP/Warpgate authentication. * description: SSH connection established, or requires TOTP or a browser sign-in.
* 400: * 400:
* description: Missing required parameters or invalid SSH key format. * description: Missing required parameters or invalid SSH key format.
* 401: * 401:
@@ -983,7 +983,7 @@ export async function activate(ctx: PluginContext) {
finish: (responses: string[]) => void, finish: (responses: string[]) => void,
prompts: Array<{ prompt: string; echo: boolean }>, prompts: Array<{ prompt: string; echo: boolean }>,
promptIndex: number, promptIndex: number,
isWarpgate = false, isBrowserSignIn = false,
) => { ) => {
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
client, client,
@@ -1000,7 +1000,7 @@ export async function activate(ctx: PluginContext) {
totpPromptIndex: promptIndex, totpPromptIndex: promptIndex,
resolvedPassword: resolvedCredentials.password, resolvedPassword: resolvedCredentials.password,
totpAttempts: 0, totpAttempts: 0,
...(isWarpgate ? { isWarpgate: true } : {}), ...(isBrowserSignIn ? { isBrowserSignIn: true } : {}),
}; };
}; };
@@ -1025,23 +1025,24 @@ export async function activate(ctx: PluginContext) {
return; return;
} }
if (decision.kind === "warpgate") { if (decision.kind === "browser") {
if (responseSent) return; if (responseSent) return;
responseSent = true; responseSent = true;
connectionLogs.push( connectionLogs.push(
createConnectionLog( createConnectionLog(
"info", "info",
"sftp_auth", "sftp_auth",
"Warpgate authentication required", `${decision.label} sign-in required`,
{ url: decision.url }, { url: decision.url },
), ),
); );
parkForAnswer(finish, prompts, -1, true); parkForAnswer(finish, prompts, -1, true);
res.json({ res.json({
requires_warpgate: true, requires_browser_sign_in: true,
sessionId, sessionId,
label: decision.label,
url: decision.url, url: decision.url,
securityKey: decision.securityKey, code: decision.code,
connectionLogs, connectionLogs,
}); });
return; return;
@@ -1347,10 +1348,10 @@ export async function activate(ctx: PluginContext) {
/** /**
* @openapi * @openapi
* /plugin-api/file-manager/connect-warpgate: * /plugin-api/file-manager/connect-browser-sign-in:
* post: * post:
* summary: Complete Warpgate authentication * summary: Continue after a browser sign-in
* description: Submits empty response to complete Warpgate authentication after user completes browser auth. * description: Answers the parked keyboard-interactive round once the user finished signing in in the browser (an SSH gateway's approval, for example).
* tags: * tags:
* - File Manager * - File Manager
* requestBody: * requestBody:
@@ -1366,15 +1367,15 @@ export async function activate(ctx: PluginContext) {
* type: string * type: string
* responses: * responses:
* 200: * 200:
* description: Warpgate authentication completed successfully. * description: Signed in, SSH connection established.
* 401: * 401:
* description: Authentication failed or unauthorized. * description: Authentication failed or unauthorized.
* 404: * 404:
* description: Warpgate session expired. * description: Sign-in session expired.
* 408: * 408:
* description: Warpgate session timeout. * description: Sign-in session timeout.
*/ */
app.post("/connect-warpgate", async (req, res) => { app.post("/connect-browser-sign-in", async (req, res) => {
const { sessionId } = req.body; const { sessionId } = req.body;
const userId = ctx.currentActor(); const userId = ctx.currentActor();
@@ -1389,12 +1390,12 @@ export async function activate(ctx: PluginContext) {
if (!session) { if (!session) {
return res return res
.status(404) .status(404)
.json({ error: "Warpgate session expired. Please reconnect." }); .json({ error: "Sign-in session expired. Please reconnect." });
} }
if (!session.isWarpgate) { if (!session.isBrowserSignIn) {
return res return res
.status(400) .status(400)
.json({ error: "Session is not a Warpgate session" }); .json({ error: "Session is not waiting for a browser sign-in" });
} }
if (Date.now() - session.createdAt > 300000) { if (Date.now() - session.createdAt > 300000) {
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
@@ -1405,7 +1406,7 @@ export async function activate(ctx: PluginContext) {
} }
return res return res
.status(408) .status(408)
.json({ error: "Warpgate session timeout. Please reconnect." }); .json({ error: "Sign-in session timeout. Please reconnect." });
} }
let responseSent = false; let responseSent = false;
@@ -1413,7 +1414,7 @@ export async function activate(ctx: PluginContext) {
if (!responseSent) { if (!responseSent) {
responseSent = true; responseSent = true;
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
res.status(408).json({ error: "Warpgate verification timeout" }); res.status(408).json({ error: "Sign-in verification timeout" });
} }
}, 60000); }, 60000);
@@ -1440,7 +1441,7 @@ export async function activate(ctx: PluginContext) {
res.json({ res.json({
status: "success", status: "success",
message: "Warpgate verified, SSH connection established", message: "Signed in, SSH connection established",
}); });
if (session.hostId && session.userId) { if (session.hostId && session.userId) {
@@ -1460,9 +1461,7 @@ export async function activate(ctx: PluginContext) {
responseSent = true; responseSent = true;
clearTimeout(responseTimeout); clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
res res.status(401).json({ status: "error", message: "Sign-in failed" });
.status(401)
.json({ status: "error", message: "Warpgate authentication failed" });
}); });
session.finish([""]); session.finish([""]);
@@ -1567,12 +1566,10 @@ export async function activate(ctx: PluginContext) {
} }
const session = sshSessions[sessionId]; const session = sshSessions[sessionId];
if (!session || !session.isConnected) { if (!session || !session.isConnected) {
return res return res.status(400).json({
.status(400) error: "SSH session not found or not connected",
.json({ connected: false,
error: "SSH session not found or not connected", });
connected: false,
});
} }
if (!userId || !verifySessionOwnership(session, userId)) { if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" }); return res.status(403).json({ error: "Session access denied" });
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more