mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
+5
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+26
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": "主控台錯誤",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "驗證碼:",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user