633 lines
18 KiB
TypeScript
633 lines
18 KiB
TypeScript
/**
|
|
* Jail detail page.
|
|
*
|
|
* Displays full configuration and state for a single fail2ban jail:
|
|
* - Status badges and control buttons (start, stop, idle, reload)
|
|
* - Log paths, fail-regex, ignore-regex patterns
|
|
* - Date pattern, encoding, and actions
|
|
* - Ignore list management (add / remove IPs)
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Field,
|
|
Input,
|
|
MessageBar,
|
|
MessageBarBody,
|
|
Spinner,
|
|
Switch,
|
|
Text,
|
|
Tooltip,
|
|
makeStyles,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import { useCommonSectionStyles } from "../theme/commonStyles";
|
|
import {
|
|
ArrowClockwiseRegular,
|
|
ArrowLeftRegular,
|
|
ArrowSyncRegular,
|
|
DismissRegular,
|
|
PauseRegular,
|
|
PlayRegular,
|
|
StopRegular,
|
|
} from "@fluentui/react-icons";
|
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
|
|
import { formatSeconds } from "../utils/formatDate";
|
|
import type { Jail } from "../types/jail";
|
|
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const useStyles = makeStyles({
|
|
root: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: tokens.spacingVerticalL,
|
|
},
|
|
breadcrumb: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: tokens.spacingHorizontalS,
|
|
},
|
|
|
|
headerRow: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: tokens.spacingHorizontalM,
|
|
flexWrap: "wrap",
|
|
},
|
|
controlRow: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: tokens.spacingHorizontalS,
|
|
},
|
|
grid: {
|
|
display: "grid",
|
|
gridTemplateColumns: "max-content 1fr",
|
|
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
|
|
alignItems: "baseline",
|
|
},
|
|
label: {
|
|
fontWeight: tokens.fontWeightSemibold,
|
|
color: tokens.colorNeutralForeground2,
|
|
},
|
|
mono: {
|
|
fontFamily: "Consolas, 'Courier New', monospace",
|
|
fontSize: tokens.fontSizeBase200,
|
|
},
|
|
codeList: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: tokens.spacingVerticalXXS,
|
|
paddingTop: tokens.spacingVerticalXS,
|
|
},
|
|
codeItem: {
|
|
fontFamily: "Consolas, 'Courier New', monospace",
|
|
fontSize: tokens.fontSizeBase200,
|
|
padding: `2px ${tokens.spacingHorizontalS}`,
|
|
backgroundColor: tokens.colorNeutralBackground2,
|
|
borderRadius: tokens.borderRadiusSmall,
|
|
wordBreak: "break-all",
|
|
},
|
|
centred: {
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
padding: tokens.spacingVerticalXXL,
|
|
},
|
|
ignoreRow: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: tokens.spacingHorizontalS,
|
|
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
|
backgroundColor: tokens.colorNeutralBackground2,
|
|
borderRadius: tokens.borderRadiusSmall,
|
|
},
|
|
formRow: {
|
|
display: "flex",
|
|
gap: tokens.spacingHorizontalM,
|
|
alignItems: "flex-end",
|
|
flexWrap: "wrap",
|
|
},
|
|
formField: { minWidth: "200px", flexGrow: 1 },
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
|
|
const styles = useStyles();
|
|
if (items.length === 0) {
|
|
return <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>{empty}</Text>;
|
|
}
|
|
return (
|
|
<div className={styles.codeList}>
|
|
{items.map((item, i) => (
|
|
<span key={i} className={styles.codeItem}>
|
|
{item}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Jail info card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface JailInfoProps {
|
|
jail: Jail;
|
|
onRefresh: () => void;
|
|
onStart: () => Promise<void>;
|
|
onStop: () => Promise<void>;
|
|
onSetIdle: (on: boolean) => Promise<void>;
|
|
onReload: () => Promise<void>;
|
|
}
|
|
|
|
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
|
|
const styles = useStyles();
|
|
const sectionStyles = useCommonSectionStyles();
|
|
const navigate = useNavigate();
|
|
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
|
|
|
const handle =
|
|
(fn: () => Promise<unknown>, postNavigate = false) =>
|
|
(): void => {
|
|
setCtrlError(null);
|
|
fn()
|
|
.then(() => {
|
|
if (postNavigate) {
|
|
navigate("/jails");
|
|
} else {
|
|
onRefresh();
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
const msg =
|
|
err instanceof Error
|
|
? err.message
|
|
: String(err);
|
|
setCtrlError(msg);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className={sectionStyles.section}>
|
|
<div className={sectionStyles.sectionHeader}>
|
|
<div className={styles.headerRow}>
|
|
<Text
|
|
size={600}
|
|
weight="semibold"
|
|
style={{ fontFamily: "Consolas, 'Courier New', monospace" }}
|
|
>
|
|
{jail.name}
|
|
</Text>
|
|
{jail.running ? (
|
|
jail.idle ? (
|
|
<Badge appearance="filled" color="warning">idle</Badge>
|
|
) : (
|
|
<Badge appearance="filled" color="success">running</Badge>
|
|
)
|
|
) : (
|
|
<Badge appearance="filled" color="danger">stopped</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<ArrowClockwiseRegular />}
|
|
onClick={onRefresh}
|
|
aria-label="Refresh"
|
|
/>
|
|
</div>
|
|
|
|
{ctrlError && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{ctrlError}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
{/* Control buttons */}
|
|
<div className={styles.controlRow}>
|
|
{jail.running ? (
|
|
<Tooltip content="Stop jail" relationship="label">
|
|
<Button
|
|
appearance="secondary"
|
|
icon={<StopRegular />}
|
|
onClick={handle(onStop)}
|
|
>
|
|
Stop
|
|
</Button>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip content="Start jail" relationship="label">
|
|
<Button
|
|
appearance="primary"
|
|
icon={<PlayRegular />}
|
|
onClick={handle(onStart)}
|
|
>
|
|
Start
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip
|
|
content={jail.idle ? "Resume from idle mode" : "Pause monitoring (idle mode)"}
|
|
relationship="label"
|
|
>
|
|
<Button
|
|
appearance="outline"
|
|
icon={<PauseRegular />}
|
|
onClick={handle(() => onSetIdle(!jail.idle))}
|
|
disabled={!jail.running}
|
|
>
|
|
{jail.idle ? "Resume" : "Set Idle"}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip content="Reload jail configuration" relationship="label">
|
|
<Button
|
|
appearance="outline"
|
|
icon={<ArrowSyncRegular />}
|
|
onClick={handle(onReload)}
|
|
>
|
|
Reload
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
{jail.status && (
|
|
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
|
<Text className={styles.label}>Currently banned:</Text>
|
|
<Text>{String(jail.status.currently_banned)}</Text>
|
|
<Text className={styles.label}>Total banned:</Text>
|
|
<Text>{String(jail.status.total_banned)}</Text>
|
|
<Text className={styles.label}>Currently failed:</Text>
|
|
<Text>{String(jail.status.currently_failed)}</Text>
|
|
<Text className={styles.label}>Total failed:</Text>
|
|
<Text>{String(jail.status.total_failed)}</Text>
|
|
</div>
|
|
)}
|
|
|
|
{/* Config grid */}
|
|
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
|
<Text className={styles.label}>Backend:</Text>
|
|
<Text className={styles.mono}>{jail.backend}</Text>
|
|
<Text className={styles.label}>Find time:</Text>
|
|
<Text>{formatSeconds(jail.find_time)}</Text>
|
|
<Text className={styles.label}>Ban time:</Text>
|
|
<Text>{formatSeconds(jail.ban_time)}</Text>
|
|
<Text className={styles.label}>Max retry:</Text>
|
|
<Text>{String(jail.max_retry)}</Text>
|
|
{jail.date_pattern && (
|
|
<>
|
|
<Text className={styles.label}>Date pattern:</Text>
|
|
<Text className={styles.mono}>{jail.date_pattern}</Text>
|
|
</>
|
|
)}
|
|
{jail.log_encoding && (
|
|
<>
|
|
<Text className={styles.label}>Log encoding:</Text>
|
|
<Text className={styles.mono}>{jail.log_encoding}</Text>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Patterns section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
|
|
const sectionStyles = useCommonSectionStyles();
|
|
return (
|
|
<div className={sectionStyles.section}>
|
|
<div className={sectionStyles.sectionHeader}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
Log Paths & Patterns
|
|
</Text>
|
|
</div>
|
|
|
|
<Text size={300} weight="semibold">Log Paths</Text>
|
|
<CodeList items={jail.log_paths} empty="No log paths configured." />
|
|
|
|
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
|
Fail Regex
|
|
</Text>
|
|
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
|
|
|
|
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
|
Ignore Regex
|
|
</Text>
|
|
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
|
|
|
|
{jail.actions.length > 0 && (
|
|
<>
|
|
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
|
Actions
|
|
</Text>
|
|
<CodeList items={jail.actions} empty="" />
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Ban-time escalation section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null {
|
|
const styles = useStyles();
|
|
const sectionStyles = useCommonSectionStyles();
|
|
const esc = jail.bantime_escalation;
|
|
if (!esc?.increment) return null;
|
|
|
|
return (
|
|
<div className={sectionStyles.section}>
|
|
<div className={sectionStyles.sectionHeader}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
Ban-time Escalation
|
|
</Text>
|
|
<Badge appearance="filled" color="informative">enabled</Badge>
|
|
</div>
|
|
<div className={styles.grid}>
|
|
{esc.factor !== null && (
|
|
<>
|
|
<Text className={styles.label}>Factor:</Text>
|
|
<Text className={styles.mono}>{String(esc.factor)}</Text>
|
|
</>
|
|
)}
|
|
{esc.formula && (
|
|
<>
|
|
<Text className={styles.label}>Formula:</Text>
|
|
<Text className={styles.mono}>{esc.formula}</Text>
|
|
</>
|
|
)}
|
|
{esc.multipliers && (
|
|
<>
|
|
<Text className={styles.label}>Multipliers:</Text>
|
|
<Text className={styles.mono}>{esc.multipliers}</Text>
|
|
</>
|
|
)}
|
|
{esc.max_time !== null && (
|
|
<>
|
|
<Text className={styles.label}>Max time:</Text>
|
|
<Text>{formatSeconds(esc.max_time)}</Text>
|
|
</>
|
|
)}
|
|
{esc.rnd_time !== null && (
|
|
<>
|
|
<Text className={styles.label}>Random jitter:</Text>
|
|
<Text>{formatSeconds(esc.rnd_time)}</Text>
|
|
</>
|
|
)}
|
|
<Text className={styles.label}>Count across all jails:</Text>
|
|
<Text>{esc.overall_jails ? "yes" : "no"}</Text>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Ignore list section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface IgnoreListSectionProps {
|
|
jailName: string;
|
|
ignoreList: string[];
|
|
ignoreSelf: boolean;
|
|
onAdd: (ip: string) => Promise<void>;
|
|
onRemove: (ip: string) => Promise<void>;
|
|
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
|
|
}
|
|
|
|
function IgnoreListSection({
|
|
jailName: _jailName,
|
|
ignoreList,
|
|
ignoreSelf,
|
|
onAdd,
|
|
onRemove,
|
|
onToggleIgnoreSelf,
|
|
}: IgnoreListSectionProps): React.JSX.Element {
|
|
const styles = useStyles();
|
|
const sectionStyles = useCommonSectionStyles();
|
|
const [inputVal, setInputVal] = useState("");
|
|
const [opError, setOpError] = useState<string | null>(null);
|
|
|
|
const handleAdd = (): void => {
|
|
if (!inputVal.trim()) return;
|
|
setOpError(null);
|
|
onAdd(inputVal.trim())
|
|
.then(() => {
|
|
setInputVal("");
|
|
})
|
|
.catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setOpError(msg);
|
|
});
|
|
};
|
|
|
|
const handleRemove = (ip: string): void => {
|
|
setOpError(null);
|
|
onRemove(ip).catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setOpError(msg);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className={sectionStyles.section}>
|
|
<div className={sectionStyles.sectionHeader}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
Ignore List (IP Whitelist)
|
|
</Text>
|
|
</div>
|
|
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
|
|
</div>
|
|
|
|
{/* Ignore-self toggle */}
|
|
<Switch
|
|
label="Ignore self — exclude this server's own IP addresses from banning"
|
|
checked={ignoreSelf}
|
|
onChange={(_e, data): void => {
|
|
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setOpError(msg);
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{opError && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{opError}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
{/* Add form */}
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formField}>
|
|
<Field label="Add IP or CIDR network">
|
|
<Input
|
|
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
|
|
value={inputVal}
|
|
onChange={(_, d) => {
|
|
setInputVal(d.value);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleAdd();
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Button
|
|
appearance="primary"
|
|
onClick={handleAdd}
|
|
disabled={!inputVal.trim()}
|
|
style={{ marginBottom: "4px" }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
{ignoreList.length === 0 ? (
|
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
The ignore list is empty.
|
|
</Text>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
|
{ignoreList.map((ip) => (
|
|
<div key={ip} className={styles.ignoreRow}>
|
|
<Text className={styles.mono}>{ip}</Text>
|
|
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<DismissRegular />}
|
|
onClick={() => {
|
|
handleRemove(ip);
|
|
}}
|
|
aria-label={`Remove ${ip}`}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Jail detail page.
|
|
*
|
|
* Fetches and displays the full configuration and state of a single jail
|
|
* identified by the `:name` route parameter.
|
|
*/
|
|
export function JailDetailPage(): React.JSX.Element {
|
|
const styles = useStyles();
|
|
const { name = "" } = useParams<{ name: string }>();
|
|
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
|
|
useJailDetail(name);
|
|
const {
|
|
items,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
search,
|
|
loading: bannedLoading,
|
|
error: bannedError,
|
|
opError,
|
|
refresh: refreshBanned,
|
|
setPage,
|
|
setPageSize,
|
|
setSearch,
|
|
unban,
|
|
} = useJailBannedIps(name);
|
|
|
|
if (loading && !jail) {
|
|
return (
|
|
<div className={styles.centred}>
|
|
<Spinner label={`Loading jail ${name}…`} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className={styles.root}>
|
|
<Link to="/jails" style={{ textDecoration: "none" }}>
|
|
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
|
Back to Jails
|
|
</Button>
|
|
</Link>
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>Failed to load jail {name}: {error}</MessageBarBody>
|
|
</MessageBar>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!jail) return <></>;
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
{/* Breadcrumb */}
|
|
<div className={styles.breadcrumb}>
|
|
<Link to="/jails" style={{ textDecoration: "none" }}>
|
|
<Button appearance="subtle" size="small" icon={<ArrowLeftRegular />}>
|
|
Jails
|
|
</Button>
|
|
</Link>
|
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
/
|
|
</Text>
|
|
<Text size={200} className={styles.mono}>
|
|
{name}
|
|
</Text>
|
|
</div>
|
|
|
|
<JailInfoSection jail={jail} onRefresh={refresh} onStart={start} onStop={stop} onReload={reload} onSetIdle={setIdle} />
|
|
<BannedIpsSection
|
|
items={items}
|
|
total={total}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
search={search}
|
|
loading={bannedLoading}
|
|
error={bannedError}
|
|
opError={opError}
|
|
onSearch={setSearch}
|
|
onPageChange={setPage}
|
|
onPageSizeChange={setPageSize}
|
|
onRefresh={refreshBanned}
|
|
onUnban={unban}
|
|
/>
|
|
<PatternsSection jail={jail} />
|
|
<BantimeEscalationSection jail={jail} />
|
|
<IgnoreListSection
|
|
jailName={name}
|
|
ignoreList={ignoreList}
|
|
ignoreSelf={ignoreSelf}
|
|
onAdd={addIp}
|
|
onRemove={removeIp}
|
|
onToggleIgnoreSelf={toggleIgnoreSelf}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|