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:
fuomag9
2026-03-03 22:16:34 +01:00
parent 1b157afc72
commit 0dad675c6d
20 changed files with 974 additions and 18 deletions

View File

@@ -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;

View File

@@ -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
);

View File

@@ -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>
);
}

View File

@@ -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" };
}
}

View File

@@ -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,

View 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>
);
}

View 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 ?? ""}
/>
);
}

View 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 });
}
}

View File

@@ -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
View 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
);

View File

@@ -71,6 +71,13 @@
"when": 1772129593846,
"tag": "0009_watery_bill_hollister",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1772200000000,
"tag": "0010_waf",
"breakpoints": true
}
]
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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 {

View File

@@ -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
}
);

View File

@@ -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(),
});

View File

@@ -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,
};
}

View 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,
}));
}

View File

@@ -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
View 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;
}