Merge pull request #4860 from Infisical/feat/ssh-pam

feat: ssh pam
This commit is contained in:
Sheen
2025-11-19 04:04:48 +08:00
committed by GitHub
36 changed files with 1495 additions and 213 deletions

View File

@@ -1,13 +1,15 @@
import { PamResourceType, PamSessionStatus } from "../enums";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export * from "./ssh-resource";
export type TPamResource = TPostgresResource | TMySQLResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamAccount = TPostgresAccount | TMySQLAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamFolder = {
id: string;
@@ -19,6 +21,22 @@ export type TPamFolder = {
updatedAt: string;
};
// Session log types
export type TPamCommandLog = {
input: string;
output: string;
timestamp: string;
};
export type TTerminalEvent = {
timestamp: string;
eventType: "input" | "output" | "resize" | "error";
data: string; // Base64 encoded binary data
elapsedTime: number; // Seconds since session start (for replay)
};
export type TPamSessionLog = TPamCommandLog | TTerminalEvent;
export type TPamSession = {
id: string;
projectId: string;
@@ -37,11 +55,7 @@ export type TPamSession = {
endedAt?: string | null;
createdAt: string;
updatedAt: string;
commandLogs: {
input: string;
output: string;
timestamp: string;
}[];
logs: TPamSessionLog[];
};
// Resource DTOs

View File

@@ -0,0 +1,47 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export enum SSHAuthMethod {
Password = "password",
PublicKey = "public-key",
Certificate = "certificate"
}
export type TSSHConnectionDetails = {
host: string;
port: number;
};
export type TSSHPasswordCredentials = {
authMethod: SSHAuthMethod.Password;
username: string;
password: string;
};
export type TSSHPublicKeyCredentials = {
authMethod: SSHAuthMethod.PublicKey;
username: string;
privateKey: string;
};
export type TSSHCertificateCredentials = {
authMethod: SSHAuthMethod.Certificate;
username: string;
};
export type TSSHCredentials =
| TSSHPasswordCredentials
| TSSHPublicKeyCredentials
| TSSHCertificateCredentials;
// Resources
export type TSSHResource = TBasePamResource & { resourceType: PamResourceType.SSH } & {
connectionDetails: TSSHConnectionDetails;
rotationAccountCredentials?: TSSHCredentials | null;
};
// Accounts
export type TSSHAccount = TBasePamAccount & {
credentials: TSSHCredentials;
};

View File

@@ -58,15 +58,19 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
return duration;
}, [duration]);
const command = useMemo(
() =>
account &&
(account.resource.resourceType === PamResourceType.Postgres ||
account.resource.resourceType === PamResourceType.MySQL)
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
: "",
[account, cliDuration]
);
const command = useMemo(() => {
if (!account) return "";
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
case PamResourceType.MySQL:
return `infisical pam db access-account ${account.id} --duration ${cliDuration}`;
case PamResourceType.SSH:
return `infisical pam ssh access-account ${account.id} --duration ${cliDuration}`;
default:
return "";
}
}, [account, cliDuration]);
if (!account) return null;

View File

