mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: Step CA SSH certificates as a host authentication type (#1340)
* feat: Step CA SSH certificates as a host authentication type Issue short-lived SSH user certificates from a smallstep CA through its OIDC provisioner, over the CA's HTTP API rather than the step binary. Everything after issuance reuses the OPKSSH plumbing: the same encrypted per-user/host token store, WebSocket dialog and ssh2 certificate injection, with the connect paths branching on a shared usesIssuedCertificate() predicate. Instance-wide CA settings live in the admin panel, with a private-host allowlist for the SSRF guard. * fix: harden Step CA callback flow * style: format Step CA changes
This commit is contained in:
@@ -356,9 +356,12 @@ networks:
|
|||||||
|
|
||||||
For multiple Termix backend instances, set the same `REDIS_URL` and optional
|
For multiple Termix backend instances, set the same `REDIS_URL` and optional
|
||||||
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
|
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
|
||||||
presence, control requests, controller state, and events; keep WebSocket session
|
presence, control requests, controller state, and events. It also routes Step CA
|
||||||
affinity enabled because live SSH and remote desktop transports remain attached
|
OAuth callbacks back to the instance holding the user's terminal; the optional
|
||||||
to the backend instance that opened them. A single instance needs no Redis.
|
`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
|
||||||
|
Keep WebSocket session affinity enabled because live SSH and remote desktop
|
||||||
|
transports remain attached to the backend instance that opened them. A single
|
||||||
|
instance needs no Redis.
|
||||||
|
|
||||||
### Command Line Interface
|
### Command Line Interface
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
# REDIS_URL: "redis://redis:6379"
|
# REDIS_URL: "redis://redis:6379"
|
||||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
|
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
# in-memory fallback.
|
# in-memory fallback.
|
||||||
# REDIS_URL: "redis://redis:6379"
|
# REDIS_URL: "redis://redis:6379"
|
||||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
|
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||||
# Hardened deployments can require keys from environment variables or
|
# Hardened deployments can require keys from environment variables or
|
||||||
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
||||||
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
||||||
|
|||||||
@@ -351,9 +351,11 @@ networks:
|
|||||||
```
|
```
|
||||||
|
|
||||||
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
|
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
|
||||||
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件;
|
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
|
||||||
实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持
|
也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
|
||||||
WebSocket 会话亲和性。单实例部署无需 Redis。
|
`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
|
||||||
|
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
|
||||||
|
单实例部署无需 Redis。
|
||||||
|
|
||||||
### 命令行工具
|
### 命令行工具
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
|
|||||||
"identity",
|
"identity",
|
||||||
"certificate",
|
"certificate",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
|
"step_ca",
|
||||||
"acme",
|
"acme",
|
||||||
"ssl",
|
"ssl",
|
||||||
"audit",
|
"audit",
|
||||||
|
|||||||
@@ -600,13 +600,14 @@ export function registerHostBulkRoutes(
|
|||||||
"credential",
|
"credential",
|
||||||
"none",
|
"none",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
"vault",
|
"vault",
|
||||||
].includes(hostData.authType)
|
].includes(hostData.authType)
|
||||||
) {
|
) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`,
|
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import { escapeHtml } from "./opkssh-html.js";
|
||||||
|
|
||||||
|
function resultPage(ok: boolean, message: string): string {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||||
|
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
|
||||||
|
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
|
||||||
|
* the browser that finishes the sign-in may not be the one running Termix,
|
||||||
|
* so the request is matched to its session by the OAuth state.
|
||||||
|
*/
|
||||||
|
export function registerHostStepCaRoutes(router: Router): void {
|
||||||
|
router.get("/step-ca-callback", async (req: Request, res: Response) => {
|
||||||
|
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
|
||||||
|
const stringQuery = (name: string): string | undefined => {
|
||||||
|
const value = req.query[name];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
};
|
||||||
|
const result = await completeStepCaAuth({
|
||||||
|
state: stringQuery("state"),
|
||||||
|
code: stringQuery("code"),
|
||||||
|
error: stringQuery("error"),
|
||||||
|
error_description: stringQuery("error_description"),
|
||||||
|
});
|
||||||
|
res
|
||||||
|
.status(result.ok ? 200 : 400)
|
||||||
|
.type("html")
|
||||||
|
.send(resultPage(result.ok, result.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from "./host-normalizers.js";
|
} from "./host-normalizers.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||||
|
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||||
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
|
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
|
||||||
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
|
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
|
||||||
@@ -2899,6 +2900,7 @@ router.delete(
|
|||||||
);
|
);
|
||||||
|
|
||||||
registerHostOpksshRoutes(router);
|
registerHostOpksshRoutes(router);
|
||||||
|
registerHostStepCaRoutes(router);
|
||||||
|
|
||||||
registerHostNetworkRoutes(router, {
|
registerHostNetworkRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
export function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
|
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from "../../utils/audit-forwarder.js";
|
} from "../../utils/audit-forwarder.js";
|
||||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||||
|
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
parseNotificationAllowlist,
|
parseNotificationAllowlist,
|
||||||
@@ -1083,30 +1085,31 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get(
|
/**
|
||||||
"/notification-private-endpoints",
|
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
||||||
authenticateJWT,
|
* Shared by notifications and Step CA; each keeps its own setting key.
|
||||||
async (req, res) => {
|
*/
|
||||||
|
const registerPrivateEndpointAllowlist = (
|
||||||
|
path: string,
|
||||||
|
settingKey: string,
|
||||||
|
auditAction: string,
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
router.get(path, authenticateJWT, async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
if (!(await getAdminActor(userId))) {
|
if (!(await getAdminActor(userId))) {
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
}
|
}
|
||||||
const raw = await createCurrentSettingsRepository().get(
|
const raw = await createCurrentSettingsRepository().get(settingKey);
|
||||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
|
||||||
);
|
|
||||||
res.json({ hosts: parseNotificationAllowlist(raw) });
|
res.json({ hosts: parseNotificationAllowlist(raw) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get notification endpoint allowlist", err);
|
authLogger.error(`Failed to get ${label} allowlist`, err);
|
||||||
res.status(500).json({ error: "Failed to get the allowlist" });
|
res.status(500).json({ error: "Failed to get the allowlist" });
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
router.patch(
|
router.patch(path, authenticateJWT, async (req, res) => {
|
||||||
"/notification-private-endpoints",
|
|
||||||
authenticateJWT,
|
|
||||||
async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
const actor = await getAdminActor(userId);
|
const actor = await getAdminActor(userId);
|
||||||
@@ -1123,7 +1126,6 @@ export function registerUserSettingsRoutes(
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "At most 50 hosts are allowed" });
|
.json({ error: "At most 50 hosts are allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleaned: string[] = [];
|
const cleaned: string[] = [];
|
||||||
for (const entry of hosts) {
|
for (const entry of hosts) {
|
||||||
if (typeof entry !== "string") {
|
if (typeof entry !== "string") {
|
||||||
@@ -1142,14 +1144,14 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentSettingsRepository().set(
|
await createCurrentSettingsRepository().set(
|
||||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
settingKey,
|
||||||
JSON.stringify(cleaned),
|
JSON.stringify(cleaned),
|
||||||
);
|
);
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId,
|
userId,
|
||||||
username: actor.username ?? userId,
|
username: actor.username ?? userId,
|
||||||
action: "update_notification_private_endpoints",
|
action: auditAction,
|
||||||
resourceType: "setting",
|
resourceType: "setting",
|
||||||
details: JSON.stringify({ hosts: cleaned }),
|
details: JSON.stringify({ hosts: cleaned }),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -1158,15 +1160,156 @@ export function registerUserSettingsRoutes(
|
|||||||
});
|
});
|
||||||
res.json({ hosts: cleaned });
|
res.json({ hosts: cleaned });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error(
|
authLogger.error(`Failed to update ${label} allowlist`, err);
|
||||||
"Failed to update notification endpoint allowlist",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
res.status(500).json({ error: "Failed to update the allowlist" });
|
res.status(500).json({ error: "Failed to update the allowlist" });
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/notification-private-endpoints:
|
||||||
|
* get:
|
||||||
|
* summary: Get the private hosts notification channels may contact (admin only)
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* patch:
|
||||||
|
* summary: Replace that allowlist (admin only)
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
*/
|
||||||
|
registerPrivateEndpointAllowlist(
|
||||||
|
"/notification-private-endpoints",
|
||||||
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
"update_notification_private_endpoints",
|
||||||
|
"notification endpoint",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/step-ca-private-endpoints:
|
||||||
|
* get:
|
||||||
|
* summary: Get the private hosts the Step CA certificate flow may contact (admin only)
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* patch:
|
||||||
|
* summary: Replace that allowlist (admin only)
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
*/
|
||||||
|
registerPrivateEndpointAllowlist(
|
||||||
|
"/step-ca-private-endpoints",
|
||||||
|
STEP_CA_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
"update_step_ca_private_endpoints",
|
||||||
|
"Step CA endpoint",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/step-ca-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Step CA settings. Admins get the values; everyone else only whether it is configured.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* patch:
|
||||||
|
* summary: Set the Step CA URL, root fingerprint and OIDC provisioner (admin only). Empty values clear the configuration.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
*/
|
||||||
|
router.get("/step-ca-settings", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const { readStepCaSettings } =
|
||||||
|
await import("../../hosts/step-ca-auth.js");
|
||||||
|
const settings = await readStepCaSettings();
|
||||||
|
if (!(await getAdminActor(userId))) {
|
||||||
|
return res.json({ configured: settings !== null });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
configured: settings !== null,
|
||||||
|
caUrl: settings?.caUrl ?? "",
|
||||||
|
fingerprint: settings?.fingerprint ?? "",
|
||||||
|
provisioner: settings?.provisioner ?? "",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get Step CA settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to get Step CA settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/step-ca-settings", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { caUrl, fingerprint, provisioner } = req.body ?? {};
|
||||||
|
if (
|
||||||
|
[caUrl, fingerprint, provisioner].some((v) => typeof v !== "string")
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "caUrl, fingerprint and provisioner must be strings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const values = {
|
||||||
|
caUrl: caUrl.trim(),
|
||||||
|
fingerprint: fingerprint.trim(),
|
||||||
|
provisioner: provisioner.trim(),
|
||||||
|
};
|
||||||
|
const clearing =
|
||||||
|
!values.caUrl && !values.fingerprint && !values.provisioner;
|
||||||
|
if (!clearing) {
|
||||||
|
const { normalizeCaUrl, normalizeFingerprint } =
|
||||||
|
await import("../../utils/step-ca-client.js");
|
||||||
|
try {
|
||||||
|
values.caUrl = normalizeCaUrl(values.caUrl);
|
||||||
|
values.fingerprint = normalizeFingerprint(values.fingerprint);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: getErrorMessage(err) });
|
||||||
|
}
|
||||||
|
if (!values.provisioner) {
|
||||||
|
return res.status(400).json({ error: "provisioner is required" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { STEP_CA_SETTING_KEYS } =
|
||||||
|
await import("../../hosts/step-ca-auth.js");
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
if (clearing) {
|
||||||
|
await settings.delete(STEP_CA_SETTING_KEYS.url);
|
||||||
|
await settings.delete(STEP_CA_SETTING_KEYS.fingerprint);
|
||||||
|
await settings.delete(STEP_CA_SETTING_KEYS.provisioner);
|
||||||
|
} else {
|
||||||
|
await settings.set(STEP_CA_SETTING_KEYS.url, values.caUrl);
|
||||||
|
await settings.set(
|
||||||
|
STEP_CA_SETTING_KEYS.fingerprint,
|
||||||
|
values.fingerprint,
|
||||||
|
);
|
||||||
|
await settings.set(
|
||||||
|
STEP_CA_SETTING_KEYS.provisioner,
|
||||||
|
values.provisioner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_step_ca_settings",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ configured: !clearing, caUrl: values.caUrl }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
res.json({ configured: !clearing, ...values });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update Step CA settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to update Step CA settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/host-defaults:
|
* /users/host-defaults:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
@@ -300,7 +301,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
if (resolvedCredentials.password) {
|
if (resolvedCredentials.password) {
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
}
|
}
|
||||||
} else if (resolvedCredentials.authType === "opkssh") {
|
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||||
try {
|
try {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(userId, hostId);
|
const token = await getOPKSSHToken(userId, hostId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {
|
import {
|
||||||
logAudit,
|
logAudit,
|
||||||
@@ -289,7 +290,7 @@ async function buildDedicatedTransferConnectConfig(
|
|||||||
throw new Error("Password required for transfer connection");
|
throw new Error("Password required for transfer connection");
|
||||||
}
|
}
|
||||||
config.password = host.password;
|
config.password = host.password;
|
||||||
} else if (authType === "opkssh") {
|
} else if (usesIssuedCertificate(authType)) {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(userId, host.id);
|
const token = await getOPKSSHToken(userId, host.id);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -1120,7 +1121,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
connectionLogs.push(
|
connectionLogs.push(
|
||||||
createConnectionLog("info", "sftp_auth", "Using password authentication"),
|
createConnectionLog("info", "sftp_auth", "Using password authentication"),
|
||||||
);
|
);
|
||||||
} else if (resolvedCredentials.authType === "opkssh") {
|
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||||
try {
|
try {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(userId, hostId);
|
const token = await getOPKSSHToken(userId, hostId);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Auth types whose SSH certificate is issued on demand through a browser
|
||||||
|
* sign-in and cached per user and host (opkssh_tokens). They share the
|
||||||
|
* whole connect path; only the issuing flow differs.
|
||||||
|
*/
|
||||||
|
export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const;
|
||||||
|
|
||||||
|
export function usesIssuedCertificate(authType: string | null | undefined) {
|
||||||
|
return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes(
|
||||||
|
authType ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Client } from "ssh2";
|
import type { Client } from "ssh2";
|
||||||
|
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||||
|
|
||||||
export type StatsCapableHost = {
|
export type StatsCapableHost = {
|
||||||
connectionType?: string;
|
connectionType?: string;
|
||||||
@@ -13,7 +14,8 @@ export type TcpPingStatsConfig = {
|
|||||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||||
const connectionType = host.connectionType || "ssh";
|
const connectionType = host.connectionType || "ssh";
|
||||||
if (connectionType !== "ssh") return false;
|
if (connectionType !== "ssh") return false;
|
||||||
if (host.authType === "none" || host.authType === "opkssh") return false;
|
if (host.authType === "none" || usesIssuedCertificate(host.authType))
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||||
@@ -1396,7 +1397,7 @@ async function buildSshConfig(
|
|||||||
host.authType === "warpgate"
|
host.authType === "warpgate"
|
||||||
) {
|
) {
|
||||||
// no credentials needed
|
// no credentials needed
|
||||||
} else if (host.authType === "opkssh") {
|
} else if (usesIssuedCertificate(host.authType)) {
|
||||||
// cert auth setup happens in createSshFactory (needs client instance)
|
// cert auth setup happens in createSshFactory (needs client instance)
|
||||||
} else if (host.authType === "vault") {
|
} else if (host.authType === "vault") {
|
||||||
// cert auth setup happens in createSshFactory (needs client instance)
|
// cert auth setup happens in createSshFactory (needs client instance)
|
||||||
@@ -1440,7 +1441,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
|||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
// Set up OPKSSH cert auth if needed (requires client instance)
|
// Set up OPKSSH cert auth if needed (requires client instance)
|
||||||
if (host.authType === "opkssh" && host.userId) {
|
if (usesIssuedCertificate(host.authType) && host.userId) {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(host.userId, host.id);
|
const token = await getOPKSSHToken(host.userId, host.id);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -2359,7 +2360,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
const config = await buildSshConfig(host);
|
const config = await buildSshConfig(host);
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
if (host.authType === "opkssh" && host.userId) {
|
if (usesIssuedCertificate(host.authType) && host.userId) {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(host.userId, host.id);
|
const token = await getOPKSSHToken(host.userId, host.id);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
|
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||||
|
import {
|
||||||
|
createCurrentOpksshTokenRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js";
|
||||||
|
import {
|
||||||
|
stepCaRuntime,
|
||||||
|
type StepCaCallbackQuery,
|
||||||
|
type StepCaCallbackResult,
|
||||||
|
} from "./step-ca-runtime.js";
|
||||||
|
import {
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
createPkce,
|
||||||
|
decodeJwtClaims,
|
||||||
|
discoverOidcEndpoints,
|
||||||
|
exchangeCodeForIdToken,
|
||||||
|
fetchRootCertificate,
|
||||||
|
findOidcProvisioner,
|
||||||
|
generateSshKeyPair,
|
||||||
|
parseSshCertificate,
|
||||||
|
signSshCertificate,
|
||||||
|
type StepCaTarget,
|
||||||
|
} from "../utils/step-ca-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step CA (smallstep) SSH user certificates through its OIDC provisioner.
|
||||||
|
*
|
||||||
|
* Mirrors the OPKSSH flow and reuses its storage, WS messages and connect
|
||||||
|
* path; the only difference is how the certificate is obtained: no binary,
|
||||||
|
* just the CA's HTTP API plus one OIDC redirect back to Termix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback";
|
||||||
|
export const STEP_CA_SETTING_KEYS = {
|
||||||
|
url: "step_ca_url",
|
||||||
|
fingerprint: "step_ca_fingerprint",
|
||||||
|
provisioner: "step_ca_provisioner",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const REMOTE_CALLBACK_WAIT_MS = 30_000;
|
||||||
|
const REMOTE_POLL_MS = 250;
|
||||||
|
|
||||||
|
export interface StepCaSettings {
|
||||||
|
caUrl: string;
|
||||||
|
fingerprint: string;
|
||||||
|
provisioner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStepCaSettings(): Promise<StepCaSettings | null> {
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
const [caUrl, fingerprint, provisioner] = await Promise.all([
|
||||||
|
settings.get(STEP_CA_SETTING_KEYS.url),
|
||||||
|
settings.get(STEP_CA_SETTING_KEYS.fingerprint),
|
||||||
|
settings.get(STEP_CA_SETTING_KEYS.provisioner),
|
||||||
|
]);
|
||||||
|
if (!caUrl || !fingerprint || !provisioner) return null;
|
||||||
|
return { caUrl, fingerprint, provisioner };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepCaAuthSession {
|
||||||
|
state: string;
|
||||||
|
userId: string;
|
||||||
|
hostId: number;
|
||||||
|
username: string;
|
||||||
|
ws: WebSocket;
|
||||||
|
target: StepCaTarget;
|
||||||
|
rootPem: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
redirectUri: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
nonce: string;
|
||||||
|
keyPair: { publicKeyLine: string; privateKeyPem: string };
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
commandPoll: NodeJS.Timeout | null;
|
||||||
|
processing: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, StepCaAuthSession>();
|
||||||
|
|
||||||
|
function send(ws: WebSocket, message: object): void {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
} catch {
|
||||||
|
/* socket already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSession(session: StepCaAuthSession, removeRuntime = true): void {
|
||||||
|
clearTimeout(session.timeout);
|
||||||
|
if (session.commandPoll) clearInterval(session.commandPoll);
|
||||||
|
sessions.delete(session.state);
|
||||||
|
if (removeRuntime) void stepCaRuntime.remove(session.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStepCaAuth(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
username: string,
|
||||||
|
ws: WebSocket,
|
||||||
|
requestOrigin: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const settings = await readStepCaSettings();
|
||||||
|
if (!settings) {
|
||||||
|
send(ws, {
|
||||||
|
type: "opkssh_config_error",
|
||||||
|
requestId: "",
|
||||||
|
error:
|
||||||
|
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = randomBytes(24).toString("base64url");
|
||||||
|
try {
|
||||||
|
const target: StepCaTarget = {
|
||||||
|
caUrl: settings.caUrl,
|
||||||
|
fingerprint: settings.fingerprint,
|
||||||
|
allowedPrivateHosts: await readStepCaPrivateAllowlist(),
|
||||||
|
};
|
||||||
|
const rootPem = await fetchRootCertificate(target);
|
||||||
|
const provisioner = await findOidcProvisioner(
|
||||||
|
target,
|
||||||
|
rootPem,
|
||||||
|
settings.provisioner,
|
||||||
|
);
|
||||||
|
const endpoints = await discoverOidcEndpoints(
|
||||||
|
provisioner.configurationEndpoint,
|
||||||
|
target.allowedPrivateHosts,
|
||||||
|
);
|
||||||
|
const pkce = createPkce();
|
||||||
|
const nonce = randomBytes(16).toString("base64url");
|
||||||
|
const redirectUri = `${requestOrigin}${STEP_CA_CALLBACK_PATH}`;
|
||||||
|
|
||||||
|
const session: StepCaAuthSession = {
|
||||||
|
state,
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
username,
|
||||||
|
ws,
|
||||||
|
target,
|
||||||
|
rootPem,
|
||||||
|
clientId: provisioner.clientID,
|
||||||
|
clientSecret: provisioner.clientSecret,
|
||||||
|
tokenEndpoint: endpoints.tokenEndpoint,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier: pkce.verifier,
|
||||||
|
nonce,
|
||||||
|
keyPair: generateSshKeyPair(),
|
||||||
|
processing: false,
|
||||||
|
completed: false,
|
||||||
|
commandPoll: null,
|
||||||
|
timeout: setTimeout(() => {
|
||||||
|
const current = sessions.get(state);
|
||||||
|
if (!current || current.completed || current.processing) return;
|
||||||
|
send(ws, { type: "opkssh_timeout", requestId: state });
|
||||||
|
endSession(current);
|
||||||
|
}, AUTH_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
sessions.set(state, session);
|
||||||
|
await stepCaRuntime.register(state);
|
||||||
|
session.commandPoll = setInterval(() => {
|
||||||
|
if (session.processing || session.completed) return;
|
||||||
|
void stepCaRuntime.takeCommand(state).then(async (query) => {
|
||||||
|
if (!query || session.processing || session.completed) return;
|
||||||
|
session.processing = true;
|
||||||
|
const result = await finishStepCaAuth(session, query, false);
|
||||||
|
await stepCaRuntime.complete(state, result);
|
||||||
|
});
|
||||||
|
}, REMOTE_POLL_MS);
|
||||||
|
session.commandPoll.unref();
|
||||||
|
ws.once("close", () => {
|
||||||
|
const current = sessions.get(state);
|
||||||
|
if (current && !current.completed) endSession(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
send(ws, {
|
||||||
|
type: "opkssh_status",
|
||||||
|
requestId: state,
|
||||||
|
stage: "chooser",
|
||||||
|
label: "Step CA",
|
||||||
|
url: buildAuthorizationUrl({
|
||||||
|
authorizationEndpoint: endpoints.authorizationEndpoint,
|
||||||
|
clientId: provisioner.clientID,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
codeChallenge: pkce.challenge,
|
||||||
|
}),
|
||||||
|
providers: [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to start Step CA authentication", error, {
|
||||||
|
operation: "step_ca_start_error",
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
send(ws, {
|
||||||
|
type: "opkssh_error",
|
||||||
|
requestId: state,
|
||||||
|
error: `Step CA: ${getErrorMessage(error)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelStepCaAuth(requestId: string): boolean {
|
||||||
|
const session = sessions.get(requestId);
|
||||||
|
if (!session) return false;
|
||||||
|
endSession(session);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes the flow once the identity provider redirects back: exchanges
|
||||||
|
* the code, has the CA sign the key, stores the certificate the way OPKSSH
|
||||||
|
* does (same table, same encryption, same token id) and tells the terminal
|
||||||
|
* to reconnect.
|
||||||
|
*/
|
||||||
|
export async function completeStepCaAuth(
|
||||||
|
query: StepCaCallbackQuery,
|
||||||
|
): Promise<StepCaCallbackResult> {
|
||||||
|
const session = query.state ? sessions.get(query.state) : undefined;
|
||||||
|
if (!session) {
|
||||||
|
if (!query.state || !(await stepCaRuntime.submit(query.state, query))) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "This sign-in request is no longer active.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await stepCaRuntime.takeResult(query.state);
|
||||||
|
if (result) return result;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "The Termix instance handling this sign-in did not respond.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (session.processing || session.completed) {
|
||||||
|
return { ok: false, message: "This sign-in request was already used." };
|
||||||
|
}
|
||||||
|
session.processing = true;
|
||||||
|
return finishStepCaAuth(session, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishStepCaAuth(
|
||||||
|
session: StepCaAuthSession,
|
||||||
|
query: StepCaCallbackQuery,
|
||||||
|
removeRuntime = true,
|
||||||
|
): Promise<StepCaCallbackResult> {
|
||||||
|
if (query.error || !query.code) {
|
||||||
|
const message = query.error_description || query.error || "Sign-in failed";
|
||||||
|
send(session.ws, {
|
||||||
|
type: "opkssh_error",
|
||||||
|
requestId: session.state,
|
||||||
|
error: `Step CA: ${message}`,
|
||||||
|
});
|
||||||
|
endSession(session, removeRuntime);
|
||||||
|
return { ok: false, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
send(session.ws, {
|
||||||
|
type: "opkssh_status",
|
||||||
|
requestId: session.state,
|
||||||
|
stage: "authenticating",
|
||||||
|
});
|
||||||
|
const idToken = await exchangeCodeForIdToken({
|
||||||
|
tokenEndpoint: session.tokenEndpoint,
|
||||||
|
clientId: session.clientId,
|
||||||
|
clientSecret: session.clientSecret,
|
||||||
|
code: query.code,
|
||||||
|
redirectUri: session.redirectUri,
|
||||||
|
codeVerifier: session.codeVerifier,
|
||||||
|
allowedPrivateHosts: session.target.allowedPrivateHosts,
|
||||||
|
});
|
||||||
|
const claims = decodeJwtClaims(idToken);
|
||||||
|
if (claims.nonce !== session.nonce) {
|
||||||
|
throw new Error("The identity token does not match this sign-in");
|
||||||
|
}
|
||||||
|
const email = typeof claims.email === "string" ? claims.email : undefined;
|
||||||
|
|
||||||
|
const certificate = await signSshCertificate(
|
||||||
|
session.target,
|
||||||
|
session.rootPem,
|
||||||
|
{
|
||||||
|
publicKeyLine: session.keyPair.publicKeyLine,
|
||||||
|
ott: idToken,
|
||||||
|
principals: [session.username],
|
||||||
|
keyId: email ?? session.username,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const certificateInfo = parseSshCertificate(certificate);
|
||||||
|
if (certificateInfo.publicKeyLine !== session.keyPair.publicKeyLine) {
|
||||||
|
throw new Error("The CA returned a certificate for a different key");
|
||||||
|
}
|
||||||
|
if (!certificateInfo.principals.includes(session.username)) {
|
||||||
|
throw new Error("The CA certificate does not include the host username");
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
certificateInfo.validBefore.getTime() <= now ||
|
||||||
|
certificateInfo.validAfter.getTime() > now + 60_000
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"The CA returned a certificate outside its validity window",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expiresAt = certificateInfo.validBefore;
|
||||||
|
|
||||||
|
const userDataKey = DataCrypto.getUserDataKey(session.userId);
|
||||||
|
if (!userDataKey) throw new Error("User data key not found");
|
||||||
|
// Same token id as OPKSSH: getOPKSSHToken decrypts with it.
|
||||||
|
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
|
||||||
|
await createCurrentOpksshTokenRepository().upsert({
|
||||||
|
userId: session.userId,
|
||||||
|
hostId: session.hostId,
|
||||||
|
sshCert: FieldCrypto.encryptField(
|
||||||
|
certificate,
|
||||||
|
userDataKey,
|
||||||
|
tokenId,
|
||||||
|
"ssh_cert",
|
||||||
|
),
|
||||||
|
privateKey: FieldCrypto.encryptField(
|
||||||
|
session.keyPair.privateKeyPem,
|
||||||
|
userDataKey,
|
||||||
|
tokenId,
|
||||||
|
"private_key",
|
||||||
|
),
|
||||||
|
email,
|
||||||
|
sub: typeof claims.sub === "string" ? claims.sub : undefined,
|
||||||
|
issuer: typeof claims.iss === "string" ? claims.iss : undefined,
|
||||||
|
audience: typeof claims.aud === "string" ? claims.aud : undefined,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
session.completed = true;
|
||||||
|
send(session.ws, {
|
||||||
|
type: "opkssh_completed",
|
||||||
|
requestId: session.state,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
endSession(session, removeRuntime);
|
||||||
|
return { ok: true, message: "Signed in. You can close this window." };
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Step CA certificate issuance failed", error, {
|
||||||
|
operation: "step_ca_complete_error",
|
||||||
|
userId: session.userId,
|
||||||
|
hostId: session.hostId,
|
||||||
|
});
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
send(session.ws, {
|
||||||
|
type: "opkssh_error",
|
||||||
|
requestId: session.state,
|
||||||
|
error: `Step CA: ${message}`,
|
||||||
|
});
|
||||||
|
endSession(session, removeRuntime);
|
||||||
|
return { ok: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { createClient } from "redis";
|
||||||
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
decryptSystemSecret,
|
||||||
|
encryptSystemSecret,
|
||||||
|
} from "../utils/system-secret-crypto.js";
|
||||||
|
|
||||||
|
export interface StepCaCallbackQuery {
|
||||||
|
state?: string;
|
||||||
|
code?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepCaCallbackResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX =
|
||||||
|
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
|
||||||
|
const SESSION_TTL_SECONDS = 5 * 60;
|
||||||
|
const RESULT_TTL_SECONDS = 60;
|
||||||
|
const CONNECT_RETRY_MS = 15_000;
|
||||||
|
|
||||||
|
export class StepCaRuntime {
|
||||||
|
private client: ReturnType<typeof createClient> | null = null;
|
||||||
|
private connecting: Promise<boolean> | null = null;
|
||||||
|
private nextConnectAttempt = 0;
|
||||||
|
|
||||||
|
async register(state: string): Promise<void> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return;
|
||||||
|
await this.client
|
||||||
|
.set(this.routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
|
||||||
|
.catch((error) => this.logFailure("register", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(state: string, query: StepCaCallbackQuery): Promise<boolean> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return false;
|
||||||
|
try {
|
||||||
|
if (!(await this.client.exists(this.routeKey(state)))) return false;
|
||||||
|
const encrypted = await encryptSystemSecret(JSON.stringify(query));
|
||||||
|
const stored = await this.client.set(this.commandKey(state), encrypted, {
|
||||||
|
EX: SESSION_TTL_SECONDS,
|
||||||
|
NX: true,
|
||||||
|
});
|
||||||
|
return stored === "OK";
|
||||||
|
} catch (error) {
|
||||||
|
this.logFailure("submit", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeCommand(state: string): Promise<StepCaCallbackQuery | null> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return null;
|
||||||
|
try {
|
||||||
|
const encrypted = await this.client.getDel(this.commandKey(state));
|
||||||
|
if (!encrypted) return null;
|
||||||
|
return this.decode<StepCaCallbackQuery>(
|
||||||
|
await decryptSystemSecret(encrypted.toString()),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logFailure("take_command", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(state: string, result: StepCaCallbackResult): Promise<void> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return;
|
||||||
|
try {
|
||||||
|
const encrypted = await encryptSystemSecret(JSON.stringify(result));
|
||||||
|
await this.client
|
||||||
|
.multi()
|
||||||
|
.set(this.resultKey(state), encrypted, { EX: RESULT_TTL_SECONDS })
|
||||||
|
.del(this.routeKey(state))
|
||||||
|
.del(this.commandKey(state))
|
||||||
|
.exec();
|
||||||
|
} catch (error) {
|
||||||
|
this.logFailure("complete", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeResult(state: string): Promise<StepCaCallbackResult | null> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return null;
|
||||||
|
try {
|
||||||
|
const encrypted = await this.client.getDel(this.resultKey(state));
|
||||||
|
if (!encrypted) return null;
|
||||||
|
return this.decode<StepCaCallbackResult>(
|
||||||
|
await decryptSystemSecret(encrypted.toString()),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logFailure("take_result", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(state: string): Promise<void> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.client) return;
|
||||||
|
await this.client
|
||||||
|
.del([
|
||||||
|
this.routeKey(state),
|
||||||
|
this.commandKey(state),
|
||||||
|
this.resultKey(state),
|
||||||
|
])
|
||||||
|
.catch((error) => this.logFailure("remove", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.client?.isOpen) await this.client.quit();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureConnected(): Promise<boolean> {
|
||||||
|
const url = process.env.REDIS_URL?.trim();
|
||||||
|
if (!url) return false;
|
||||||
|
if (this.client?.isReady) return true;
|
||||||
|
if (Date.now() < this.nextConnectAttempt) return false;
|
||||||
|
if (this.connecting) return this.connecting;
|
||||||
|
this.connecting = this.connect(url).finally(() => {
|
||||||
|
this.connecting = null;
|
||||||
|
});
|
||||||
|
return this.connecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.client = createClient({
|
||||||
|
url,
|
||||||
|
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||||
|
});
|
||||||
|
this.client.on("error", (error) => this.logFailure("client", error));
|
||||||
|
await this.client.connect();
|
||||||
|
this.nextConnectAttempt = 0;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||||
|
this.logFailure("connect", error);
|
||||||
|
await this.client?.disconnect().catch(() => {});
|
||||||
|
this.client = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private routeKey(state: string): string {
|
||||||
|
return `${PREFIX}:route:${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandKey(state: string): string {
|
||||||
|
return `${PREFIX}:command:${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultKey(state: string): string {
|
||||||
|
return `${PREFIX}:result:${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decode<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logFailure(operation: string, error: unknown): void {
|
||||||
|
sshLogger.warn("Step CA Redis runtime unavailable", {
|
||||||
|
operation: `step_ca_redis_${operation}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stepCaRuntime = new StepCaRuntime();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
import { getAuditUsername } from "../../utils/audit-logger.js";
|
import { getAuditUsername } from "../../utils/audit-logger.js";
|
||||||
|
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||||
import { collabRoomHub } from "../collab/room-hub.js";
|
import { collabRoomHub } from "../collab/room-hub.js";
|
||||||
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js";
|
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js";
|
||||||
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
|
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
|
||||||
@@ -1088,6 +1089,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
const hostname = host.name || host.ip;
|
const hostname = host.name || host.ip;
|
||||||
const requestOrigin = getRequestOrigin(req);
|
const requestOrigin = getRequestOrigin(req);
|
||||||
|
if (host.authType === "stepca") {
|
||||||
|
const { startStepCaAuth } = await import("../step-ca-auth.js");
|
||||||
|
await startStepCaAuth(
|
||||||
|
userId,
|
||||||
|
opksshData.hostId,
|
||||||
|
host.username,
|
||||||
|
ws,
|
||||||
|
requestOrigin,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
await startOPKSSHAuth(
|
await startOPKSSHAuth(
|
||||||
userId,
|
userId,
|
||||||
opksshData.hostId,
|
opksshData.hostId,
|
||||||
@@ -1113,6 +1125,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "opkssh_cancel": {
|
case "opkssh_cancel": {
|
||||||
|
{
|
||||||
|
const { cancelStepCaAuth } = await import("../step-ca-auth.js");
|
||||||
|
cancelStepCaAuth(
|
||||||
|
String((data as { requestId?: string })?.requestId ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
const cancelData = data as { requestId: string };
|
const cancelData = data as { requestId: string };
|
||||||
try {
|
try {
|
||||||
const { cancelAuthSession } = await import("../opkssh-auth.js");
|
const { cancelAuthSession } = await import("../opkssh-auth.js");
|
||||||
@@ -2457,7 +2475,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resolvedCredentials.authType === "opkssh" &&
|
usesIssuedCertificate(resolvedCredentials.authType) &&
|
||||||
err.message.includes("All configured authentication methods failed")
|
err.message.includes("All configured authentication methods failed")
|
||||||
) {
|
) {
|
||||||
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
|
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
|
||||||
@@ -3064,8 +3082,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (resolvedCredentials.authType === "opkssh") {
|
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||||
sendLog("auth", "info", "Using OPKSSH certificate authentication");
|
sendLog("auth", "info", "Using issued SSH certificate authentication");
|
||||||
try {
|
try {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(userId, id);
|
const token = await getOPKSSHToken(userId, id);
|
||||||
@@ -3074,7 +3092,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sendLog(
|
sendLog(
|
||||||
"auth",
|
"auth",
|
||||||
"info",
|
"info",
|
||||||
"No valid OPKSSH token found, requesting authentication",
|
"No valid certificate found, requesting sign-in",
|
||||||
);
|
);
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -3085,7 +3103,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendLog("auth", "info", "Using cached OPKSSH certificate");
|
sendLog("auth", "info", "Using cached SSH certificate");
|
||||||
|
|
||||||
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
|
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
|
||||||
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
|
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
sent: [] as Array<Record<string, unknown>>,
|
||||||
|
upsert: vi.fn(async () => undefined),
|
||||||
|
parseInfo: {
|
||||||
|
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||||
|
principals: ["alice"],
|
||||||
|
validAfter: new Date(Date.now() - 60_000),
|
||||||
|
validBefore: new Date(Date.now() + 60 * 60_000),
|
||||||
|
},
|
||||||
|
idToken: "",
|
||||||
|
command: null as Record<string, string> | null,
|
||||||
|
remoteResult: null as { ok: boolean; message: string } | null,
|
||||||
|
runtimeComplete: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/data-crypto.js", () => ({
|
||||||
|
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) },
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/field-crypto.js", () => ({
|
||||||
|
FieldCrypto: {
|
||||||
|
encryptField: (value: string, _key: Buffer, _id: string, field: string) =>
|
||||||
|
`${field}:${value}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
get: async (key: string) =>
|
||||||
|
({
|
||||||
|
step_ca_url: "https://ca.example",
|
||||||
|
step_ca_fingerprint: "a".repeat(64),
|
||||||
|
step_ca_provisioner: "oidc",
|
||||||
|
})[key],
|
||||||
|
}),
|
||||||
|
createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/step-ca-egress.js", () => ({
|
||||||
|
readStepCaPrivateAllowlist: async () => [],
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/step-ca-client.js", () => ({
|
||||||
|
fetchRootCertificate: async () => "ROOT",
|
||||||
|
findOidcProvisioner: async () => ({
|
||||||
|
clientID: "client",
|
||||||
|
configurationEndpoint: "https://idp.example/.well-known/openid",
|
||||||
|
}),
|
||||||
|
discoverOidcEndpoints: async () => ({
|
||||||
|
authorizationEndpoint: "https://idp.example/auth",
|
||||||
|
tokenEndpoint: "https://idp.example/token",
|
||||||
|
}),
|
||||||
|
createPkce: () => ({ verifier: "verifier", challenge: "challenge" }),
|
||||||
|
generateSshKeyPair: () => ({
|
||||||
|
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||||
|
privateKeyPem: "PRIVATE",
|
||||||
|
}),
|
||||||
|
buildAuthorizationUrl: (input: Record<string, string>) => {
|
||||||
|
const url = new URL("https://idp.example/auth");
|
||||||
|
url.searchParams.set("state", input.state);
|
||||||
|
url.searchParams.set("nonce", input.nonce);
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
exchangeCodeForIdToken: async () => state.idToken,
|
||||||
|
decodeJwtClaims: (token: string) => {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
return payload
|
||||||
|
? JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
||||||
|
: {};
|
||||||
|
},
|
||||||
|
signSshCertificate: async () => "CERT",
|
||||||
|
parseSshCertificate: () => state.parseInfo,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hosts/step-ca-runtime.js", () => ({
|
||||||
|
stepCaRuntime: {
|
||||||
|
register: vi.fn(async () => undefined),
|
||||||
|
remove: vi.fn(async () => undefined),
|
||||||
|
submit: vi.fn(async () => true),
|
||||||
|
takeCommand: vi.fn(async () => {
|
||||||
|
const command = state.command;
|
||||||
|
state.command = null;
|
||||||
|
return command;
|
||||||
|
}),
|
||||||
|
complete: state.runtimeComplete,
|
||||||
|
takeResult: vi.fn(async () => {
|
||||||
|
const result = state.remoteResult;
|
||||||
|
state.remoteResult = null;
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } =
|
||||||
|
await import("../../hosts/step-ca-auth.js");
|
||||||
|
|
||||||
|
function jwt(payload: object): string {
|
||||||
|
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeWs(): WebSocket {
|
||||||
|
return {
|
||||||
|
send: (raw: string) => state.sent.push(JSON.parse(raw)),
|
||||||
|
once: vi.fn(),
|
||||||
|
} as unknown as WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(): Promise<{ requestId: string; nonce: string }> {
|
||||||
|
await startStepCaAuth(
|
||||||
|
"user-1",
|
||||||
|
7,
|
||||||
|
"alice",
|
||||||
|
fakeWs(),
|
||||||
|
"https://termix.example",
|
||||||
|
);
|
||||||
|
const chooser = state.sent.at(-1)!;
|
||||||
|
const url = new URL(String(chooser.url));
|
||||||
|
return {
|
||||||
|
requestId: String(chooser.requestId),
|
||||||
|
nonce: url.searchParams.get("nonce")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.sent.length = 0;
|
||||||
|
state.upsert.mockClear();
|
||||||
|
state.runtimeComplete.mockClear();
|
||||||
|
state.command = null;
|
||||||
|
state.remoteResult = null;
|
||||||
|
state.parseInfo = {
|
||||||
|
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||||
|
principals: ["alice"],
|
||||||
|
validAfter: new Date(Date.now() - 60_000),
|
||||||
|
validBefore: new Date(Date.now() + 60 * 60_000),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Step CA authentication", () => {
|
||||||
|
it("stores only a certificate bound to the nonce, key, principal and validity window", async () => {
|
||||||
|
const { requestId, nonce } = await start();
|
||||||
|
state.idToken = jwt({ nonce, email: "alice@example.com" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeStepCaAuth({ state: requestId, code: "code" }),
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
message: "Signed in. You can close this window.",
|
||||||
|
});
|
||||||
|
expect(state.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: "user-1", hostId: 7 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token without the requested nonce", async () => {
|
||||||
|
const { requestId } = await start();
|
||||||
|
state.idToken = jwt({ email: "alice@example.com" });
|
||||||
|
|
||||||
|
const result = await completeStepCaAuth({ state: requestId, code: "code" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toMatch(/does not match/);
|
||||||
|
expect(state.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
|
||||||
|
const { requestId, nonce } = await start();
|
||||||
|
state.idToken = jwt({ nonce });
|
||||||
|
state.command = { state: requestId, code: "remote-code" };
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
expect(state.upsert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a result produced by another instance", async () => {
|
||||||
|
state.remoteResult = { ok: true, message: "remote success" };
|
||||||
|
await expect(
|
||||||
|
completeStepCaAuth({ state: "remote-state", code: "code" }),
|
||||||
|
).resolves.toEqual({ ok: true, message: "remote success" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels local sessions", async () => {
|
||||||
|
const { requestId } = await start();
|
||||||
|
expect(cancelStepCaAuth(requestId)).toBe(true);
|
||||||
|
expect(cancelStepCaAuth(requestId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import {
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
certificateFingerprint,
|
||||||
|
createPkce,
|
||||||
|
decodeJwtClaims,
|
||||||
|
generateSshKeyPair,
|
||||||
|
normalizeCaUrl,
|
||||||
|
normalizeFingerprint,
|
||||||
|
parseSshCertificate,
|
||||||
|
} from "../../utils/step-ca-client.js";
|
||||||
|
import {
|
||||||
|
generateCa,
|
||||||
|
signUserCertificate,
|
||||||
|
} from "../../database/routes/ssh-certificate.js";
|
||||||
|
|
||||||
|
describe("step-ca client helpers", () => {
|
||||||
|
it("normalizes the CA url and fingerprint the way step does", () => {
|
||||||
|
expect(normalizeCaUrl(" https://ca.internal:9000/ ")).toBe(
|
||||||
|
"https://ca.internal:9000",
|
||||||
|
);
|
||||||
|
expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/);
|
||||||
|
const fp = "AB:cd".repeat(16).replace(/:/g, "") + "";
|
||||||
|
expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase());
|
||||||
|
expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fingerprints a PEM certificate by the sha256 of its DER", () => {
|
||||||
|
const der = crypto.randomBytes(64);
|
||||||
|
const pem = `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`;
|
||||||
|
expect(certificateFingerprint(pem)).toBe(
|
||||||
|
crypto.createHash("sha256").update(der).digest("hex"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a PKCE authorization request", () => {
|
||||||
|
const { verifier, challenge } = createPkce();
|
||||||
|
expect(challenge).toBe(
|
||||||
|
crypto.createHash("sha256").update(verifier).digest("base64url"),
|
||||||
|
);
|
||||||
|
const url = new URL(
|
||||||
|
buildAuthorizationUrl({
|
||||||
|
authorizationEndpoint: "https://idp.example/auth?tenant=x",
|
||||||
|
clientId: "cid",
|
||||||
|
redirectUri: "https://termix.example/callback",
|
||||||
|
state: "s",
|
||||||
|
nonce: "n",
|
||||||
|
codeChallenge: challenge,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("tenant")).toBe("x");
|
||||||
|
expect(url.searchParams.get("response_type")).toBe("code");
|
||||||
|
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||||
|
expect(url.searchParams.get("scope")).toContain("openid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates an ed25519 key whose public line a CA can certify, and reads the cert back", () => {
|
||||||
|
const { publicKeyLine, privateKeyPem } = generateSshKeyPair();
|
||||||
|
expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/);
|
||||||
|
expect(privateKeyPem).toContain("BEGIN PRIVATE KEY");
|
||||||
|
|
||||||
|
const ca = generateCa();
|
||||||
|
const cert = signUserCertificate({
|
||||||
|
userPublicKeyLine: publicKeyLine,
|
||||||
|
caPrivateKeyPem: ca.privateKeyPem,
|
||||||
|
caPublicKeyLine: ca.publicKeyLine,
|
||||||
|
keyId: "alice@example",
|
||||||
|
principals: ["alice", "ops"],
|
||||||
|
validAfter: 1_700_000_000,
|
||||||
|
validBefore: 1_700_057_600,
|
||||||
|
});
|
||||||
|
expect(cert).not.toBeNull();
|
||||||
|
const info = parseSshCertificate(cert!);
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
keyType: "ssh-ed25519-cert-v01@openssh.com",
|
||||||
|
publicKeyLine,
|
||||||
|
keyId: "alice@example",
|
||||||
|
principals: ["alice", "ops"],
|
||||||
|
});
|
||||||
|
expect(info.validAfter.toISOString()).toBe("2023-11-14T22:13:20.000Z");
|
||||||
|
expect(info.validBefore.getTime() - info.validAfter.getTime()).toBe(
|
||||||
|
16 * 3600 * 1000,
|
||||||
|
);
|
||||||
|
expect(() => parseSshCertificate(publicKeyLine)).toThrow(/certificate/);
|
||||||
|
expect(() =>
|
||||||
|
parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"),
|
||||||
|
).toThrow(/certificate/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeJwtClaims", () => {
|
||||||
|
it("reads the payload without verifying and tolerates junk", () => {
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({ email: "a@b.c", nonce: "n1" }),
|
||||||
|
).toString("base64url");
|
||||||
|
expect(decodeJwtClaims(`x.${payload}.y`)).toEqual({
|
||||||
|
email: "a@b.c",
|
||||||
|
nonce: "n1",
|
||||||
|
});
|
||||||
|
expect(decodeJwtClaims("not-a-jwt")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,10 +147,21 @@ export function createDnsLookupHook(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutboundTlsOptions {
|
||||||
|
/** PEM bundle to trust instead of the system store (private CAs). */
|
||||||
|
ca?: string;
|
||||||
|
/**
|
||||||
|
* Skip certificate verification. Only for the one request that fetches a
|
||||||
|
* private CA's root by fingerprint, where the caller verifies the result.
|
||||||
|
*/
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function safeOutboundFetch(
|
export async function safeOutboundFetch(
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
allowedPrivateHosts: readonly string[] = [],
|
allowedPrivateHosts: readonly string[] = [],
|
||||||
|
tls: OutboundTlsOptions = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
if (
|
if (
|
||||||
@@ -172,6 +183,10 @@ export async function safeOutboundFetch(
|
|||||||
const dispatcher = new Agent({
|
const dispatcher = new Agent({
|
||||||
connect: {
|
connect: {
|
||||||
lookup: createDnsLookupHook(lookup, allowPrivate),
|
lookup: createDnsLookupHook(lookup, allowPrivate),
|
||||||
|
...(tls.ca ? { ca: tls.ca } : {}),
|
||||||
|
...(tls.rejectUnauthorized === false
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal client for smallstep's step-ca SSH user-certificate flow, done
|
||||||
|
* over its HTTP API rather than the `step` binary:
|
||||||
|
*
|
||||||
|
* 1. bootstrap the CA's root certificate by fingerprint (GET /root/{fp})
|
||||||
|
* 2. read the OIDC provisioner's client settings (GET /provisioners)
|
||||||
|
* 3. run the OIDC authorization-code flow against the provider
|
||||||
|
* 4. POST the id_token as the one-time token to /1.0/ssh/sign
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StepCaTarget {
|
||||||
|
caUrl: string;
|
||||||
|
fingerprint: string;
|
||||||
|
/** Hosts the SSRF guard may reach even when they resolve to private ranges. */
|
||||||
|
allowedPrivateHosts: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepCaOidcProvisioner {
|
||||||
|
name: string;
|
||||||
|
clientID: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
configurationEndpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcEndpoints {
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/** Display-only claims; the CA is the component that verifies the token. */
|
||||||
|
export function decodeJwtClaims(token: string): Record<string, unknown> {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCaUrl(raw: string): string {
|
||||||
|
const url = new URL(raw.trim());
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw new Error("Step CA URL must use https");
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFingerprint(raw: string): string {
|
||||||
|
const hex = raw.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
|
||||||
|
if (hex.length !== 64) {
|
||||||
|
throw new Error("CA fingerprint must be a SHA-256 hex digest");
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pemToDer(pem: string): Buffer {
|
||||||
|
const body = pem
|
||||||
|
.replace(/-----BEGIN [^-]+-----/g, "")
|
||||||
|
.replace(/-----END [^-]+-----/g, "")
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
return Buffer.from(body, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function certificateFingerprint(pem: string): string {
|
||||||
|
return crypto.createHash("sha256").update(pemToDer(pem)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(response: Response, what: string): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${what} failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root endpoint is served under the CA's own TLS certificate, which
|
||||||
|
* nothing trusts yet - so this one request skips verification and trusts
|
||||||
|
* the fingerprint instead, exactly like `step ca bootstrap`.
|
||||||
|
*/
|
||||||
|
export async function fetchRootCertificate(
|
||||||
|
target: StepCaTarget,
|
||||||
|
): Promise<string> {
|
||||||
|
const fingerprint = normalizeFingerprint(target.fingerprint);
|
||||||
|
const response = await safeOutboundFetch(
|
||||||
|
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
|
||||||
|
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||||
|
target.allowedPrivateHosts,
|
||||||
|
{ rejectUnauthorized: false },
|
||||||
|
);
|
||||||
|
const { ca } = await readJson<{ ca?: string }>(
|
||||||
|
response,
|
||||||
|
"Fetching the CA root",
|
||||||
|
);
|
||||||
|
if (!ca || certificateFingerprint(ca) !== fingerprint) {
|
||||||
|
throw new Error("CA root certificate does not match the fingerprint");
|
||||||
|
}
|
||||||
|
return ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOidcProvisioner(
|
||||||
|
target: StepCaTarget,
|
||||||
|
rootPem: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<StepCaOidcProvisioner> {
|
||||||
|
const base = normalizeCaUrl(target.caUrl);
|
||||||
|
let cursor = "";
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const response = await safeOutboundFetch(
|
||||||
|
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
|
||||||
|
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||||
|
target.allowedPrivateHosts,
|
||||||
|
{ ca: rootPem },
|
||||||
|
);
|
||||||
|
const body = await readJson<{
|
||||||
|
provisioners?: Array<Record<string, unknown>>;
|
||||||
|
nextCursor?: string;
|
||||||
|
}>(response, "Listing CA provisioners");
|
||||||
|
const match = (body.provisioners ?? []).find(
|
||||||
|
(p) => p.name === name && p.type === "OIDC",
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
if (
|
||||||
|
typeof match.clientID !== "string" ||
|
||||||
|
typeof match.configurationEndpoint !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("The OIDC provisioner is missing its client settings");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
clientID: match.clientID,
|
||||||
|
clientSecret:
|
||||||
|
typeof match.clientSecret === "string"
|
||||||
|
? match.clientSecret
|
||||||
|
: undefined,
|
||||||
|
configurationEndpoint: match.configurationEndpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!body.nextCursor) break;
|
||||||
|
cursor = body.nextCursor;
|
||||||
|
}
|
||||||
|
throw new Error(`OIDC provisioner "${name}" not found on the CA`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverOidcEndpoints(
|
||||||
|
configurationEndpoint: string,
|
||||||
|
allowedPrivateHosts: readonly string[],
|
||||||
|
): Promise<OidcEndpoints> {
|
||||||
|
const response = await safeOutboundFetch(
|
||||||
|
configurationEndpoint,
|
||||||
|
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||||
|
allowedPrivateHosts,
|
||||||
|
);
|
||||||
|
const doc = await readJson<{
|
||||||
|
authorization_endpoint?: string;
|
||||||
|
token_endpoint?: string;
|
||||||
|
}>(response, "OIDC discovery");
|
||||||
|
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||||
|
throw new Error("OIDC discovery document is incomplete");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
authorizationEndpoint: doc.authorization_endpoint,
|
||||||
|
tokenEndpoint: doc.token_endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPkce(): { verifier: string; challenge: string } {
|
||||||
|
const verifier = crypto.randomBytes(48).toString("base64url");
|
||||||
|
const challenge = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(verifier)
|
||||||
|
.digest("base64url");
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizationUrl(input: {
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
state: string;
|
||||||
|
nonce: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
}): string {
|
||||||
|
const url = new URL(input.authorizationEndpoint);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("client_id", input.clientId);
|
||||||
|
url.searchParams.set("redirect_uri", input.redirectUri);
|
||||||
|
url.searchParams.set("scope", "openid email profile");
|
||||||
|
url.searchParams.set("state", input.state);
|
||||||
|
url.searchParams.set("nonce", input.nonce);
|
||||||
|
url.searchParams.set("code_challenge", input.codeChallenge);
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForIdToken(input: {
|
||||||
|
tokenEndpoint: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
code: string;
|
||||||
|
redirectUri: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
allowedPrivateHosts: readonly string[];
|
||||||
|
}): Promise<string> {
|
||||||
|
const form = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: input.code,
|
||||||
|
redirect_uri: input.redirectUri,
|
||||||
|
client_id: input.clientId,
|
||||||
|
code_verifier: input.codeVerifier,
|
||||||
|
});
|
||||||
|
if (input.clientSecret) form.set("client_secret", input.clientSecret);
|
||||||
|
const response = await safeOutboundFetch(
|
||||||
|
input.tokenEndpoint,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form.toString(),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
},
|
||||||
|
input.allowedPrivateHosts,
|
||||||
|
);
|
||||||
|
const body = await readJson<{ id_token?: string }>(
|
||||||
|
response,
|
||||||
|
"OIDC token exchange",
|
||||||
|
);
|
||||||
|
if (!body.id_token) {
|
||||||
|
throw new Error("The identity provider returned no id_token");
|
||||||
|
}
|
||||||
|
return body.id_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SSH keys and certificates ---------------------------------------------
|
||||||
|
|
||||||
|
function sshString(value: Buffer | string): Buffer {
|
||||||
|
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(data.length, 0);
|
||||||
|
return Buffer.concat([len, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSshKeyPair(): {
|
||||||
|
publicKeyLine: string;
|
||||||
|
privateKeyPem: string;
|
||||||
|
} {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
|
||||||
|
const raw = Buffer.from(jwk.x, "base64url");
|
||||||
|
const blob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
|
||||||
|
return {
|
||||||
|
publicKeyLine: `ssh-ed25519 ${blob.toString("base64")}`,
|
||||||
|
privateKeyPem: privateKey
|
||||||
|
.export({ format: "pem", type: "pkcs8" })
|
||||||
|
.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signSshCertificate(
|
||||||
|
target: StepCaTarget,
|
||||||
|
rootPem: string,
|
||||||
|
input: {
|
||||||
|
publicKeyLine: string;
|
||||||
|
ott: string;
|
||||||
|
principals: string[];
|
||||||
|
keyId: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const blob = input.publicKeyLine.trim().split(/\s+/)[1];
|
||||||
|
if (!blob) throw new Error("Invalid public key line");
|
||||||
|
const response = await safeOutboundFetch(
|
||||||
|
`${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
publicKey: blob,
|
||||||
|
ott: input.ott,
|
||||||
|
certType: "user",
|
||||||
|
principals: input.principals,
|
||||||
|
keyID: input.keyId,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
},
|
||||||
|
target.allowedPrivateHosts,
|
||||||
|
{ ca: rootPem },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const err = (await response.json()) as { message?: string };
|
||||||
|
detail = err.message ? `: ${err.message}` : "";
|
||||||
|
} catch {
|
||||||
|
/* no body */
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`The CA refused to sign the key (HTTP ${response.status})${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { crt?: string };
|
||||||
|
if (!body.crt) throw new Error("The CA returned no certificate");
|
||||||
|
return body.crt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshCertificateInfo {
|
||||||
|
keyType: string;
|
||||||
|
publicKeyLine: string;
|
||||||
|
keyId: string;
|
||||||
|
principals: string[];
|
||||||
|
validAfter: Date;
|
||||||
|
validBefore: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the identity and validity window out of an OpenSSH certificate line. */
|
||||||
|
export function parseSshCertificate(line: string): SshCertificateInfo {
|
||||||
|
const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
|
||||||
|
if (blob.length === 0) throw new Error("Invalid SSH certificate");
|
||||||
|
let offset = 0;
|
||||||
|
const readString = (): Buffer => {
|
||||||
|
if (offset + 4 > blob.length) throw new Error("Truncated SSH certificate");
|
||||||
|
const len = blob.readUInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (offset + len > blob.length) {
|
||||||
|
throw new Error("Truncated SSH certificate");
|
||||||
|
}
|
||||||
|
const value = blob.subarray(offset, offset + len);
|
||||||
|
offset += len;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const readUint64 = (): bigint => {
|
||||||
|
if (offset + 8 > blob.length) throw new Error("Truncated SSH certificate");
|
||||||
|
const value = blob.readBigUInt64BE(offset);
|
||||||
|
offset += 8;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyType = readString().toString();
|
||||||
|
if (!keyType.endsWith("-cert-v01@openssh.com")) {
|
||||||
|
throw new Error("The CA returned a public key instead of a certificate");
|
||||||
|
}
|
||||||
|
readString(); // nonce
|
||||||
|
// Public key fields differ by algorithm; consume them by shape.
|
||||||
|
const publicKeyParts: Buffer[] = [];
|
||||||
|
let plainKeyType: string;
|
||||||
|
if (keyType.startsWith("ssh-rsa")) {
|
||||||
|
plainKeyType = "ssh-rsa";
|
||||||
|
publicKeyParts.push(readString(), readString()); // e, n
|
||||||
|
} else if (keyType.startsWith("ecdsa-")) {
|
||||||
|
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
|
||||||
|
publicKeyParts.push(readString(), readString()); // curve, Q
|
||||||
|
} else {
|
||||||
|
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
|
||||||
|
publicKeyParts.push(readString()); // ed25519 pk
|
||||||
|
}
|
||||||
|
const publicKeyBlob = Buffer.concat([
|
||||||
|
sshString(plainKeyType),
|
||||||
|
...publicKeyParts.map(sshString),
|
||||||
|
]);
|
||||||
|
readUint64(); // serial
|
||||||
|
offset += 4; // type
|
||||||
|
const keyId = readString().toString();
|
||||||
|
const principalsBlob = readString();
|
||||||
|
const principals: string[] = [];
|
||||||
|
for (let p = 0; p < principalsBlob.length;) {
|
||||||
|
if (p + 4 > principalsBlob.length) {
|
||||||
|
throw new Error("Invalid SSH certificate principals");
|
||||||
|
}
|
||||||
|
const len = principalsBlob.readUInt32BE(p);
|
||||||
|
if (p + 4 + len > principalsBlob.length) {
|
||||||
|
throw new Error("Invalid SSH certificate principals");
|
||||||
|
}
|
||||||
|
principals.push(principalsBlob.subarray(p + 4, p + 4 + len).toString());
|
||||||
|
p += 4 + len;
|
||||||
|
}
|
||||||
|
const validAfter = readUint64();
|
||||||
|
const validBefore = readUint64();
|
||||||
|
const toDate = (seconds: bigint) =>
|
||||||
|
new Date(
|
||||||
|
Number(seconds > 8_640_000_000_000n ? 8_640_000_000_000n : seconds) *
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
keyType,
|
||||||
|
publicKeyLine: `${plainKeyType} ${publicKeyBlob.toString("base64")}`,
|
||||||
|
keyId,
|
||||||
|
principals,
|
||||||
|
validAfter: toDate(validAfter),
|
||||||
|
validBefore: toDate(validBefore),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||||
|
import { parseNotificationAllowlist } from "./notification-egress.js";
|
||||||
|
|
||||||
|
/** Private hosts (a CA on the LAN, an internal IdP) the Step CA flow may reach. */
|
||||||
|
export const STEP_CA_PRIVATE_ALLOWLIST_KEY =
|
||||||
|
"step_ca_private_endpoint_allowlist";
|
||||||
|
|
||||||
|
export async function readStepCaPrivateAllowlist(): Promise<string[]> {
|
||||||
|
const raw = await createCurrentSettingsRepository().get(
|
||||||
|
STEP_CA_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
);
|
||||||
|
return parseNotificationAllowlist(raw);
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
|||||||
|
|
||||||
export interface HostSidebarFilterState {
|
export interface HostSidebarFilterState {
|
||||||
status: ("online" | "offline" | "pinned")[];
|
status: ("online" | "offline" | "pinned")[];
|
||||||
authType: ("password" | "key" | "credential" | "none" | "opkssh")[];
|
authType: (
|
||||||
|
"password" | "key" | "credential" | "none" | "opkssh" | "stepca"
|
||||||
|
)[];
|
||||||
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
|
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
|
||||||
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
|
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -94,6 +96,7 @@ const FILTER_AUTH_TYPE: HostSidebarFilterState["authType"] = [
|
|||||||
"credential",
|
"credential",
|
||||||
"none",
|
"none",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
];
|
];
|
||||||
const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [
|
const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [
|
||||||
"ssh",
|
"ssh",
|
||||||
|
|||||||
+16
-2
@@ -68,7 +68,13 @@ export interface LDAPProviderConfig {
|
|||||||
|
|
||||||
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
|
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
export type SSHAuthType =
|
export type SSHAuthType =
|
||||||
"password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
| "password"
|
||||||
|
| "key"
|
||||||
|
| "credential"
|
||||||
|
| "none"
|
||||||
|
| "opkssh"
|
||||||
|
| "stepca"
|
||||||
|
| "tailscale";
|
||||||
|
|
||||||
export type GuacamoleAuthType = "password" | "credential";
|
export type GuacamoleAuthType = "password" | "credential";
|
||||||
|
|
||||||
@@ -133,6 +139,7 @@ export type Host = {
|
|||||||
| "credential"
|
| "credential"
|
||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
|
| "stepca"
|
||||||
| "tailscale"
|
| "tailscale"
|
||||||
| "agent"
|
| "agent"
|
||||||
| "vault";
|
| "vault";
|
||||||
@@ -298,6 +305,7 @@ export interface HostData {
|
|||||||
| "credential"
|
| "credential"
|
||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
|
| "stepca"
|
||||||
| "tailscale"
|
| "tailscale"
|
||||||
| "agent"
|
| "agent"
|
||||||
| "vault";
|
| "vault";
|
||||||
@@ -832,7 +840,13 @@ export type ErrorType =
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type AuthType =
|
export type AuthType =
|
||||||
"password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
| "password"
|
||||||
|
| "key"
|
||||||
|
| "credential"
|
||||||
|
| "none"
|
||||||
|
| "opkssh"
|
||||||
|
| "stepca"
|
||||||
|
| "tailscale";
|
||||||
|
|
||||||
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type Host = {
|
|||||||
| "credential"
|
| "credential"
|
||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
|
| "stepca"
|
||||||
| "tailscale"
|
| "tailscale"
|
||||||
| "vault"
|
| "vault"
|
||||||
| "agent";
|
| "agent";
|
||||||
|
|||||||
@@ -226,6 +226,25 @@ export async function getNotificationPrivateEndpoints(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get Step CA endpoint allowlist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStepCaPrivateEndpoints(
|
||||||
|
hosts: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
|
||||||
|
.data.hosts;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update Step CA endpoint allowlist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setNotificationPrivateEndpoints(
|
export async function setNotificationPrivateEndpoints(
|
||||||
hosts: string[],
|
hosts: string[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
|||||||
@@ -85,6 +85,39 @@ export async function updateTerminalSessionSettings(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StepCaSettings {
|
||||||
|
configured: boolean;
|
||||||
|
caUrl: string;
|
||||||
|
fingerprint: string;
|
||||||
|
provisioner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStepCaSettings(): Promise<StepCaSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/step-ca-settings");
|
||||||
|
return {
|
||||||
|
configured: !!response.data.configured,
|
||||||
|
caUrl: response.data.caUrl ?? "",
|
||||||
|
fingerprint: response.data.fingerprint ?? "",
|
||||||
|
provisioner: response.data.provisioner ?? "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch Step CA settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStepCaSettings(input: {
|
||||||
|
caUrl: string;
|
||||||
|
fingerprint: string;
|
||||||
|
provisioner: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await authApi.patch("/users/step-ca-settings", input);
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update Step CA settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSessionTimeout(
|
export async function updateSessionTimeout(
|
||||||
timeoutHours: number,
|
timeoutHours: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
|
|||||||
"none",
|
"none",
|
||||||
"agent",
|
"agent",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
"vault",
|
"vault",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1357,7 +1357,12 @@ export function DashboardTab({
|
|||||||
const hostId = Number(host.id);
|
const hostId = Number(host.id);
|
||||||
const knownStatus = statuses?.[hostId]?.status;
|
const knownStatus = statuses?.[hostId]?.status;
|
||||||
if (knownStatus === "offline") return null;
|
if (knownStatus === "offline") return null;
|
||||||
if (host.authType === "none" || host.authType === "opkssh") return null;
|
if (
|
||||||
|
host.authType === "none" ||
|
||||||
|
host.authType === "opkssh" ||
|
||||||
|
host.authType === "stepca"
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = newSessions.get(hostId);
|
const existing = newSessions.get(hostId);
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
providers?: Array<{ alias: string; issuer: string }>;
|
providers?: Array<{ alias: string; issuer: string }>;
|
||||||
|
/** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
|
||||||
|
label?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
requestId: msg.requestId || "",
|
requestId: msg.requestId || "",
|
||||||
stage: "chooser",
|
stage: "chooser",
|
||||||
providers: msg.providers,
|
providers: msg.providers,
|
||||||
|
label: typeof msg.label === "string" ? msg.label : undefined,
|
||||||
});
|
});
|
||||||
if (opksshTimeoutRef.current) {
|
if (opksshTimeoutRef.current) {
|
||||||
clearTimeout(opksshTimeoutRef.current);
|
clearTimeout(opksshTimeoutRef.current);
|
||||||
@@ -3598,6 +3601,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
stage={opksshDialog.stage}
|
stage={opksshDialog.stage}
|
||||||
error={opksshDialog.error}
|
error={opksshDialog.error}
|
||||||
providers={opksshDialog.providers}
|
providers={opksshDialog.providers}
|
||||||
|
label={opksshDialog.label}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (webSocketRef.current) {
|
if (webSocketRef.current) {
|
||||||
webSocketRef.current.send(
|
webSocketRef.current.send(
|
||||||
|
|||||||
@@ -920,6 +920,9 @@
|
|||||||
"keyPassphraseSaved": "Passphrase saved, type to change",
|
"keyPassphraseSaved": "Passphrase saved, type to change",
|
||||||
"replaceKey": "Replace key",
|
"replaceKey": "Replace key",
|
||||||
"docsLink": "View docs",
|
"docsLink": "View docs",
|
||||||
|
"stepcaLabel": "Step CA",
|
||||||
|
"stepcaDesc": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator under Admin Settings.",
|
||||||
|
"authTypeStepca": "Step CA",
|
||||||
"opksshLabel": "OPKSSH",
|
"opksshLabel": "OPKSSH",
|
||||||
"opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
|
"opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
|
||||||
"warpgateLabel": "Warpgate Gateway",
|
"warpgateLabel": "Warpgate Gateway",
|
||||||
@@ -1503,6 +1506,7 @@
|
|||||||
"filterAuthCredential": "Credential",
|
"filterAuthCredential": "Credential",
|
||||||
"filterAuthNone": "None",
|
"filterAuthNone": "None",
|
||||||
"filterAuthOpkssh": "OPKSSH",
|
"filterAuthOpkssh": "OPKSSH",
|
||||||
|
"filterAuthStepca": "Step CA",
|
||||||
"filterProtocolGroup": "Protocol",
|
"filterProtocolGroup": "Protocol",
|
||||||
"filterProtocolSsh": "SSH",
|
"filterProtocolSsh": "SSH",
|
||||||
"filterProtocolRdp": "RDP",
|
"filterProtocolRdp": "RDP",
|
||||||
@@ -2068,6 +2072,7 @@
|
|||||||
"warpgateAuthUrl": "Authentication URL",
|
"warpgateAuthUrl": "Authentication URL",
|
||||||
"warpgateOpenBrowser": "Open in Browser",
|
"warpgateOpenBrowser": "Open in Browser",
|
||||||
"warpgateContinue": "I've Completed Authentication",
|
"warpgateContinue": "I've Completed Authentication",
|
||||||
|
"certAuthRequired": "{{provider}} Sign-in Required",
|
||||||
"opksshAuthRequired": "OPKSSH Authentication Required",
|
"opksshAuthRequired": "OPKSSH Authentication Required",
|
||||||
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
|
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
|
||||||
"opksshOpenBrowser": "Open Browser to Authenticate",
|
"opksshOpenBrowser": "Open Browser to Authenticate",
|
||||||
@@ -3689,6 +3694,15 @@
|
|||||||
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
|
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
|
||||||
"aiPrivateEndpoints": "Allowed private AI hosts",
|
"aiPrivateEndpoints": "Allowed private AI hosts",
|
||||||
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
|
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
|
||||||
|
"stepCa": "Step CA",
|
||||||
|
"stepCaDesc": "Issue short-lived SSH certificates from a smallstep CA. Enter the CA URL, its root fingerprint (as shown by step ca bootstrap) and the name of the OIDC provisioner; then choose \"Step CA\" as a host's authentication type. Leave all three empty to disable.",
|
||||||
|
"stepCaFingerprint": "Root fingerprint (SHA-256)",
|
||||||
|
"stepCaProvisioner": "OIDC provisioner name",
|
||||||
|
"stepCaSaved": "Step CA settings saved",
|
||||||
|
"stepCaSaveFailed": "Failed to save Step CA settings",
|
||||||
|
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
|
||||||
|
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
|
||||||
|
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
|
||||||
"notificationPrivateEndpoints": "Allowed private notification hosts",
|
"notificationPrivateEndpoints": "Allowed private notification hosts",
|
||||||
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
||||||
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
getAiGloballyEnabled,
|
getAiGloballyEnabled,
|
||||||
getAiPrivateEndpoints,
|
getAiPrivateEndpoints,
|
||||||
getNotificationPrivateEndpoints,
|
getNotificationPrivateEndpoints,
|
||||||
|
getStepCaPrivateEndpoints,
|
||||||
|
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||||
@@ -97,6 +99,8 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
getTerminalSessionSettings,
|
getTerminalSessionSettings,
|
||||||
updateTerminalSessionSettings,
|
updateTerminalSessionSettings,
|
||||||
|
getStepCaSettings,
|
||||||
|
updateStepCaSettings,
|
||||||
} from "@/api/settings-api";
|
} from "@/api/settings-api";
|
||||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +185,25 @@ export function AdminSettingsPanel({
|
|||||||
useState(true);
|
useState(true);
|
||||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
||||||
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
|
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
|
||||||
|
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [stepCaSettings, setStepCaSettings] = useState({
|
||||||
|
caUrl: "",
|
||||||
|
fingerprint: "",
|
||||||
|
provisioner: "",
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
getStepCaSettings()
|
||||||
|
.then((s) =>
|
||||||
|
setStepCaSettings({
|
||||||
|
caUrl: s.caUrl,
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
provisioner: s.provisioner,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||||
useState<string[]>([]);
|
useState<string[]>([]);
|
||||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||||
@@ -380,6 +403,7 @@ export function AdminSettingsPanel({
|
|||||||
aiEnabled,
|
aiEnabled,
|
||||||
aiEndpoints,
|
aiEndpoints,
|
||||||
notificationEndpoints,
|
notificationEndpoints,
|
||||||
|
stepCaEndpoints,
|
||||||
imageStorage,
|
imageStorage,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
getRegistrationAllowed(),
|
getRegistrationAllowed(),
|
||||||
@@ -399,6 +423,7 @@ export function AdminSettingsPanel({
|
|||||||
getAiGloballyEnabled(),
|
getAiGloballyEnabled(),
|
||||||
getAiPrivateEndpoints(),
|
getAiPrivateEndpoints(),
|
||||||
getNotificationPrivateEndpoints(),
|
getNotificationPrivateEndpoints(),
|
||||||
|
getStepCaPrivateEndpoints(),
|
||||||
getTerminalImageStorageSettings(),
|
getTerminalImageStorageSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -451,6 +476,9 @@ export function AdminSettingsPanel({
|
|||||||
if (aiEndpoints.status === "fulfilled") {
|
if (aiEndpoints.status === "fulfilled") {
|
||||||
setAiPrivateEndpoints(aiEndpoints.value);
|
setAiPrivateEndpoints(aiEndpoints.value);
|
||||||
}
|
}
|
||||||
|
if (stepCaEndpoints.status === "fulfilled") {
|
||||||
|
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||||
|
}
|
||||||
if (notificationEndpoints.status === "fulfilled") {
|
if (notificationEndpoints.status === "fulfilled") {
|
||||||
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||||
}
|
}
|
||||||
@@ -613,6 +641,28 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveStepCaSettings() {
|
||||||
|
try {
|
||||||
|
await updateStepCaSettings(stepCaSettings);
|
||||||
|
toast.success(t("admin.stepCaSaved"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("admin.stepCaSaveFailed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
|
||||||
|
const previous = stepCaPrivateEndpoints;
|
||||||
|
setStepCaPrivateEndpoints(hosts);
|
||||||
|
try {
|
||||||
|
setStepCaPrivateEndpoints(await setStepCaPrivateEndpointsApi(hosts));
|
||||||
|
} catch {
|
||||||
|
setStepCaPrivateEndpoints(previous);
|
||||||
|
toast.error(t("admin.updateStepCaEndpointsFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
|
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
|
||||||
const previous = notificationPrivateEndpoints;
|
const previous = notificationPrivateEndpoints;
|
||||||
setNotificationPrivateEndpoints(hosts);
|
setNotificationPrivateEndpoints(hosts);
|
||||||
@@ -1166,6 +1216,11 @@ export function AdminSettingsPanel({
|
|||||||
aiPrivateEndpoints={aiPrivateEndpoints}
|
aiPrivateEndpoints={aiPrivateEndpoints}
|
||||||
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
||||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||||
|
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||||
|
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||||
|
stepCaSettings={stepCaSettings}
|
||||||
|
setStepCaSettings={setStepCaSettings}
|
||||||
|
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||||
onSaveNotificationPrivateEndpoints={
|
onSaveNotificationPrivateEndpoints={
|
||||||
handleSaveNotificationPrivateEndpoints
|
handleSaveNotificationPrivateEndpoints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ type GeneralSettingsSectionProps = {
|
|||||||
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
||||||
notificationPrivateEndpoints: string[];
|
notificationPrivateEndpoints: string[];
|
||||||
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||||
|
stepCaPrivateEndpoints: string[];
|
||||||
|
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
|
||||||
|
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
|
||||||
|
setStepCaSettings: Dispatch<
|
||||||
|
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
|
||||||
|
>;
|
||||||
|
handleSaveStepCaSettings: () => void;
|
||||||
handleToggleSessionSharingGloballyEnabled: () => void;
|
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
handleToggleRegistration: () => void;
|
handleToggleRegistration: () => void;
|
||||||
@@ -87,6 +94,11 @@ export function AdminGeneralSettingsSection({
|
|||||||
onSaveAiPrivateEndpoints,
|
onSaveAiPrivateEndpoints,
|
||||||
notificationPrivateEndpoints,
|
notificationPrivateEndpoints,
|
||||||
onSaveNotificationPrivateEndpoints,
|
onSaveNotificationPrivateEndpoints,
|
||||||
|
stepCaPrivateEndpoints,
|
||||||
|
onSaveStepCaPrivateEndpoints,
|
||||||
|
stepCaSettings,
|
||||||
|
setStepCaSettings,
|
||||||
|
handleSaveStepCaSettings,
|
||||||
handleToggleSessionSharingGloballyEnabled,
|
handleToggleSessionSharingGloballyEnabled,
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
handleToggleRegistration,
|
handleToggleRegistration,
|
||||||
@@ -215,6 +227,73 @@ export function AdminGeneralSettingsSection({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 py-2">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{t("admin.stepCaPrivateEndpoints")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t("admin.stepCaPrivateEndpointsDesc")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="rounded-none"
|
||||||
|
defaultValue={stepCaPrivateEndpoints.join(", ")}
|
||||||
|
placeholder="ca.internal, sso.internal"
|
||||||
|
onBlur={(event) =>
|
||||||
|
onSaveStepCaPrivateEndpoints(
|
||||||
|
event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("admin.stepCa")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t("admin.stepCaDesc")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="rounded-none"
|
||||||
|
placeholder="https://ca.internal:9000"
|
||||||
|
value={stepCaSettings.caUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStepCaSettings((p) => ({ ...p, caUrl: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="rounded-none font-mono text-xs"
|
||||||
|
placeholder={t("admin.stepCaFingerprint")}
|
||||||
|
value={stepCaSettings.fingerprint}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStepCaSettings((p) => ({ ...p, fingerprint: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="rounded-none"
|
||||||
|
placeholder={t("admin.stepCaProvisioner")}
|
||||||
|
value={stepCaSettings.provisioner}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStepCaSettings((p) => ({
|
||||||
|
...p,
|
||||||
|
provisioner: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||||
|
onClick={handleSaveStepCaSettings}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("admin.allowRegistration")}
|
label={t("admin.allowRegistration")}
|
||||||
description={t("admin.allowRegistrationDesc")}
|
description={t("admin.allowRegistrationDesc")}
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ export function HostEditor({
|
|||||||
"vault",
|
"vault",
|
||||||
"none",
|
"none",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
"agent",
|
"agent",
|
||||||
].map((m) => (
|
].map((m) => (
|
||||||
@@ -647,6 +648,26 @@ export function HostEditor({
|
|||||||
{t("hosts.oidcUsernameHint")}
|
{t("hosts.oidcUsernameHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{authMethod === "stepca" && (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("hosts.stepcaLabel")}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[10px] text-accent-brand hover:underline"
|
||||||
|
>
|
||||||
|
{t("hosts.docsLink")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t("hosts.stepcaDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{authMethod === "tailscale" && (
|
{authMethod === "tailscale" && (
|
||||||
<p className="text-[10px] text-muted-foreground/60">
|
<p className="text-[10px] text-muted-foreground/60">
|
||||||
{t("hosts.tailscaleUsernameHint")}
|
{t("hosts.tailscaleUsernameHint")}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export function HostProxmoxTab({
|
|||||||
{t("hosts.authTypeCredential")}
|
{t("hosts.authTypeCredential")}
|
||||||
</option>
|
</option>
|
||||||
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
||||||
|
<option value="stepca">{t("hosts.authTypeStepca")}</option>
|
||||||
<option value="none">{t("hosts.authTypeNone")}</option>
|
<option value="none">{t("hosts.authTypeNone")}</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|||||||
@@ -766,7 +766,14 @@ export function HostsPanel({
|
|||||||
{t("hosts.filterAuthGroup")}
|
{t("hosts.filterAuthGroup")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{(
|
{(
|
||||||
["password", "key", "credential", "none", "opkssh"] as const
|
[
|
||||||
|
"password",
|
||||||
|
"key",
|
||||||
|
"credential",
|
||||||
|
"none",
|
||||||
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
|
] as const
|
||||||
).map((val) => (
|
).map((val) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={val}
|
key={val}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface OPKSSHDialogProps {
|
|||||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
providers?: Array<{ alias: string; issuer: string }>;
|
providers?: Array<{ alias: string; issuer: string }>;
|
||||||
|
/** Issuer name shown in the title; defaults to OPKSSH. */
|
||||||
|
label?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onOpenUrl: () => void;
|
onOpenUrl: () => void;
|
||||||
onSelectProvider?: (alias: string) => void;
|
onSelectProvider?: (alias: string) => void;
|
||||||
@@ -26,6 +28,7 @@ export function OPKSSHDialog({
|
|||||||
onOpenUrl,
|
onOpenUrl,
|
||||||
onSelectProvider,
|
onSelectProvider,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
label,
|
||||||
}: OPKSSHDialogProps) {
|
}: OPKSSHDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -42,7 +45,9 @@ export function OPKSSHDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="size-4 text-accent-brand" />
|
<Shield className="size-4 text-accent-brand" />
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||||
{t("terminal.opksshAuthRequired")}
|
{label
|
||||||
|
? t("terminal.certAuthRequired", { provider: label })
|
||||||
|
: t("terminal.opksshAuthRequired")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{stage === "chooser" && (
|
{stage === "chooser" && (
|
||||||
|
|||||||
Reference in New Issue
Block a user