diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 2e0cbf3e..23dc4845 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -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; diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 7346543c..3b7f967e 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -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 { 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 ); diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index fe6bd8f9..90cb6fe5 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -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({ + + + + + Web Application Firewall (WAF) + + + Configure a global WAF applied to all proxy hosts. Per-host settings can merge with or override these defaults. + Powered by Coraza with optional OWASP Core Rule Set. + + + {wafState?.message && ( + + {wafState.message} + + )} + } + label="Enable WAF globally" + /> + + + Engine Mode + + + } label="Off" /> + } label="Detection Only" /> + } label="On (Blocking)" /> + + + } + label={ + + Load OWASP Core Rule Set{" "} + + (covers SQLi, XSS, LFI, RCE — recommended) + + + } + /> + + + WAF audit events are stored for 90 days and viewable under WAF Events in the sidebar. + Set mode to Detection Only first to observe traffic before enabling blocking. + + + + + + + ); } diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 137bbd15..d9eea2e1 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -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 { + 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" }; + } +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 08d30451..dcd23d92 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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, diff --git a/app/(dashboard)/waf-events/WafEventsClient.tsx b/app/(dashboard)/waf-events/WafEventsClient.tsx new file mode 100644 index 00000000..ad2cca21 --- /dev/null +++ b/app/(dashboard)/waf-events/WafEventsClient.tsx @@ -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 = { + CRITICAL: "error", + ERROR: "error", + HIGH: "error", + WARNING: "warning", + NOTICE: "info", + INFO: "info", +}; + +function SeverityChip({ severity }: { severity: string | null }) { + if (!severity) return ; + const upper = severity.toUpperCase(); + const color = SEVERITY_COLOR[upper] ?? "default"; + return ; +} + +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 | 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) => ( + + {new Date(r.ts * 1000).toLocaleString()} + + ), + }, + { + id: "severity", + label: "Severity", + width: 110, + render: (r: WafEvent) => , + }, + { + id: "host", + label: "Host", + width: 200, + render: (r: WafEvent) => ( + + {r.host || } + + ), + }, + { + id: "clientIp", + label: "Client IP", + width: 160, + render: (r: WafEvent) => ( + + + {r.clientIp} + + {r.countryCode && ( + + )} + + ), + }, + { + id: "method", + label: "Method", + width: 80, + render: (r: WafEvent) => ( + + ), + }, + { + id: "uri", + label: "URI", + render: (r: WafEvent) => ( + + + {r.uri || } + + + ), + }, + { + id: "ruleId", + label: "Rule ID", + width: 90, + render: (r: WafEvent) => ( + + {r.ruleId ?? "—"} + + ), + }, + { + id: "ruleMessage", + label: "Rule Message", + render: (r: WafEvent) => ( + + + {r.ruleMessage ?? } + + + ), + }, + ]; + + return ( + + + WAF Events + + + Web Application Firewall detections and blocks. Events are retained for 90 days. + + + { setSearchTerm(e.target.value); updateSearch(e.target.value); }} + slotProps={{ + input: { startAdornment: }, + }} + size="small" + sx={{ maxWidth: 480 }} + /> + + + + ); +} diff --git a/app/(dashboard)/waf-events/page.tsx b/app/(dashboard)/waf-events/page.tsx new file mode 100644 index 00000000..9462d8a9 --- /dev/null +++ b/app/(dashboard)/waf-events/page.tsx @@ -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 ( + + ); +} diff --git a/app/api/waf-events/route.ts b/app/api/waf-events/route.ts new file mode 100644 index 00000000..52d13ae5 --- /dev/null +++ b/app/api/waf-events/route.ts @@ -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 }); + } +} diff --git a/docker/caddy/Dockerfile b/docker/caddy/Dockerfile index bc9949fb..3cd47eeb 100644 --- a/docker/caddy/Dockerfile +++ b/docker/caddy/Dockerfile @@ -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 diff --git a/drizzle/0010_waf.sql b/drizzle/0010_waf.sql new file mode 100644 index 00000000..2c5f49bd --- /dev/null +++ b/drizzle/0010_waf.sql @@ -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 +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e33ce34e..471181e4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1772129593846, "tag": "0009_watery_bill_hollister", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1772200000000, + "tag": "0010_waf", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index b0e83ae3..c5ba4620 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -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({ + ); @@ -231,6 +233,7 @@ export function EditHostDialog({ geoblock_mode: host.geoblock_mode, }} /> + ); diff --git a/src/components/proxy-hosts/WafFields.tsx b/src/components/proxy-hosts/WafFields.tsx new file mode 100644 index 00000000..3a8d649b --- /dev/null +++ b/src/components/proxy-hosts/WafFields.tsx @@ -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 ( + + }> + + + Web Application Firewall (WAF) + {enabled && ( + + {engineMode === "On" ? "Blocking" : engineMode === "DetectionOnly" ? "Detection Only" : "Off"} + + )} + + + + {/* Hidden marker so the server action knows WAF config was submitted */} + + + + + + + + {/* Enable toggle */} + setEnabled(checked)} + size="small" + /> + } + label="Enable WAF for this host" + /> + + {enabled && ( + <> + {/* Override mode */} + + + Override Mode + + v && setWafMode(v)} + size="small" + sx={{ mt: 0.5 }} + > + Merge with global + Override global + + + + {/* Engine mode */} + + + Engine Mode + + setEngineMode(e.target.value as "Off" | "DetectionOnly" | "On")} + > + } label="Off" /> + } label="Detection Only" /> + } label="On (Blocking)" /> + + + + {/* OWASP CRS */} + setLoadCrs(checked)} + size="small" + /> + } + label={ + + Load OWASP Core Rule Set{" "} + + (covers SQLi, XSS, LFI, RCE) + + + } + /> + + {/* Custom directives */} + 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 + /> + + )} + + + + ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9068767c..f34ddbad 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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 { diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index e2c2488b..32f78dd3 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -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 { + 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 = { + handler: 'waf', + directives, + }; + + if (waf.load_owasp_crs) { + handler.load_owasp_crs = true; + } + + return handler; +} + function buildBlockerHandler(config: GeoBlockSettings): Record { const handler: Record = { 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 } ); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 54495902..655823b4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), +}); diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index 269e62f3..2b8c2617 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -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): 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, }; } diff --git a/src/lib/models/waf-events.ts b/src/lib/models/waf-events.ts new file mode 100644 index 00000000..61d457e8 --- /dev/null +++ b/src/lib/models/waf-events.ts @@ -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 { + 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 { + 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, + })); +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 4c799400..3499ca41 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -210,3 +210,18 @@ export async function getGeoBlockSettings(): Promise { export async function saveGeoBlockSettings(settings: GeoBlockSettings): Promise { 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 { + return await getEffectiveSetting("waf"); +} + +export async function saveWafSettings(s: WafSettings): Promise { + await setSetting("waf", s); +} diff --git a/src/lib/waf-log-parser.ts b/src/lib/waf-log-parser.ts new file mode 100644 index 00000000..d5ecdaf8 --- /dev/null +++ b/src/lib/waf-log-parser.ts @@ -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>> | null = null; +const geoCache = new Map(); + +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 { + if (!existsSync(GEOIP_DB)) return; + try { + geoReader = await maxmind.open(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 { + await initGeoIP(); + console.log('[waf-log-parser] initialized'); +} + +export async function parseNewWafLogEntries(): Promise { + 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; +}