@@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { MySQLAccountForm } from "./MySQLAccountForm";
import { PostgresAccountForm } from "./PostgresAccountForm";
import { SshAccountForm } from "./SshAccountForm";
type FormProps = {
onComplete: (account: TPamAccount) => void;
@@ -65,6 +66,10 @@ const CreateForm = ({
return (
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
case PamResourceType.SSH:
return (
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
return <PostgresAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}

View File

@@ -0,0 +1,272 @@
import { useEffect, useState } from "react";
import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { PamResourceType, TSSHAccount } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TSSHAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const SSHPasswordCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string().trim().min(1, "Username is required"),
password: z.string().trim().min(1, "Password is required")
});
const SSHPublicKeyCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string().trim().min(1, "Username is required"),
privateKey: z.string().trim().min(1, "Private key is required")
});
const SSHCertificateCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string().trim().min(1, "Username is required")
});
const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
SSHPasswordCredentialsSchema,
SSHPublicKeyCredentialsSchema,
SSHCertificateCredentialsSchema
]);
const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSshAccountSchema,
// We don't support rotation for now, just feed a false value to
// make the schema happy
rotationEnabled: z.boolean().default(false)
});
type FormData = z.infer<typeof formSchema>;
const SshAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control, setValue } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const authMethod =
useWatch({ control, name: "credentials.authMethod" }) || SSHAuthMethod.Password;
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<Controller
name="credentials.authMethod"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="mb-3"
isError={Boolean(error?.message)}
errorText={error?.message}
label="Authentication Method"
>
<Select
value={value || SSHAuthMethod.Password}
onValueChange={(newAuthMethod) => {
onChange(newAuthMethod);
// Clear out credentials from other auth methods
setValue("credentials.password", undefined, { shouldDirty: true });
setValue("credentials.privateKey", undefined, { shouldDirty: true });
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SSHAuthMethod.Password}>Password</SelectItem>
<SelectItem value={SSHAuthMethod.PublicKey}>SSH Key</SelectItem>
<SelectItem value={SSHAuthMethod.Certificate}>Certificate</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-3"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
{authMethod === SSHAuthMethod.Password && (
<Controller
name="credentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowPassword(true);
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowPassword(false);
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.PublicKey && (
<Controller
name="credentials.privateKey"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Private Key"
>
<TextArea
{...field}
value={field.value === UNCHANGED_PASSWORD_SENTINEL ? "" : field.value}
className="min-h-32 resize-y font-mono text-xs"
placeholder={
isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL
? "Private key unchanged - click to update"
: "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
}
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.Certificate && (
<p className="mb-0 text-xs text-mineshaft-400">
Certificate-based authentication will use the certificate configured on the SSH resource.
</p>
)}
</div>
);
};
export const SshAccountForm = ({ account, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const getDefaultCredentials = () => {
if (!account) return undefined;
if (account.credentials.authMethod === SSHAuthMethod.Password) {
return {
...account.credentials,
password: UNCHANGED_PASSWORD_SENTINEL
};
}
if (account.credentials.authMethod === SSHAuthMethod.PublicKey) {
return {
...account.credentials,
privateKey: UNCHANGED_PASSWORD_SENTINEL
};
}
return account.credentials;
};
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account
? {
...account,
credentials: getDefaultCredentials()
}
: {
name: "",
description: "",
credentials: {
authMethod: SSHAuthMethod.Password,
username: "",
password: ""
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields />
<SshAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -11,6 +11,7 @@ import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm";
type FormProps = {
onComplete: (resource: TPamResource) => void;
@@ -51,6 +52,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <PostgresResourceForm onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -79,6 +82,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}

View File

@@ -0,0 +1,72 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TSSHResource } from "@app/hooks/api/pam";
import { SshResourceFields } from "./shared/SshResourceFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
resource?: TSSHResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const BaseSshConnectionDetailsSchema = z.object({
host: z.string().trim().min(1, "Host is required"),
port: z.number().int().min(1).max(65535)
});
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.SSH),
connectionDetails: BaseSshConnectionDetailsSchema
});
type FormData = z.infer<typeof formSchema>;
export const SSHResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.SSH,
connectionDetails: {
host: "",
port: 22
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericResourceFields />
<SshResourceFields />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,42 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
export const SshResourceFields = () => {
const { control } = useFormContext();
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="connectionDetails.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input placeholder="example.com or 192.168.1.1" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
</div>
);
};

View File

@@ -78,7 +78,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
if (
resource === PamResourceType.RDP ||
resource === PamResourceType.SSH ||
resource === PamResourceType.Kubernetes ||
resource === PamResourceType.MCP ||
resource === PamResourceType.Redis ||

View File

@@ -0,0 +1,129 @@
import { useMemo, useState } from "react";
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TPamCommandLog } from "@app/hooks/api/pam";
import { formatLogContent } from "./PamSessionLogsSection.utils";
type Props = {
logs: TPamCommandLog[];
};
export const CommandLogView = ({ logs }: Props) => {
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const toggleExpand = (timestamp: string) => {
setExpandedLogTimestamps((prev) => {
if (prev.has(timestamp)) {
return new Set();
}
return new Set([timestamp]);
});
};
const filteredLogs = useMemo(
() =>
logs.filter((log) => {
const searchValue = search.trim().toLowerCase();
return (
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
);
}),
[logs, search]
);
return (
<>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search logs..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => {
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
const formattedInput = formatLogContent(log.input);
const logKey = `${log.timestamp}-${index}`;
return (
<button
type="button"
key={logKey}
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
}`}
onClick={() => toggleExpand(log.timestamp)}
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 select-none">
<FontAwesomeIcon
icon={faChevronRight}
className={twMerge(
"size-3 transition-transform duration-100 ease-in-out",
isExpanded && "rotate-90"
)}
/>
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
</div>
<div
className={`mt-2 font-mono ${
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
}`}
>
<HighlightText text={formattedInput} highlight={search} />
</div>
<div
className={twMerge(
"grid transition-all duration-100 ease-in-out",
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
{log.output && (
<div className="pt-2 text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
)}
</div>
</div>
</button>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No logs match search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
)}
</div>
</>
);
};

View File

@@ -1,44 +1,19 @@
import { useMemo, useState } from "react";
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TPamSession } from "@app/hooks/api/pam";
import { formatLogContent } from "./PamSessionLogsSection.utils";
import { CommandLogView } from "./CommandLogView";
import { TerminalEventView } from "./TerminalEventView";
type Props = {
session: TPamSession;
};
export const PamSessionLogsSection = ({ session }: Props) => {
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const toggleExpand = (timestamp: string) => {
setExpandedLogTimestamps((prev) => {
if (prev.has(timestamp)) {
return new Set();
}
return new Set([timestamp]);
});
};
const filteredLogs = useMemo(
() =>
session.commandLogs.filter((log) => {
const { input, output } = log;
const searchValue = search.trim().toLowerCase();
return (
input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue)
);
}),
[session.commandLogs, search]
);
// Determine log type based on resource type
const isSSHSession = session.resourceType === PamResourceType.SSH;
const isDatabaseSession =
session.resourceType === PamResourceType.Postgres ||
session.resourceType === PamResourceType.MySQL;
const hasLogs = session.logs.length > 0;
return (
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
</div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search logs..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredLogs.length > 0 ? (
filteredLogs.map((log) => {
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
const formattedInput = formatLogContent(log.input);
return (
<button
type="button"
key={log.timestamp}
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
}`}
onClick={() => toggleExpand(log.timestamp)}
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 select-none">
<FontAwesomeIcon
icon={faChevronRight}
className={twMerge(
"size-3 transition-transform duration-100 ease-in-out",
isExpanded && "rotate-90"
)}
/>
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
</div>
<div
className={`mt-2 font-mono ${
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
}`}
>
<HighlightText text={formattedInput} highlight={search} />
</div>
<div
className={twMerge(
"grid transition-all duration-100 ease-in-out",
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
{log.output && (
<div className="pt-2 text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
)}
</div>
</div>
</button>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No logs match search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
{!hasLogs && (
<div className="flex grow items-center justify-center text-bunker-300">
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,86 @@
import { useMemo, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TTerminalEvent } from "@app/hooks/api/pam";
import { aggregateTerminalEvents } from "./terminal-utils";
type Props = {
events: TTerminalEvent[];
};
export const TerminalEventView = ({ events }: Props) => {
const [search, setSearch] = useState("");
const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]);
const filteredEvents = useMemo(
() =>
aggregatedEvents.filter((event) => {
const searchValue = search.trim().toLowerCase();
if (!searchValue) return true;
return event.data.toLowerCase().includes(searchValue);
}),
[aggregatedEvents, search]
);
return (
<>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search terminal output..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredEvents.length > 0 ? (
filteredEvents.map((event, index) => {
const eventKey = `${event.timestamp}-${index}`;
return (
<div
key={eventKey}
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 text-xs">
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
</div>
<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
<HighlightText text={event.data} highlight={search} />
</div>
</div>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No terminal output matches search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Terminal session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,72 @@
import { TTerminalEvent } from "@app/hooks/api/pam";
// Strip ANSI escape codes from terminal output
export const stripAnsiCodes = (text: string): string => {
// Remove ANSI escape sequences
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "");
};
export type AggregatedTerminalEvent = {
timestamp: string;
eventType: string;
data: string;
elapsedTime: number;
eventCount: number;
};
// Aggregate consecutive output events to avoid character-by-character display
export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => {
// Filter to only show output events (input is echoed, so redundant)
const outputEvents = events.filter((e) => e.eventType === "output");
if (outputEvents.length === 0) return [];
// First, combine all events into one string to process
const allText = outputEvents
.map((e) => {
try {
return stripAnsiCodes(atob(e.data));
} catch {
return "";
}
})
.join("");
// Split on lines that contain shell prompts
// Pattern matches: user@hostname:path# or user@hostname:path$
const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/;
const lines = allText.split("\n");
const segments: string[] = [];
let currentSegment: string[] = [];
lines.forEach((line) => {
const hasPrompt = promptPattern.test(line);
if (hasPrompt && currentSegment.length > 0) {
// Found a new prompt, save current segment and start new one
segments.push(currentSegment.join("\n"));
currentSegment = [line];
} else {
// Add line to current segment
currentSegment.push(line);
}
});
// Add the last segment
if (currentSegment.length > 0) {
segments.push(currentSegment.join("\n"));
}
// Filter out empty segments and convert to aggregated events
const validSegments = segments.filter((seg) => seg.trim().length > 0);
return validSegments.map((segment) => ({
timestamp: outputEvents[0].timestamp,
eventType: "output",
data: segment,
elapsedTime: outputEvents[0].elapsedTime,
eventCount: Math.ceil(outputEvents.length / validSegments.length)
}));
};

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
faBoxOpen,
faChevronDown,
@@ -25,18 +25,19 @@ import {
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
import { PAM_RESOURCE_TYPE_MAP, TTerminalEvent, TPamSession } from "@app/hooks/api/pam";
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
type Props = {
session: TPamSession;
search: string;
filteredCommandLogs: TPamSession["commandLogs"];
filteredLogs: TPamSession["logs"];
};
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
export const PamSessionRow = ({ session, search, filteredLogs }: Props) => {
const router = useRouter();
const [showAllLogs, setShowAllLogs] = useState(false);
@@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
// Check if logs are terminal events and aggregate them
const processedLogs = useMemo(() => {
if (filteredLogs.length === 0) return [];
// Check if first log is a terminal event
const isTerminalEvents = "data" in filteredLogs[0];
if (isTerminalEvents) {
// Aggregate terminal events for better display
return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]);
}
// Return command logs as-is
return filteredLogs;
}, [filteredLogs]);
const LOGS_TO_SHOW = 5;
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW);
return (
<>
@@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamResources}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.PamSessions}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -157,32 +174,56 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
</Td>
</Tr>
{filteredCommandLogs.length > 0 && (
{filteredLogs.length > 0 && (
<Tr>
<Td colSpan={5} className="py-3 text-xs">
{logsToShow.map((log) => {
const formattedInput = formatLogContent(log.input);
// Handle command logs (database sessions)
if ("input" in log && "output" in log) {
const formattedInput = formatLogContent(log.input);
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono break-all whitespace-pre-wrap">
<HighlightText text={formattedInput} highlight={search} />
<div className="font-mono break-all whitespace-pre-wrap">
<HighlightText text={formattedInput} highlight={search} />
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output.trim()} highlight={search} />
</div>
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output.trim()} highlight={search} />
);
}
// Handle aggregated terminal events (SSH sessions)
if ("data" in log && typeof log.data === "string") {
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono break-all whitespace-pre-wrap text-bunker-300">
<HighlightText text={log.data.trim()} highlight={search} />
</div>
</div>
</div>
);
);
}
return null;
})}
{filteredCommandLogs.length > LOGS_TO_SHOW && (
{filteredLogs.length > LOGS_TO_SHOW && (
<div className="mt-2">
<Button
variant="link"
@@ -193,7 +234,7 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
>
{showAllLogs
? "Show less"
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
: `Show ${filteredLogs.length - LOGS_TO_SHOW} more log${filteredLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
</Button>
</div>
)}

View File

@@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
id,
resourceName,
userId,
commandLogs
logs
} = session;
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
@@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => {
const filteredLogs =
searchValue.length >= 2
? commandLogs.filter(
(log) =>
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
)
? logs.filter((log) => {
// Handle command logs (database sessions)
if ("input" in log && "output" in log) {
return (
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
);
}
// Handle terminal events (SSH sessions)
if ("data" in log) {
try {
const decodedData = atob(log.data);
return decodedData.toLowerCase().includes(searchValue);
} catch {
return false;
}
}
return false;
})
: [];
return {
@@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
key={session.id}
session={session}
search={search.trim().toLowerCase()}
filteredCommandLogs={filteredLogs}
filteredLogs={filteredLogs}
/>
))}
</TBody>