script improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 10:44:58 +01:00
parent 0dff3ade09
commit ed54050d6b
4 changed files with 512 additions and 163 deletions

View File

@@ -13,10 +13,12 @@ import {
DeleteResult,
EnableRequest,
OperationResult,
RequirementsDeleteResult,
RequirementsUploadResult,
ScriptInfo,
ScriptUploadResponse,
StatusForNameResponse,
UnitStatus,
ScriptWithStatus,
StatusForNameResponse
} from '../types/scripting';
import { SnifferStatusResponse } from '../types/sniffer';
@@ -168,21 +170,42 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
Scripts
------------------------- */
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
return res.data;
};
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
/**
* Fetch the combined scripts + status endpoint.
* Returns a list of ScriptWithStatus entries.
*/
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
return res.data;
};
/**
* Convenience: list only the basic ScriptInfo items (no mappings).
* This uses the combined endpoint and maps to ScriptInfo[].
*/
export const listScripts = async (): Promise<ScriptInfo[]> => {
const res = await api.get<ScriptInfo[]>('/scripts/scripts');
return res.data;
const all = await fetchScriptsAll();
return all.map((s) => ({ name: s.name, path: s.path }));
};
/**
* Get status/mappings for a single script by name.
* Because the backend merged status into /scripts, we fetch that and filter.
*/
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
const all = await fetchScriptsAll();
const found = all.find((s) => s.name === name);
if (!found) {
// If script not found we still return an empty mapping structure
return { name, mappings: [] };
}
return { name: found.name, mappings: found.mappings || [] };
};
/**
* Upload a script (multipart). If a requirements file is provided the backend
* will run pip install and return pip output in the response (mandatory install).
*/
export const uploadScript = async (opts: {
name: string;
script: File | Blob;
@@ -193,19 +216,59 @@ export const uploadScript = async (opts: {
fd.append('script', opts.script);
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
// axios will set multipart/form-data boundary automatically when FormData passed
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
/**
* Download the script file as a Blob. Use this blob to create an object URL or read text.
*/
export const downloadScript = async (name: string): Promise<Blob> => {
// expect the backend to return a FileResponse. request as blob to trigger browser download handling.
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Download the requirements.txt for a script as a Blob.
* Returns 404 if not present (axios will throw).
*/
export const downloadRequirements = async (name: string): Promise<Blob> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Replace / upload requirements for a script. This endpoint ALWAYS runs pip install
* and will return pip stdout/stderr on success. On pip failure the backend returns 500.
*
* Use multipart/form-data so Swagger UI shows a file picker (backend expects UploadFile).
*/
export const uploadRequirements = async (
name: string,
requirements: File | Blob,
): Promise<RequirementsUploadResult> => {
const fd = new FormData();
fd.append('requirements', requirements);
const res = await api.put<RequirementsUploadResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
/**
* Delete only the requirements file and attempt to clean up the venv.
* Returns summary `{ removed: { requirements_removed, venv_removed }, errors? }`.
*/
export const deleteRequirements = async (name: string): Promise<RequirementsDeleteResult> => {
const res = await api.delete<RequirementsDeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`);
return res.data;
};
/**
* Delete a script (and its files / venv). If qnum provided only remove that unit.
*/
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
const params: Record<string, any> = {};
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
@@ -213,13 +276,18 @@ export const deleteScript = async (name: string, qnum?: number | null): Promise<
return res.data;
};
/**
* Enable a script (creates + starts a systemd unit). Returns OperationResult including service name.
*/
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
return res.data;
};
/**
* Disable a script unit (POST with qnum as query param). Returns OperationResult.
*/
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
// backend expects qnum as query param (POST body null)
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },
});

View File

@@ -27,12 +27,15 @@ import type { ColumnsType } from 'antd/es/table';
import { useEffect, useState } from 'react';
import {
deleteRequirements,
deleteScript,
disableScript,
downloadRequirements,
downloadScript,
enableScript,
fetchScriptsStatusAll,
fetchScriptsAll,
listScripts,
uploadRequirements,
uploadScript,
} from '../api/apiClient';
import PythonEditor from '../components/PythonEditor';
@@ -42,7 +45,7 @@ import {
OperationResult,
ScriptInfo,
ScriptUploadResponse,
UnitStatus,
ScriptWithStatus,
} from '../types/scripting';
const { Paragraph } = Typography;
@@ -67,33 +70,53 @@ type UnitMapping = {
};
export default function ScriptsManager({ onOpenInEditor }: Props) {
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
// now scripts holds ScriptWithStatus entries (name,path,mappings,requirements_exists)
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
const [loading, setLoading] = useState(false);
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [editorModalVisible, setEditorModalVisible] = useState(false);
const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
const [fetchingStatuses, setFetchingStatuses] = useState(false);
const [statuses, setStatuses] = useState<Record<string, UnitStatus> | null>(null);
// pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
// requirements editor modal
const [reqModalVisible, setReqModalVisible] = useState(false);
const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null);
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
useEffect(() => {
refreshList();
// optional: fetch statuses immediately as well
refreshStatuses();
refreshAll();
}, []);
async function refreshAll() {
setLoading(true);
try {
const all = await fetchScriptsAll();
setScripts(all || []);
} catch (err: any) {
notification.error({ message: 'Failed to fetch scripts', description: err?.message || String(err) });
} finally {
setLoading(false);
}
}
async function refreshList() {
setLoading(true);
try {
const data = await listScripts();
setScripts(data || []);
// map listScripts (basic) onto current scripts while preserving existing mappings where possible
const map = new Map(scripts.map((s) => [s.name, s]));
const merged: ScriptWithStatus[] = (data || []).map(
(si) => map.get(si.name) ?? { ...si, mappings: [], requirements_exists: false },
);
setScripts(merged);
} catch (err: any) {
notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
} finally {
@@ -104,9 +127,10 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function refreshStatuses() {
setFetchingStatuses(true);
try {
const all = await fetchScriptsStatusAll();
setStatuses(all);
const count = all ? Object.keys(all).length : 0;
// fetchScriptsAll already returns mappings and requirements_exists; we reuse it
const all = await fetchScriptsAll();
setScripts(all || []);
const count = all ? all.reduce((acc, s) => acc + (s.mappings?.length || 0), 0) : 0;
notification.info({ message: 'Statuses fetched', description: `Found ${count} service(s)` });
} catch (err: any) {
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
@@ -115,40 +139,22 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}
}
// Helper: collect UnitMapping[] for a given script name from statuses
// Helper to get mappings for a script (already available on the script object)
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
if (!statuses) return [];
const mappings: UnitMapping[] = [];
for (const svcKey of Object.keys(statuses)) {
const us = statuses[svcKey];
const parsed = us?.parsed as any;
// Prefer matching parsed.name if present, otherwise infer by service string
const parsedName = parsed?.name ?? null;
if (parsedName === scriptName || svcKey.includes(`-${scriptName}-q`)) {
mappings.push({
service: svcKey,
parsed: {
service: parsed?.service ?? svcKey,
exec_start: parsed?.exec_start ?? null,
name: parsed?.name ?? null,
script_path: parsed?.script_path ?? null,
qnum: typeof parsed?.qnum === 'number' ? parsed.qnum : null,
extra: parsed?.extra ?? null,
},
active: !!us?.active,
});
}
}
// sort by qnum (nulls last)
mappings.sort((a, b) => {
const aq = a.parsed.qnum;
const bq = b.parsed.qnum;
if (aq == null && bq == null) return 0;
if (aq == null) return 1;
if (bq == null) return -1;
return aq - bq;
});
return mappings;
const s = scripts.find((x) => x.name === scriptName);
if (!s || !s.mappings) return [];
return s.mappings.map((m) => ({
service: m.service,
parsed: {
service: m.parsed?.service ?? m.service,
exec_start: m.parsed?.exec_start ?? null,
name: m.parsed?.name ?? null,
script_path: m.parsed?.script_path ?? null,
qnum: typeof m.parsed?.qnum === 'number' ? m.parsed.qnum : null,
extra: m.parsed?.extra ?? null,
},
active: !!m.active,
}));
};
async function handleDownload(name: string) {
@@ -168,6 +174,81 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}
}
// requirements download
async function handleDownloadRequirements(name: string) {
try {
const blob = await downloadRequirements(name);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'requirements.txt';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
notification.success({ message: 'requirements.txt download started' });
} catch (err: any) {
notification.error({ message: 'Download failed', description: err?.message || String(err) });
}
}
// Edit requirements (download and open editor)
async function handleEditRequirements(name: string) {
try {
const blob = await downloadRequirements(name);
const text = await blob.text();
setReqEditorValue(text);
setReqEditingName(name);
setReqModalVisible(true);
} catch (err: any) {
notification.error({ message: 'Failed to open requirements', description: err?.message || String(err) });
}
}
// Save edited requirements (uploads and runs pip install)
async function handleSaveRequirements(name: string, content: string) {
try {
// create blob and upload via API -> returns pip output
const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' }));
if (resp?.pip) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setReqModalVisible(false);
setReqEditingName(null);
setReqEditorValue('');
// refresh
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to upload requirements', description: err?.message || String(err) });
}
}
async function handleDeleteRequirements(name: string) {
Modal.confirm({
title: `Delete requirements for ${name}?`,
content: `This will remove requirements.txt and attempt to clean up the venv for ${name}.`,
onOk: async () => {
try {
const res = await deleteRequirements(name);
notification.success({
message: 'requirements deleted',
description: `requirements removed: ${res.removed?.requirements_removed ? 'yes' : 'no'}`,
});
if (res.errors && res.errors.length) {
Modal.info({
title: 'Delete requirements returned errors',
content: <pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>,
});
}
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to delete requirements', description: err?.message || String(err) });
}
},
});
}
async function handleDelete(name: string) {
try {
const res: DeleteResult = await deleteScript(name);
@@ -183,8 +264,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
),
});
}
refreshList();
refreshStatuses();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
}
@@ -203,9 +283,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
try {
const res: OperationResult = await disableScript(scriptName, qnum);
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
// refresh both lists and statuses
await refreshStatuses();
await refreshList();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
@@ -248,8 +326,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
setUploadModalVisible(false);
form.resetFields();
setCurrentEditingName(null);
refreshList();
refreshStatuses();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
}
@@ -282,8 +359,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}
setEditorModalVisible(false);
refreshList();
refreshStatuses();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Save failed', description: err?.message || String(err) });
}
@@ -307,8 +383,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
setEnableModalVisible(false);
setEnableTarget(null);
enableForm.resetFields();
refreshList();
refreshStatuses();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
}
@@ -316,12 +391,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleDisable(name: string) {
try {
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
// default qnum fallback
const qnum = 0;
const res: OperationResult = await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
refreshList();
refreshStatuses();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
@@ -341,11 +415,31 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
key: 'path',
render: (p) => p || '-',
},
{
title: 'Requirements',
key: 'requirements',
render: (_: any, record: ScriptWithStatus) =>
record.requirements_exists ? (
<Space>
<Button size="small" onClick={() => handleDownloadRequirements(record.name)} icon={<DownloadOutlined />}>
Download
</Button>
<Button size="small" onClick={() => handleEditRequirements(record.name)} icon={<EditOutlined />}>
Edit
</Button>
<Button size="small" danger onClick={() => handleDeleteRequirements(record.name)}>
Delete
</Button>
</Space>
) : (
<span style={{ color: '#999' }}>—</span>
),
},
{
title: 'Actions',
key: 'actions',
width: 320,
render: (_, record) => (
render: (_, record: ScriptWithStatus) => (
<Space>
<Tooltip title="Download">
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
@@ -410,7 +504,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
// Table expandable config
const expandable = {
expandedRowRender: (record: ScriptInfo) => {
expandedRowRender: (record: ScriptWithStatus) => {
const mappings = getMappingsForScript(record.name);
if (!mappings || mappings.length === 0) {
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
@@ -425,12 +519,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
/>
);
},
rowExpandable: (record: ScriptInfo) => {
rowExpandable: (record: ScriptWithStatus) => {
const mappings = getMappingsForScript(record.name);
return mappings.length > 0;
},
defaultExpandAllRows: true,
// show expand icon in first column
defaultExpandAllRows: true, // everything expanded by default
expandRowByClick: false,
};
@@ -442,22 +535,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
Upload / New
</Button>
<Button onClick={refreshList}>Refresh</Button>
<Button onClick={refreshAll}>Refresh</Button>
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
{statuses && (
<Button
onClick={() =>
Modal.info({
title: 'Status summary',
content: (
<pre style={{ maxHeight: 300, overflow: 'auto' }}>{JSON.stringify(statuses, null, 2)}</pre>
),
})
}
>
View Raw Status
</Button>
)}
</Space>
</Col>
</Row>
@@ -482,11 +561,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
footer={null}
>
<Form form={form} layout="vertical" onFinish={handleUpload}>
<Form.Item name="name" label="Script name (include .py if you want)" rules={[{ required: true }]}>
<Input placeholder="my_script.py" />
<Form.Item name="name" label="Script name" rules={[{ required: true }]}>
<Input placeholder="my_script" />
</Form.Item>
<Form.Item label="Script file (optional if using editor below)">
<Form.Item label="Script file">
<Form.Item name="scriptFile" noStyle>
<Upload beforeUpload={() => false} maxCount={1}>
<Button icon={<UploadOutlined />}>Choose script file</Button>
@@ -624,6 +703,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Form>
</Modal>
{/* Requirements Editor Modal */}
<Modal
open={reqModalVisible}
title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'}
onCancel={() => {
setReqModalVisible(false);
setReqEditingName(null);
setReqEditorValue('');
}}
footer={
<Space>
<Button
onClick={() => {
setReqModalVisible(false);
setReqEditingName(null);
setReqEditorValue('');
}}
>
Cancel
</Button>
<Button
type="primary"
onClick={() => {
if (!reqEditingName) {
notification.warning({ message: 'No target name' });
return;
}
handleSaveRequirements(reqEditingName, reqEditorValue);
}}
>
Save & Install (runs pip)
</Button>
</Space>
}
width={800}
>
<PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} />
</Modal>
{/* Pip output modal */}
<Modal
open={pipModalVisible}

View File

@@ -3,13 +3,6 @@ export type ScriptInfo = {
path: string;
};
export type ScriptUploadResponse = ScriptInfo & {
pip?: {
stdout?: string;
stderr?: string;
} | null;
};
export type UnitParsed = {
service: string;
exec_start?: string | null;
@@ -35,6 +28,18 @@ export type StatusForNameResponse = {
mappings: UnitMapping[];
};
export type ScriptWithStatus = ScriptInfo & {
mappings: UnitMapping[];
requirements_exists: boolean;
};
export type ScriptUploadResponse = ScriptInfo & {
pip?: {
stdout?: string;
stderr?: string;
} | null;
};
export type OperationResult = {
status: string;
name: string;
@@ -58,3 +63,20 @@ export type EnableRequest = {
extra_args?: string | null;
enable_at_boot?: boolean | null;
};
// Response shape for requirements upload (pip output)
export type RequirementsUploadResult = {
pip: {
stdout?: string;
stderr?: string;
};
};
// Response shape for requirements delete
export type RequirementsDeleteResult = {
removed: {
requirements_removed: boolean;
venv_removed: boolean;
};
errors?: string[];
};