diff --git a/frontend/src/api/c2.ts b/frontend/src/api/c2.ts new file mode 100644 index 0000000..d4fe677 --- /dev/null +++ b/frontend/src/api/c2.ts @@ -0,0 +1,60 @@ +import { apiClient } from './client'; +import type { + C2Config, + C2ConfigInput, + C2TestResult, + C2CallbacksResponse, + C2ExecuteInput, + C2ExecuteResponse, +} from './types'; + +export async function getC2Config(engagementId: number): Promise { + try { + const { data } = await apiClient.get(`/engagements/${engagementId}/c2-config`); + return data; + } catch (err: unknown) { + const e = err as { response?: { status?: number } }; + if (e?.response?.status === 404) return null; + throw err; + } +} + +export async function putC2Config( + engagementId: number, + input: C2ConfigInput, +): Promise { + const { data } = await apiClient.put( + `/engagements/${engagementId}/c2-config`, + input, + ); + return data; +} + +export async function deleteC2Config(engagementId: number): Promise { + await apiClient.delete(`/engagements/${engagementId}/c2-config`); +} + +export async function testC2Config(engagementId: number): Promise { + const { data } = await apiClient.post( + `/engagements/${engagementId}/c2-config/test`, + ); + return data; +} + +export async function listCallbacks(engagementId: number): Promise { + const { data } = await apiClient.get( + `/engagements/${engagementId}/c2/callbacks`, + ); + return data; +} + +export async function executeC2( + simulationId: number, + input: C2ExecuteInput, +): Promise { + const { data } = await apiClient.post( + `/simulations/${simulationId}/c2/execute`, + input, + ); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c977eb..b51f45b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -154,3 +154,52 @@ export interface SimulationPatchInput { soc_comment?: string | null; incident_number?: string | null; } + +// C2 types + +export interface C2Config { + has_token: boolean; + url: string; + verify_tls: boolean; +} + +export interface C2ConfigInput { + url: string; + api_token?: string; + verify_tls: boolean; +} + +export interface C2TestResult { + ok: boolean; + error: string | null; +} + +export interface C2Callback { + display_id: number; + active: boolean; + host: string; + user: string; + domain: string; + last_checkin: string; +} + +export interface C2CallbacksResponse { + callbacks: C2Callback[]; +} + +export interface C2Task { + id: number; + mythic_task_display_id: number; + command: string; + status: string; + completed: boolean; +} + +export interface C2ExecuteInput { + callback_display_id: number; + commands: string[]; +} + +export interface C2ExecuteResponse { + tasks: C2Task[]; +} diff --git a/frontend/src/components/C2ConfigCard.tsx b/frontend/src/components/C2ConfigCard.tsx new file mode 100644 index 0000000..1363310 --- /dev/null +++ b/frontend/src/components/C2ConfigCard.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; +import { ConfirmDialog } from './ConfirmDialog'; +import { FormField, TextInput } from './FormField'; +import { useToast } from '@/hooks/useToast'; + +interface C2ConfigCardProps { + engagementId: number; +} + +export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { + const { push } = useToast(); + + const configQuery = useC2Config(engagementId); + const updateMutation = useUpdateC2Config(engagementId); + const deleteMutation = useDeleteC2Config(engagementId); + const testMutation = useTestC2Config(engagementId); + + const config = configQuery.data; + const is503 = configQuery.error + ? (configQuery.error as { response?: { status?: number } })?.response?.status === 503 + : false; + + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [verifyTls, setVerifyTls] = useState(true); + const [replaceToken, setReplaceToken] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Sync URL and verifyTls from loaded config (but not token — write-only at API level) + useEffect(() => { + if (config) { + setUrl(config.url); + setVerifyTls(config.verify_tls); + } + }, [config]); + + const disabled = is503 || configQuery.isLoading; + + const onSave = async (e: FormEvent) => { + e.preventDefault(); + if (is503) return; + setTestResult(null); + + const input: { url: string; verify_tls: boolean; api_token?: string } = { + url: url.trim(), + verify_tls: verifyTls, + }; + // Send token only if: no existing config, OR user explicitly chose to replace + if (!config?.has_token || replaceToken) { + if (token.trim()) input.api_token = token.trim(); + } + + try { + await updateMutation.mutateAsync(input); + push('C2 configuration saved', 'success'); + setToken(''); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not save C2 configuration'), 'error'); + } + }; + + const onDelete = async () => { + setShowDeleteConfirm(false); + setTestResult(null); + try { + await deleteMutation.mutateAsync(); + push('C2 configuration removed', 'success'); + setUrl(''); + setToken(''); + setVerifyTls(true); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not remove C2 configuration'), 'error'); + } + }; + + const onTest = async () => { + setTestResult(null); + try { + const result = await testMutation.mutateAsync(); + setTestResult({ + ok: result.ok, + message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'), + }); + } catch (err) { + setTestResult({ ok: false, message: extractApiError(err, 'Test failed') }); + } + }; + + const submitting = updateMutation.isPending || deleteMutation.isPending; + + return ( +
+

C2 configuration

+ + {is503 && ( +
+ C2 features are disabled (server has no encryption key configured). +
+ )} + + {configQuery.isLoading ? ( +

Loading…

+ ) : ( +
+ + setUrl(e.target.value)} + disabled={disabled} + /> + + + + {config?.has_token && !replaceToken ? ( +
+ + +
+ ) : ( + setToken(e.target.value)} + disabled={disabled} + /> + )} +
+ +
+ setVerifyTls(e.target.checked)} + disabled={disabled} + className="h-4 w-4 accent-primary" + /> + +
+ +
+ + + + + {testResult !== null && ( + + {testResult.message} + + )} + + {config?.has_token && ( + + )} +
+
+ )} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/ExecuteViaC2Modal.tsx b/frontend/src/components/ExecuteViaC2Modal.tsx new file mode 100644 index 0000000..2d621a0 --- /dev/null +++ b/frontend/src/components/ExecuteViaC2Modal.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; +import type { C2Callback } from '@/api/types'; +import { useToast } from '@/hooks/useToast'; + +interface ExecuteViaC2ModalProps { + simulationId: number; + engagementId: number; + initialCommands: string; + onClose: () => void; +} + +function formatCheckin(ts: string): string { + // Show ISO timestamp as-is — it's a data cell (font-mono) + return ts; +} + +export function ExecuteViaC2Modal({ + simulationId, + engagementId, + initialCommands, + onClose, +}: ExecuteViaC2ModalProps): JSX.Element { + const { push } = useToast(); + + const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); + const executeMutation = useExecuteC2(simulationId, engagementId); + + const [selectedId, setSelectedId] = useState(null); + const [commands, setCommands] = useState(initialCommands); + const [submitError, setSubmitError] = useState(null); + + const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? []; + + const commandLines = commands + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + const canLaunch = selectedId !== null && commandLines.length > 0; + + const onLaunch = async () => { + if (!canLaunch) return; + setSubmitError(null); + try { + const result = await executeMutation.mutateAsync({ + callback_display_id: selectedId, + commands: commandLines, + }); + push(`${result.tasks.length} task(s) submitted`, 'success'); + onClose(); + } catch (err) { + setSubmitError(extractApiError(err, 'Could not execute via C2')); + } + }; + + return ( +
+