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:
@@ -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(
|
||||
hosts: 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(
|
||||
timeoutHours: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
|
||||
"none",
|
||||
"agent",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
]);
|
||||
|
||||
@@ -1357,7 +1357,12 @@ export function DashboardTab({
|
||||
const hostId = Number(host.id);
|
||||
const knownStatus = statuses?.[hostId]?.status;
|
||||
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 {
|
||||
const existing = newSessions.get(hostId);
|
||||
|
||||
@@ -270,6 +270,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
/** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
|
||||
label?: string;
|
||||
} | null>(null);
|
||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestId: msg.requestId || "",
|
||||
stage: "chooser",
|
||||
providers: msg.providers,
|
||||
label: typeof msg.label === "string" ? msg.label : undefined,
|
||||
});
|
||||
if (opksshTimeoutRef.current) {
|
||||
clearTimeout(opksshTimeoutRef.current);
|
||||
@@ -3598,6 +3601,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
stage={opksshDialog.stage}
|
||||
error={opksshDialog.error}
|
||||
providers={opksshDialog.providers}
|
||||
label={opksshDialog.label}
|
||||
onCancel={() => {
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
|
||||
@@ -920,6 +920,9 @@
|
||||
"keyPassphraseSaved": "Passphrase saved, type to change",
|
||||
"replaceKey": "Replace key",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1503,6 +1506,7 @@
|
||||
"filterAuthCredential": "Credential",
|
||||
"filterAuthNone": "None",
|
||||
"filterAuthOpkssh": "OPKSSH",
|
||||
"filterAuthStepca": "Step CA",
|
||||
"filterProtocolGroup": "Protocol",
|
||||
"filterProtocolSsh": "SSH",
|
||||
"filterProtocolRdp": "RDP",
|
||||
@@ -2068,6 +2072,7 @@
|
||||
"warpgateAuthUrl": "Authentication URL",
|
||||
"warpgateOpenBrowser": "Open in Browser",
|
||||
"warpgateContinue": "I've Completed Authentication",
|
||||
"certAuthRequired": "{{provider}} Sign-in Required",
|
||||
"opksshAuthRequired": "OPKSSH Authentication Required",
|
||||
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
||||
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
getAiGloballyEnabled,
|
||||
getAiPrivateEndpoints,
|
||||
getNotificationPrivateEndpoints,
|
||||
getStepCaPrivateEndpoints,
|
||||
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||
@@ -97,6 +99,8 @@ import { toast } from "sonner";
|
||||
import {
|
||||
getTerminalSessionSettings,
|
||||
updateTerminalSessionSettings,
|
||||
getStepCaSettings,
|
||||
updateStepCaSettings,
|
||||
} from "@/api/settings-api";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
@@ -181,6 +185,25 @@ export function AdminSettingsPanel({
|
||||
useState(true);
|
||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
||||
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] =
|
||||
useState<string[]>([]);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
@@ -380,6 +403,7 @@ export function AdminSettingsPanel({
|
||||
aiEnabled,
|
||||
aiEndpoints,
|
||||
notificationEndpoints,
|
||||
stepCaEndpoints,
|
||||
imageStorage,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
@@ -399,6 +423,7 @@ export function AdminSettingsPanel({
|
||||
getAiGloballyEnabled(),
|
||||
getAiPrivateEndpoints(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getStepCaPrivateEndpoints(),
|
||||
getTerminalImageStorageSettings(),
|
||||
]);
|
||||
|
||||
@@ -451,6 +476,9 @@ export function AdminSettingsPanel({
|
||||
if (aiEndpoints.status === "fulfilled") {
|
||||
setAiPrivateEndpoints(aiEndpoints.value);
|
||||
}
|
||||
if (stepCaEndpoints.status === "fulfilled") {
|
||||
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
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[]) {
|
||||
const previous = notificationPrivateEndpoints;
|
||||
setNotificationPrivateEndpoints(hosts);
|
||||
@@ -1166,6 +1216,11 @@ export function AdminSettingsPanel({
|
||||
aiPrivateEndpoints={aiPrivateEndpoints}
|
||||
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||
stepCaSettings={stepCaSettings}
|
||||
setStepCaSettings={setStepCaSettings}
|
||||
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||
onSaveNotificationPrivateEndpoints={
|
||||
handleSaveNotificationPrivateEndpoints
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ type GeneralSettingsSectionProps = {
|
||||
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
||||
notificationPrivateEndpoints: string[];
|
||||
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;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
@@ -87,6 +94,11 @@ export function AdminGeneralSettingsSection({
|
||||
onSaveAiPrivateEndpoints,
|
||||
notificationPrivateEndpoints,
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
stepCaPrivateEndpoints,
|
||||
onSaveStepCaPrivateEndpoints,
|
||||
stepCaSettings,
|
||||
setStepCaSettings,
|
||||
handleSaveStepCaSettings,
|
||||
handleToggleSessionSharingGloballyEnabled,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
@@ -215,6 +227,73 @@ export function AdminGeneralSettingsSection({
|
||||
}
|
||||
/>
|
||||
</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
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
|
||||
@@ -590,6 +590,7 @@ export function HostEditor({
|
||||
"vault",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"agent",
|
||||
].map((m) => (
|
||||
@@ -647,6 +648,26 @@ export function HostEditor({
|
||||
{t("hosts.oidcUsernameHint")}
|
||||
</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" && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.tailscaleUsernameHint")}
|
||||
|
||||
@@ -171,6 +171,7 @@ export function HostProxmoxTab({
|
||||
{t("hosts.authTypeCredential")}
|
||||
</option>
|
||||
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
||||
<option value="stepca">{t("hosts.authTypeStepca")}</option>
|
||||
<option value="none">{t("hosts.authTypeNone")}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
@@ -766,7 +766,14 @@ export function HostsPanel({
|
||||
{t("hosts.filterAuthGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
["password", "key", "credential", "none", "opkssh"] as const
|
||||
[
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
] as const
|
||||
).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface OPKSSHDialogProps {
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
/** Issuer name shown in the title; defaults to OPKSSH. */
|
||||
label?: string;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
@@ -26,6 +28,7 @@ export function OPKSSHDialog({
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
label,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -42,7 +45,9 @@ export function OPKSSHDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.opksshAuthRequired")}
|
||||
{label
|
||||
? t("terminal.certAuthRequired", { provider: label })
|
||||
: t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
|
||||
Reference in New Issue
Block a user