feat: integrate Coraza WAF with full UI and event logging
- Add coraza-caddy/v2 to Caddy Docker build - Add waf_events + waf_log_parse_state DB tables (migration 0010) - Add WafSettings type and get/save functions to settings - Add WafHostConfig/WafMode types to proxy-hosts model - Add resolveEffectiveWaf + buildWafHandler to caddy config generation - Create waf-log-parser.ts: parse Coraza JSON audit log → waf_events - Add WafFields.tsx per-host WAF UI (accordion, mode, CRS, directives) - Add global WAF settings card to SettingsClient - Add WAF Events dashboard page with search, pagination, severity chips - Add WAF Events nav link to sidebar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import GppBadIcon from "@mui/icons-material/GppBad";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
|
||||
type User = {
|
||||
@@ -43,7 +44,8 @@ const NAV_ITEMS = [
|
||||
{ href: "/access-lists", label: "Access Lists", icon: VpnKeyIcon },
|
||||
{ href: "/certificates", label: "Certificates", icon: SecurityIcon },
|
||||
{ href: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
{ href: "/audit-log", label: "Audit Log", icon: HistoryIcon }
|
||||
{ href: "/audit-log", label: "Audit Log", icon: HistoryIcon },
|
||||
{ href: "/waf-events", label: "WAF Events", icon: GppBadIcon }
|
||||
] as const;
|
||||
|
||||
const DRAWER_WIDTH = 260;
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
type LoadBalancingPolicy,
|
||||
type DnsResolverInput,
|
||||
type UpstreamDnsResolutionInput,
|
||||
type GeoBlockMode
|
||||
type GeoBlockMode,
|
||||
type WafHostConfig
|
||||
} from "@/src/lib/models/proxy-hosts";
|
||||
import { getCertificate } from "@/src/lib/models/certificates";
|
||||
import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings";
|
||||
@@ -384,6 +385,34 @@ function parseResponseHeaders(formData: FormData): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseWafConfig(formData: FormData): { waf?: WafHostConfig | null } {
|
||||
if (!formData.has("waf_present")) return {};
|
||||
const enabled = parseCheckbox(formData.get("waf_enabled"));
|
||||
const rawMode = formData.get("waf_mode");
|
||||
const wafMode: WafHostConfig["waf_mode"] = rawMode === "override" ? "override" : "merge";
|
||||
const rawEngineMode = formData.get("waf_engine_mode");
|
||||
const engineMode: WafHostConfig["mode"] =
|
||||
rawEngineMode === "On" ? "On" : rawEngineMode === "Off" ? "Off" : "DetectionOnly";
|
||||
const loadCrs = parseCheckbox(formData.get("waf_load_owasp_crs"));
|
||||
const customDirectives = typeof formData.get("waf_custom_directives") === "string"
|
||||
? (formData.get("waf_custom_directives") as string).trim()
|
||||
: "";
|
||||
|
||||
if (!enabled) {
|
||||
return { waf: { enabled: false, waf_mode: wafMode } };
|
||||
}
|
||||
|
||||
return {
|
||||
waf: {
|
||||
enabled: true,
|
||||
mode: engineMode,
|
||||
load_owasp_crs: loadCrs,
|
||||
custom_directives: customDirectives,
|
||||
waf_mode: wafMode,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseDnsResolverConfig(formData: FormData): DnsResolverInput | undefined {
|
||||
if (!formData.has("dns_present")) {
|
||||
return undefined;
|
||||
@@ -505,7 +534,8 @@ export async function createProxyHostAction(
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
...parseGeoBlockConfig(formData)
|
||||
...parseGeoBlockConfig(formData),
|
||||
...parseWafConfig(formData)
|
||||
},
|
||||
userId
|
||||
);
|
||||
@@ -576,7 +606,8 @@ export async function updateProxyHostAction(
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
...parseGeoBlockConfig(formData)
|
||||
...parseGeoBlockConfig(formData),
|
||||
...parseWafConfig(formData)
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import type {
|
||||
GeneralSettings,
|
||||
AuthentikSettings,
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
LoggingSettings,
|
||||
DnsSettings,
|
||||
UpstreamDnsResolutionSettings,
|
||||
GeoBlockSettings
|
||||
GeoBlockSettings,
|
||||
WafSettings
|
||||
} from "@/src/lib/settings";
|
||||
import { GeoBlockFields } from "@/src/components/proxy-hosts/GeoBlockFields";
|
||||
import {
|
||||
@@ -27,7 +28,8 @@ import {
|
||||
deleteSlaveInstanceAction,
|
||||
toggleSlaveInstanceAction,
|
||||
syncSlaveInstancesAction,
|
||||
updateGeoBlockSettingsAction
|
||||
updateGeoBlockSettingsAction,
|
||||
updateWafSettingsAction
|
||||
} from "./actions";
|
||||
|
||||
type Props = {
|
||||
@@ -43,6 +45,7 @@ type Props = {
|
||||
dns: DnsSettings | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
|
||||
globalGeoBlock?: GeoBlockSettings | null;
|
||||
globalWaf?: WafSettings | null;
|
||||
instanceSync: {
|
||||
mode: "standalone" | "master" | "slave";
|
||||
modeFromEnv: boolean;
|
||||
@@ -87,6 +90,7 @@ export default function SettingsClient({
|
||||
dns,
|
||||
upstreamDnsResolution,
|
||||
globalGeoBlock,
|
||||
globalWaf,
|
||||
instanceSync
|
||||
}: Props) {
|
||||
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
|
||||
@@ -104,6 +108,7 @@ export default function SettingsClient({
|
||||
const [slaveInstanceState, slaveInstanceFormAction] = useFormState(createSlaveInstanceAction, null);
|
||||
const [syncState, syncFormAction] = useFormState(syncSlaveInstancesAction, null);
|
||||
const [geoBlockState, geoBlockFormAction] = useFormState(updateGeoBlockSettingsAction, null);
|
||||
const [wafState, wafFormAction] = useFormState(updateWafSettingsAction, null);
|
||||
|
||||
const isSlave = instanceSync.mode === "slave";
|
||||
const isMaster = instanceSync.mode === "master";
|
||||
@@ -777,6 +782,71 @@ export default function SettingsClient({
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||
Web Application Firewall (WAF)
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||||
Configure a global WAF applied to all proxy hosts. Per-host settings can merge with or override these defaults.
|
||||
Powered by <strong>Coraza</strong> with optional OWASP Core Rule Set.
|
||||
</Typography>
|
||||
<Stack component="form" action={wafFormAction} spacing={2}>
|
||||
{wafState?.message && (
|
||||
<Alert severity={wafState.success ? "success" : "error"}>
|
||||
{wafState.message}
|
||||
</Alert>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={<Switch name="waf_enabled" defaultChecked={globalWaf?.enabled ?? false} />}
|
||||
label="Enable WAF globally"
|
||||
/>
|
||||
<FormControl>
|
||||
<FormLabel sx={{ fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
Engine Mode
|
||||
</FormLabel>
|
||||
<RadioGroup row name="waf_mode" defaultValue={globalWaf?.mode ?? "DetectionOnly"}>
|
||||
<FormControlLabel value="Off" control={<Radio size="small" />} label="Off" />
|
||||
<FormControlLabel value="DetectionOnly" control={<Radio size="small" />} label="Detection Only" />
|
||||
<FormControlLabel value="On" control={<Radio size="small" />} label="On (Blocking)" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={<Checkbox name="waf_load_owasp_crs" defaultChecked={globalWaf?.load_owasp_crs ?? true} />}
|
||||
label={
|
||||
<span>
|
||||
Load OWASP Core Rule Set{" "}
|
||||
<Typography component="span" variant="caption" color="text.secondary">
|
||||
(covers SQLi, XSS, LFI, RCE — recommended)
|
||||
</Typography>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
name="waf_custom_directives"
|
||||
label="Custom SecLang Directives"
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
defaultValue={globalWaf?.custom_directives ?? ""}
|
||||
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="ModSecurity SecLang syntax. Applied after OWASP CRS if enabled."
|
||||
fullWidth
|
||||
/>
|
||||
<Alert severity="info" sx={{ fontSize: "0.8rem" }}>
|
||||
WAF audit events are stored for 90 days and viewable under <strong>WAF Events</strong> in the sidebar.
|
||||
Set mode to <em>Detection Only</em> first to observe traffic before enabling blocking.
|
||||
</Alert>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button type="submit" variant="contained">
|
||||
Save WAF settings
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { requireAdmin } from "@/src/lib/auth";
|
||||
import { applyCaddyConfig } from "@/src/lib/caddy";
|
||||
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
|
||||
import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances";
|
||||
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings } from "@/src/lib/settings";
|
||||
import type { CloudflareSettings, GeoBlockSettings } from "@/src/lib/settings";
|
||||
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings } from "@/src/lib/settings";
|
||||
import type { CloudflareSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings";
|
||||
|
||||
type ActionResult = {
|
||||
success: boolean;
|
||||
@@ -627,3 +627,34 @@ export async function syncSlaveInstancesAction(_prevState: ActionResult | null,
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to sync slave instances" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWafSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const enabled = formData.get("waf_enabled") === "on";
|
||||
const rawMode = formData.get("waf_mode");
|
||||
const mode: WafSettings["mode"] =
|
||||
rawMode === "On" ? "On" : rawMode === "Off" ? "Off" : "DetectionOnly";
|
||||
const loadOwasp = formData.get("waf_load_owasp_crs") === "on";
|
||||
const customDirectives = typeof formData.get("waf_custom_directives") === "string"
|
||||
? (formData.get("waf_custom_directives") as string).trim()
|
||||
: "";
|
||||
|
||||
const config: WafSettings = { enabled, mode, load_owasp_crs: loadOwasp, custom_directives: customDirectives };
|
||||
await saveWafSettings(config);
|
||||
|
||||
try {
|
||||
await applyCaddyConfig();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
return { success: true, message: `Settings saved, but could not apply to Caddy: ${errorMsg}` };
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "WAF settings saved." };
|
||||
} catch (error) {
|
||||
console.error("Failed to save WAF settings:", error);
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to save WAF settings" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsClient from "./SettingsClient";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings, getWafSettings } from "@/src/lib/settings";
|
||||
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
|
||||
import { listInstances } from "@/src/lib/models/instances";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
@@ -11,7 +11,7 @@ export default async function SettingsPage() {
|
||||
const modeFromEnv = isInstanceModeFromEnv();
|
||||
const tokenFromEnv = isSyncTokenFromEnv();
|
||||
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock] = await Promise.all([
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, globalWaf] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getCloudflareSettings(),
|
||||
getAuthentikSettings(),
|
||||
@@ -20,7 +20,8 @@ export default async function SettingsPage() {
|
||||
getDnsSettings(),
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getInstanceMode(),
|
||||
getGeoBlockSettings()
|
||||
getGeoBlockSettings(),
|
||||
getWafSettings()
|
||||
]);
|
||||
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
@@ -57,6 +58,7 @@ export default async function SettingsPage() {
|
||||
dns={dns}
|
||||
upstreamDnsResolution={upstreamDnsResolution}
|
||||
globalGeoBlock={globalGeoBlock}
|
||||
globalWaf={globalWaf}
|
||||
instanceSync={{
|
||||
mode: instanceMode,
|
||||
modeFromEnv,
|
||||
|
||||
178
app/(dashboard)/waf-events/WafEventsClient.tsx
Normal file
178
app/(dashboard)/waf-events/WafEventsClient.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Chip, Stack, TextField, Tooltip, Typography } from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { DataTable } from "@/src/components/ui/DataTable";
|
||||
import type { WafEvent } from "@/src/lib/models/waf-events";
|
||||
|
||||
type Props = {
|
||||
events: WafEvent[];
|
||||
pagination: { total: number; page: number; perPage: number };
|
||||
initialSearch: string;
|
||||
};
|
||||
|
||||
const SEVERITY_COLOR: Record<string, "error" | "warning" | "info" | "default"> = {
|
||||
CRITICAL: "error",
|
||||
ERROR: "error",
|
||||
HIGH: "error",
|
||||
WARNING: "warning",
|
||||
NOTICE: "info",
|
||||
INFO: "info",
|
||||
};
|
||||
|
||||
function SeverityChip({ severity }: { severity: string | null }) {
|
||||
if (!severity) return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
const upper = severity.toUpperCase();
|
||||
const color = SEVERITY_COLOR[upper] ?? "default";
|
||||
return <Chip label={upper} size="small" color={color} variant="outlined" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />;
|
||||
}
|
||||
|
||||
export default function WafEventsClient({ events, pagination, initialSearch }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value.trim()) {
|
||||
params.set("search", value.trim());
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}, 400);
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: "ts",
|
||||
label: "Time",
|
||||
width: 170,
|
||||
render: (r: WafEvent) => (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(r.ts * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "severity",
|
||||
label: "Severity",
|
||||
width: 110,
|
||||
render: (r: WafEvent) => <SeverityChip severity={r.severity} />,
|
||||
},
|
||||
{
|
||||
id: "host",
|
||||
label: "Host",
|
||||
width: 200,
|
||||
render: (r: WafEvent) => (
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
{r.host || <span style={{ opacity: 0.4 }}>—</span>}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "clientIp",
|
||||
label: "Client IP",
|
||||
width: 160,
|
||||
render: (r: WafEvent) => (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
{r.clientIp}
|
||||
</Typography>
|
||||
{r.countryCode && (
|
||||
<Chip label={r.countryCode} size="small" variant="outlined" sx={{ height: 18, fontSize: "0.65rem" }} />
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
label: "Method",
|
||||
width: 80,
|
||||
render: (r: WafEvent) => (
|
||||
<Chip label={r.method || "—"} size="small" variant="outlined" sx={{ fontFamily: "monospace", fontSize: "0.7rem" }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "uri",
|
||||
label: "URI",
|
||||
render: (r: WafEvent) => (
|
||||
<Tooltip title={r.uri} placement="top">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "monospace", fontSize: "0.8rem", maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{r.uri || <span style={{ opacity: 0.4 }}>—</span>}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ruleId",
|
||||
label: "Rule ID",
|
||||
width: 90,
|
||||
render: (r: WafEvent) => (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
{r.ruleId ?? "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ruleMessage",
|
||||
label: "Rule Message",
|
||||
render: (r: WafEvent) => (
|
||||
<Tooltip title={r.ruleMessage ?? ""} placement="top">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{r.ruleMessage ?? <span style={{ opacity: 0.4 }}>—</span>}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ width: "100%" }}>
|
||||
<Typography variant="h4" fontWeight={600}>
|
||||
WAF Events
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Web Application Firewall detections and blocks. Events are retained for 90 days.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
placeholder="Search by host, IP, URI, or rule message..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); updateSearch(e.target.value); }}
|
||||
slotProps={{
|
||||
input: { startAdornment: <SearchIcon sx={{ mr: 1, color: "rgba(255,255,255,0.5)" }} /> },
|
||||
}}
|
||||
size="small"
|
||||
sx={{ maxWidth: 480 }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={events}
|
||||
keyField="id"
|
||||
emptyMessage="No WAF events found. Enable the WAF in Settings and send some traffic to see events here."
|
||||
pagination={pagination}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
30
app/(dashboard)/waf-events/page.tsx
Normal file
30
app/(dashboard)/waf-events/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import WafEventsClient from "./WafEventsClient";
|
||||
import { listWafEvents, countWafEvents } from "@/src/lib/models/waf-events";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
const PER_PAGE = 50;
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string; search?: string }>;
|
||||
}
|
||||
|
||||
export default async function WafEventsPage({ searchParams }: PageProps) {
|
||||
await requireAdmin();
|
||||
const { page: pageParam, search: searchParam } = await searchParams;
|
||||
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||
const search = searchParam?.trim() || undefined;
|
||||
const offset = (page - 1) * PER_PAGE;
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
listWafEvents(PER_PAGE, offset, search),
|
||||
countWafEvents(search),
|
||||
]);
|
||||
|
||||
return (
|
||||
<WafEventsClient
|
||||
events={events}
|
||||
pagination={{ total, page, perPage: PER_PAGE }}
|
||||
initialSearch={search ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
app/api/waf-events/route.ts
Normal file
23
app/api/waf-events/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { listWafEvents, countWafEvents } from "@/src/lib/models/waf-events";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1);
|
||||
const perPage = Math.min(200, Math.max(1, parseInt(searchParams.get("per_page") ?? "50", 10) || 50));
|
||||
const search = searchParams.get("search")?.trim() || undefined;
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
listWafEvents(perPage, offset, search),
|
||||
countWafEvents(search),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ events, total, page, perPage });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ RUN GOPROXY=direct xcaddy build \
|
||||
--with github.com/caddy-dns/cloudflare \
|
||||
--with github.com/mholt/caddy-l4 \
|
||||
--with github.com/fuomag9/caddy-blocker-plugin \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--output /usr/bin/caddy
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
23
drizzle/0010_waf.sql
Normal file
23
drizzle/0010_waf.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
CREATE TABLE `waf_events` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ts` integer NOT NULL,
|
||||
`host` text NOT NULL DEFAULT '',
|
||||
`client_ip` text NOT NULL,
|
||||
`country_code` text,
|
||||
`method` text NOT NULL DEFAULT '',
|
||||
`uri` text NOT NULL DEFAULT '',
|
||||
`rule_id` integer,
|
||||
`rule_message` text,
|
||||
`severity` text,
|
||||
`raw_data` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_waf_events_ts` ON `waf_events` (`ts`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_waf_events_host_ts` ON `waf_events` (`host`, `ts`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `waf_log_parse_state` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1772129593846,
|
||||
"tag": "0009_watery_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1772200000000,
|
||||
"tag": "0010_waf",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { SettingsToggles } from "./SettingsToggles";
|
||||
import { UpstreamDnsResolutionFields } from "./UpstreamDnsResolutionFields";
|
||||
import { UpstreamInput } from "./UpstreamInput";
|
||||
import { GeoBlockFields } from "./GeoBlockFields";
|
||||
import { WafFields } from "./WafFields";
|
||||
|
||||
export function CreateHostDialog({
|
||||
open,
|
||||
@@ -128,6 +129,7 @@ export function CreateHostDialog({
|
||||
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
|
||||
<GeoBlockFields />
|
||||
<WafFields value={initialData?.waf} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
@@ -231,6 +233,7 @@ export function EditHostDialog({
|
||||
geoblock_mode: host.geoblock_mode,
|
||||
}}
|
||||
/>
|
||||
<WafFields value={host.waf} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
146
src/components/proxy-hosts/WafFields.tsx
Normal file
146
src/components/proxy-hosts/WafFields.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import { useState } from "react";
|
||||
import { type WafHostConfig } from "@/src/lib/models/proxy-hosts";
|
||||
|
||||
type Props = {
|
||||
value?: WafHostConfig | null;
|
||||
};
|
||||
|
||||
export function WafFields({ value }: Props) {
|
||||
const [enabled, setEnabled] = useState(value?.enabled ?? false);
|
||||
const [engineMode, setEngineMode] = useState<"Off" | "DetectionOnly" | "On">(
|
||||
value?.mode ?? "DetectionOnly"
|
||||
);
|
||||
const [loadCrs, setLoadCrs] = useState(value?.load_owasp_crs ?? true);
|
||||
const [customDirectives, setCustomDirectives] = useState(value?.custom_directives ?? "");
|
||||
const [wafMode, setWafMode] = useState<"merge" | "override">(value?.waf_mode ?? "merge");
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SecurityIcon fontSize="small" sx={{ color: "text.secondary" }} />
|
||||
<Typography variant="subtitle2">Web Application Firewall (WAF)</Typography>
|
||||
{enabled && (
|
||||
<Typography variant="caption" color={engineMode === "On" ? "error" : "warning.main"} sx={{ ml: 1 }}>
|
||||
{engineMode === "On" ? "Blocking" : engineMode === "DetectionOnly" ? "Detection Only" : "Off"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{/* Hidden marker so the server action knows WAF config was submitted */}
|
||||
<input type="hidden" name="waf_present" value="1" />
|
||||
<input type="hidden" name="waf_mode" value={wafMode} />
|
||||
<input type="hidden" name="waf_engine_mode" value={engineMode} />
|
||||
<input type="hidden" name="waf_load_owasp_crs" value={loadCrs ? "on" : ""} />
|
||||
<input type="hidden" name="waf_custom_directives" value={customDirectives} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
{/* Enable toggle */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Enable WAF for this host"
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Override mode */}
|
||||
<Box>
|
||||
<FormLabel sx={{ fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
Override Mode
|
||||
</FormLabel>
|
||||
<ToggleButtonGroup
|
||||
value={wafMode}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setWafMode(v)}
|
||||
size="small"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<ToggleButton value="merge">Merge with global</ToggleButton>
|
||||
<ToggleButton value="override">Override global</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Engine mode */}
|
||||
<FormControl>
|
||||
<FormLabel sx={{ fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
Engine Mode
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
value={engineMode}
|
||||
onChange={(e) => setEngineMode(e.target.value as "Off" | "DetectionOnly" | "On")}
|
||||
>
|
||||
<FormControlLabel value="Off" control={<Radio size="small" />} label="Off" />
|
||||
<FormControlLabel value="DetectionOnly" control={<Radio size="small" />} label="Detection Only" />
|
||||
<FormControlLabel value="On" control={<Radio size="small" />} label="On (Blocking)" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* OWASP CRS */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={loadCrs}
|
||||
onChange={(_, checked) => setLoadCrs(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<span>
|
||||
Load OWASP Core Rule Set{" "}
|
||||
<Typography component="span" variant="caption" color="text.secondary">
|
||||
(covers SQLi, XSS, LFI, RCE)
|
||||
</Typography>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Custom directives */}
|
||||
<TextField
|
||||
label="Custom SecLang Directives"
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
value={customDirectives}
|
||||
onChange={(e) => setCustomDirectives(e.target.value)}
|
||||
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="ModSecurity SecLang syntax. Applied after OWASP CRS if enabled."
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,26 @@ export async function register() {
|
||||
console.error("Failed to start log parser:", error);
|
||||
}
|
||||
|
||||
// Start WAF log parser for WAF event tracking
|
||||
const { initWafLogParser, parseNewWafLogEntries, stopWafLogParser } = await import("./lib/waf-log-parser");
|
||||
try {
|
||||
await initWafLogParser();
|
||||
const wafParserInterval = setInterval(async () => {
|
||||
try {
|
||||
await parseNewWafLogEntries();
|
||||
} catch (err) {
|
||||
console.error("WAF log parser interval error:", err);
|
||||
}
|
||||
}, 30_000);
|
||||
process.on("SIGTERM", () => {
|
||||
stopWafLogParser();
|
||||
clearInterval(wafParserInterval);
|
||||
});
|
||||
console.log("WAF log parser started");
|
||||
} catch (error) {
|
||||
console.error("Failed to start WAF log parser:", error);
|
||||
}
|
||||
|
||||
// Start periodic instance sync if configured (master mode only)
|
||||
const { getInstanceMode, getSyncIntervalMs, syncInstances } = await import("./lib/instance-sync");
|
||||
try {
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
getDnsSettings,
|
||||
getUpstreamDnsResolutionSettings,
|
||||
getGeoBlockSettings,
|
||||
getWafSettings,
|
||||
setSetting,
|
||||
type DnsSettings,
|
||||
type UpstreamDnsAddressFamily,
|
||||
type UpstreamDnsResolutionSettings,
|
||||
type GeoBlockSettings
|
||||
type GeoBlockSettings,
|
||||
type WafSettings
|
||||
} from "./settings";
|
||||
import { syncInstances } from "./instance-sync";
|
||||
import {
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
certificates,
|
||||
proxyHosts
|
||||
} from "./db/schema";
|
||||
import { type GeoBlockMode } from "./models/proxy-hosts";
|
||||
import { type GeoBlockMode, type WafHostConfig } from "./models/proxy-hosts";
|
||||
|
||||
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
|
||||
mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 });
|
||||
@@ -103,6 +105,7 @@ type ProxyHostMeta = {
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
|
||||
geoblock?: GeoBlockSettings;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
waf?: WafHostConfig;
|
||||
};
|
||||
|
||||
type ProxyHostAuthentikMeta = {
|
||||
@@ -781,6 +784,70 @@ function resolveEffectiveGeoBlock(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEffectiveWaf(
|
||||
global: WafSettings | null,
|
||||
host: WafHostConfig | null | undefined
|
||||
): WafSettings | null {
|
||||
const hostEnabled = host?.enabled;
|
||||
const globalEnabled = global?.enabled;
|
||||
|
||||
if (!hostEnabled && !globalEnabled) return null;
|
||||
|
||||
// Override mode: use host config entirely
|
||||
if (host && host.waf_mode === "override") {
|
||||
if (!hostEnabled) return null;
|
||||
return {
|
||||
enabled: true,
|
||||
mode: host.mode ?? 'DetectionOnly',
|
||||
load_owasp_crs: host.load_owasp_crs ?? false,
|
||||
custom_directives: host.custom_directives ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// Merge mode: start with global, overlay host fields
|
||||
if (host && global) {
|
||||
return {
|
||||
enabled: true,
|
||||
mode: host.mode ?? global.mode,
|
||||
load_owasp_crs: host.load_owasp_crs ?? global.load_owasp_crs,
|
||||
custom_directives: [global.custom_directives, host.custom_directives].filter(Boolean).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
if (host?.enabled) {
|
||||
return {
|
||||
enabled: true,
|
||||
mode: host.mode ?? 'DetectionOnly',
|
||||
load_owasp_crs: host.load_owasp_crs ?? false,
|
||||
custom_directives: host.custom_directives ?? '',
|
||||
};
|
||||
}
|
||||
if (global?.enabled) return global;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildWafHandler(waf: WafSettings): Record<string, unknown> {
|
||||
const directives = [
|
||||
`SecRuleEngine ${waf.mode}`,
|
||||
'SecAuditEngine On',
|
||||
'SecAuditLog /logs/waf-audit.log',
|
||||
'SecAuditLogFormat JSON',
|
||||
'SecAuditLogParts ABIJDEFHZ',
|
||||
waf.custom_directives,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const handler: Record<string, unknown> = {
|
||||
handler: 'waf',
|
||||
directives,
|
||||
};
|
||||
|
||||
if (waf.load_owasp_crs) {
|
||||
handler.load_owasp_crs = true;
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function buildBlockerHandler(config: GeoBlockSettings): Record<string, unknown> {
|
||||
const handler: Record<string, unknown> = {
|
||||
handler: "blocker",
|
||||
@@ -820,6 +887,7 @@ type BuildProxyRoutesOptions = {
|
||||
globalDnsSettings: DnsSettings | null;
|
||||
globalUpstreamDnsResolutionSettings: UpstreamDnsResolutionSettings | null;
|
||||
globalGeoBlock?: GeoBlockSettings | null;
|
||||
globalWaf?: WafSettings | null;
|
||||
};
|
||||
|
||||
async function buildProxyRoutes(
|
||||
@@ -867,6 +935,14 @@ async function buildProxyRoutes(
|
||||
handlers.unshift(buildBlockerHandler(effectiveGeoBlock));
|
||||
}
|
||||
|
||||
const effectiveWaf = resolveEffectiveWaf(
|
||||
options.globalWaf ?? null,
|
||||
meta.waf
|
||||
);
|
||||
if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') {
|
||||
handlers.unshift(buildWafHandler(effectiveWaf));
|
||||
}
|
||||
|
||||
if (row.hsts_enabled) {
|
||||
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
||||
handlers.push({
|
||||
@@ -1501,11 +1577,12 @@ async function buildCaddyDocument() {
|
||||
}, new Map());
|
||||
|
||||
const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap);
|
||||
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock] = await Promise.all([
|
||||
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getDnsSettings(),
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getGeoBlockSettings()
|
||||
getGeoBlockSettings(),
|
||||
getWafSettings()
|
||||
]);
|
||||
const { tlsApp, managedCertificateIds } = await buildTlsAutomation(certificateUsage, autoManagedDomains, {
|
||||
acmeEmail: generalSettings?.acmeEmail,
|
||||
@@ -1524,7 +1601,8 @@ async function buildCaddyDocument() {
|
||||
{
|
||||
globalDnsSettings: dnsSettings,
|
||||
globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings,
|
||||
globalGeoBlock
|
||||
globalGeoBlock,
|
||||
globalWaf
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -212,3 +212,29 @@ export const logParseState = sqliteTable('log_parse_state', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const wafEvents = sqliteTable(
|
||||
'waf_events',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
ts: integer('ts').notNull(),
|
||||
host: text('host').notNull().default(''),
|
||||
clientIp: text('client_ip').notNull(),
|
||||
countryCode: text('country_code'),
|
||||
method: text('method').notNull().default(''),
|
||||
uri: text('uri').notNull().default(''),
|
||||
ruleId: integer('rule_id'),
|
||||
ruleMessage: text('rule_message'),
|
||||
severity: text('severity'),
|
||||
rawData: text('raw_data'),
|
||||
},
|
||||
(table) => ({
|
||||
tsIdx: index('idx_waf_events_ts').on(table.ts),
|
||||
hostTsIdx: index('idx_waf_events_host_ts').on(table.host, table.ts),
|
||||
})
|
||||
);
|
||||
|
||||
export const wafLogParseState = sqliteTable('waf_log_parse_state', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,16 @@ const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4",
|
||||
|
||||
export type GeoBlockMode = "merge" | "override";
|
||||
|
||||
export type WafMode = "merge" | "override";
|
||||
|
||||
export type WafHostConfig = {
|
||||
enabled?: boolean;
|
||||
mode?: 'Off' | 'DetectionOnly' | 'On';
|
||||
load_owasp_crs?: boolean;
|
||||
custom_directives?: string;
|
||||
waf_mode?: WafMode;
|
||||
};
|
||||
|
||||
// Load Balancer Types
|
||||
export type LoadBalancingPolicy = "random" | "round_robin" | "least_conn" | "ip_hash" | "first" | "header" | "cookie" | "uri_hash";
|
||||
|
||||
@@ -198,6 +208,7 @@ type ProxyHostMeta = {
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
|
||||
geoblock?: GeoBlockSettings;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
waf?: WafHostConfig;
|
||||
};
|
||||
|
||||
export type ProxyHost = {
|
||||
@@ -224,6 +235,7 @@ export type ProxyHost = {
|
||||
upstream_dns_resolution: UpstreamDnsResolutionConfig | null;
|
||||
geoblock: GeoBlockSettings | null;
|
||||
geoblock_mode: GeoBlockMode;
|
||||
waf: WafHostConfig | null;
|
||||
};
|
||||
|
||||
export type ProxyHostInput = {
|
||||
@@ -247,6 +259,7 @@ export type ProxyHostInput = {
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionInput | null;
|
||||
geoblock?: GeoBlockSettings | null;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
waf?: WafHostConfig | null;
|
||||
};
|
||||
|
||||
type ProxyHostRow = typeof proxyHosts.$inferSelect;
|
||||
@@ -996,6 +1009,14 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
next.geoblock_mode = input.geoblock_mode;
|
||||
}
|
||||
|
||||
if (input.waf !== undefined) {
|
||||
if (input.waf) {
|
||||
next.waf = input.waf;
|
||||
} else {
|
||||
delete next.waf;
|
||||
}
|
||||
}
|
||||
|
||||
return serializeMeta(next);
|
||||
}
|
||||
|
||||
@@ -1320,7 +1341,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: hydrateGeoBlock(meta.geoblock),
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge"
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
waf: meta.waf ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
57
src/lib/models/waf-events.ts
Normal file
57
src/lib/models/waf-events.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import db from "../db";
|
||||
import { wafEvents } from "../db/schema";
|
||||
import { desc, like, or, count, and } from "drizzle-orm";
|
||||
|
||||
export type WafEvent = {
|
||||
id: number;
|
||||
ts: number;
|
||||
host: string;
|
||||
clientIp: string;
|
||||
countryCode: string | null;
|
||||
method: string;
|
||||
uri: string;
|
||||
ruleId: number | null;
|
||||
ruleMessage: string | null;
|
||||
severity: string | null;
|
||||
};
|
||||
|
||||
function buildSearch(search?: string) {
|
||||
if (!search) return undefined;
|
||||
return or(
|
||||
like(wafEvents.host, `%${search}%`),
|
||||
like(wafEvents.clientIp, `%${search}%`),
|
||||
like(wafEvents.uri, `%${search}%`),
|
||||
like(wafEvents.ruleMessage, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
export async function countWafEvents(search?: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(wafEvents)
|
||||
.where(buildSearch(search));
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function listWafEvents(limit = 50, offset = 0, search?: string): Promise<WafEvent[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(wafEvents)
|
||||
.where(buildSearch(search))
|
||||
.orderBy(desc(wafEvents.ts))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
ts: r.ts,
|
||||
host: r.host,
|
||||
clientIp: r.clientIp,
|
||||
countryCode: r.countryCode ?? null,
|
||||
method: r.method,
|
||||
uri: r.uri,
|
||||
ruleId: r.ruleId ?? null,
|
||||
ruleMessage: r.ruleMessage ?? null,
|
||||
severity: r.severity ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -210,3 +210,18 @@ export async function getGeoBlockSettings(): Promise<GeoBlockSettings | null> {
|
||||
export async function saveGeoBlockSettings(settings: GeoBlockSettings): Promise<void> {
|
||||
await setSetting("geoblock", settings);
|
||||
}
|
||||
|
||||
export type WafSettings = {
|
||||
enabled: boolean;
|
||||
mode: 'Off' | 'DetectionOnly' | 'On';
|
||||
load_owasp_crs: boolean;
|
||||
custom_directives: string;
|
||||
};
|
||||
|
||||
export async function getWafSettings(): Promise<WafSettings | null> {
|
||||
return await getEffectiveSetting<WafSettings>("waf");
|
||||
}
|
||||
|
||||
export async function saveWafSettings(s: WafSettings): Promise<void> {
|
||||
await setSetting("waf", s);
|
||||
}
|
||||
|
||||
191
src/lib/waf-log-parser.ts
Normal file
191
src/lib/waf-log-parser.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import maxmind, { CountryResponse } from 'maxmind';
|
||||
import db from './db';
|
||||
import { wafEvents, wafLogParseState } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const LOG_FILE = '/logs/waf-audit.log';
|
||||
const GEOIP_DB = '/usr/share/GeoIP/GeoLite2-Country.mmdb';
|
||||
const BATCH_SIZE = 200;
|
||||
const RETENTION_DAYS = 90;
|
||||
|
||||
let geoReader: Awaited<ReturnType<typeof maxmind.open<CountryResponse>>> | null = null;
|
||||
const geoCache = new Map<string, string | null>();
|
||||
|
||||
let stopped = false;
|
||||
|
||||
// ── state helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getState(key: string): string | null {
|
||||
const row = db.select({ value: wafLogParseState.value }).from(wafLogParseState).where(eq(wafLogParseState.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
function setState(key: string, value: string): void {
|
||||
db.insert(wafLogParseState).values({ key, value }).onConflictDoUpdate({ target: wafLogParseState.key, set: { value } }).run();
|
||||
}
|
||||
|
||||
// ── GeoIP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function initGeoIP(): Promise<void> {
|
||||
if (!existsSync(GEOIP_DB)) return;
|
||||
try {
|
||||
geoReader = await maxmind.open<CountryResponse>(GEOIP_DB);
|
||||
} catch {
|
||||
// GeoIP optional
|
||||
}
|
||||
}
|
||||
|
||||
function lookupCountry(ip: string): string | null {
|
||||
if (!geoReader) return null;
|
||||
if (geoCache.has(ip)) return geoCache.get(ip)!;
|
||||
if (geoCache.size > 10_000) geoCache.clear();
|
||||
try {
|
||||
const result = geoReader.get(ip);
|
||||
const code = result?.country?.iso_code ?? null;
|
||||
geoCache.set(ip, code);
|
||||
return code;
|
||||
} catch {
|
||||
geoCache.set(ip, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── parsing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CorazaAuditEntry {
|
||||
transaction?: {
|
||||
client_ip?: string;
|
||||
request?: {
|
||||
method?: string;
|
||||
uri?: string;
|
||||
host?: string;
|
||||
};
|
||||
timestamp?: string;
|
||||
};
|
||||
messages?: Array<{
|
||||
rule?: {
|
||||
id?: number;
|
||||
msg?: string;
|
||||
};
|
||||
severity?: string;
|
||||
data?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseLine(line: string): typeof wafEvents.$inferInsert | null {
|
||||
let entry: CorazaAuditEntry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = entry.transaction;
|
||||
if (!tx) return null;
|
||||
|
||||
const clientIp = tx.client_ip ?? '';
|
||||
if (!clientIp) return null;
|
||||
|
||||
const req = tx.request ?? {};
|
||||
const ts = tx.timestamp ? Math.floor(new Date(tx.timestamp).getTime() / 1000) : Math.floor(Date.now() / 1000);
|
||||
|
||||
const firstMsg = entry.messages?.[0];
|
||||
const ruleId = firstMsg?.rule?.id ?? null;
|
||||
const ruleMessage = firstMsg?.rule?.msg ?? null;
|
||||
const severity = firstMsg?.severity ?? null;
|
||||
|
||||
return {
|
||||
ts,
|
||||
host: req.host ?? '',
|
||||
clientIp,
|
||||
countryCode: lookupCountry(clientIp),
|
||||
method: req.method ?? '',
|
||||
uri: req.uri ?? '',
|
||||
ruleId,
|
||||
ruleMessage,
|
||||
severity,
|
||||
rawData: line,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLines(startOffset: number): Promise<{ lines: string[]; newOffset: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lines: string[] = [];
|
||||
let bytesRead = 0;
|
||||
|
||||
const stream = createReadStream(LOG_FILE, { start: startOffset, encoding: 'utf8' });
|
||||
stream.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT' || err.code === 'EACCES') resolve({ lines: [], newOffset: startOffset });
|
||||
else reject(err);
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
rl.on('line', (line) => {
|
||||
bytesRead += Buffer.byteLength(line, 'utf8') + 1;
|
||||
if (line.trim()) lines.push(line.trim());
|
||||
});
|
||||
rl.on('close', () => resolve({ lines, newOffset: startOffset + bytesRead }));
|
||||
rl.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function insertBatch(rows: typeof wafEvents.$inferInsert[]): void {
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
db.insert(wafEvents).values(rows.slice(i, i + BATCH_SIZE)).run();
|
||||
}
|
||||
}
|
||||
|
||||
function purgeOldEntries(): void {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - RETENTION_DAYS * 86400;
|
||||
db.run(`DELETE FROM waf_events WHERE ts < ${cutoff}`);
|
||||
}
|
||||
|
||||
// ── public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initWafLogParser(): Promise<void> {
|
||||
await initGeoIP();
|
||||
console.log('[waf-log-parser] initialized');
|
||||
}
|
||||
|
||||
export async function parseNewWafLogEntries(): Promise<void> {
|
||||
if (stopped) return;
|
||||
if (!existsSync(LOG_FILE)) return;
|
||||
|
||||
try {
|
||||
const storedOffset = parseInt(getState('waf_audit_log_offset') ?? '0', 10);
|
||||
const storedSize = parseInt(getState('waf_audit_log_size') ?? '0', 10);
|
||||
|
||||
let currentSize: number;
|
||||
try {
|
||||
currentSize = statSync(LOG_FILE).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect log rotation
|
||||
const startOffset = currentSize < storedSize ? 0 : storedOffset;
|
||||
|
||||
const { lines, newOffset } = await readLines(startOffset);
|
||||
|
||||
if (lines.length > 0) {
|
||||
const rows = lines.map(parseLine).filter((r): r is typeof wafEvents.$inferInsert => r !== null);
|
||||
if (rows.length > 0) {
|
||||
insertBatch(rows);
|
||||
console.log(`[waf-log-parser] inserted ${rows.length} WAF events`);
|
||||
}
|
||||
}
|
||||
|
||||
setState('waf_audit_log_offset', String(newOffset));
|
||||
setState('waf_audit_log_size', String(currentSize));
|
||||
|
||||
purgeOldEntries();
|
||||
} catch (err) {
|
||||
console.error('[waf-log-parser] error during parse:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopWafLogParser(): void {
|
||||
stopped = true;
|
||||
}
|
||||
Reference in New Issue
Block a user