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