- 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>
853 lines
35 KiB
TypeScript
853 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useFormState } from "react-dom";
|
|
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, Stack, Switch, TextField, Typography } from "@mui/material";
|
|
import type {
|
|
GeneralSettings,
|
|
AuthentikSettings,
|
|
MetricsSettings,
|
|
LoggingSettings,
|
|
DnsSettings,
|
|
UpstreamDnsResolutionSettings,
|
|
GeoBlockSettings,
|
|
WafSettings
|
|
} from "@/src/lib/settings";
|
|
import { GeoBlockFields } from "@/src/components/proxy-hosts/GeoBlockFields";
|
|
import {
|
|
updateCloudflareSettingsAction,
|
|
updateGeneralSettingsAction,
|
|
updateAuthentikSettingsAction,
|
|
updateMetricsSettingsAction,
|
|
updateLoggingSettingsAction,
|
|
updateDnsSettingsAction,
|
|
updateUpstreamDnsResolutionSettingsAction,
|
|
updateInstanceModeAction,
|
|
updateSlaveMasterTokenAction,
|
|
createSlaveInstanceAction,
|
|
deleteSlaveInstanceAction,
|
|
toggleSlaveInstanceAction,
|
|
syncSlaveInstancesAction,
|
|
updateGeoBlockSettingsAction,
|
|
updateWafSettingsAction
|
|
} from "./actions";
|
|
|
|
type Props = {
|
|
general: GeneralSettings | null;
|
|
cloudflare: {
|
|
hasToken: boolean;
|
|
zoneId?: string;
|
|
accountId?: string;
|
|
};
|
|
authentik: AuthentikSettings | null;
|
|
metrics: MetricsSettings | null;
|
|
logging: LoggingSettings | null;
|
|
dns: DnsSettings | null;
|
|
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
|
|
globalGeoBlock?: GeoBlockSettings | null;
|
|
globalWaf?: WafSettings | null;
|
|
instanceSync: {
|
|
mode: "standalone" | "master" | "slave";
|
|
modeFromEnv: boolean;
|
|
tokenFromEnv: boolean;
|
|
overrides: {
|
|
general: boolean;
|
|
cloudflare: boolean;
|
|
authentik: boolean;
|
|
metrics: boolean;
|
|
logging: boolean;
|
|
dns: boolean;
|
|
upstreamDnsResolution: boolean;
|
|
};
|
|
slave: {
|
|
hasToken: boolean;
|
|
lastSyncAt: string | null;
|
|
lastSyncError: string | null;
|
|
} | null;
|
|
master: {
|
|
instances: Array<{
|
|
id: number;
|
|
name: string;
|
|
base_url: string;
|
|
enabled: boolean;
|
|
last_sync_at: string | null;
|
|
last_sync_error: string | null;
|
|
}>;
|
|
envInstances: Array<{
|
|
name: string;
|
|
url: string;
|
|
}>;
|
|
} | null;
|
|
};
|
|
};
|
|
|
|
export default function SettingsClient({
|
|
general,
|
|
cloudflare,
|
|
authentik,
|
|
metrics,
|
|
logging,
|
|
dns,
|
|
upstreamDnsResolution,
|
|
globalGeoBlock,
|
|
globalWaf,
|
|
instanceSync
|
|
}: Props) {
|
|
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
|
|
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null);
|
|
const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
|
|
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
|
|
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
|
|
const [dnsState, dnsFormAction] = useFormState(updateDnsSettingsAction, null);
|
|
const [upstreamDnsResolutionState, upstreamDnsResolutionFormAction] = useFormState(
|
|
updateUpstreamDnsResolutionSettingsAction,
|
|
null
|
|
);
|
|
const [instanceModeState, instanceModeFormAction] = useFormState(updateInstanceModeAction, null);
|
|
const [slaveTokenState, slaveTokenFormAction] = useFormState(updateSlaveMasterTokenAction, null);
|
|
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";
|
|
const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general);
|
|
const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare);
|
|
const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik);
|
|
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
|
|
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
|
|
const [dnsOverride, setDnsOverride] = useState(instanceSync.overrides.dns);
|
|
const [upstreamDnsResolutionOverride, setUpstreamDnsResolutionOverride] = useState(
|
|
instanceSync.overrides.upstreamDnsResolution
|
|
);
|
|
|
|
return (
|
|
<Stack spacing={4} sx={{ width: "100%" }}>
|
|
<Stack spacing={1}>
|
|
<Typography variant="h4" fontWeight={600}>
|
|
Settings
|
|
</Typography>
|
|
<Typography color="text.secondary">Configure organization-wide defaults and DNS automation.</Typography>
|
|
</Stack>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Instance Sync
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Choose whether this instance acts independently, pushes configuration to slave nodes, or pulls configuration from a master.
|
|
</Typography>
|
|
<Stack component="form" action={instanceModeFormAction} spacing={2}>
|
|
{instanceSync.modeFromEnv && (
|
|
<Alert severity="info">
|
|
Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime.
|
|
</Alert>
|
|
)}
|
|
{instanceModeState?.message && (
|
|
<Alert severity={instanceModeState.success ? "success" : "error"}>
|
|
{instanceModeState.message}
|
|
</Alert>
|
|
)}
|
|
<TextField
|
|
name="mode"
|
|
label="Instance mode"
|
|
select
|
|
defaultValue={instanceSync.mode}
|
|
disabled={instanceSync.modeFromEnv}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="standalone">Standalone</MenuItem>
|
|
<MenuItem value="master">Master</MenuItem>
|
|
<MenuItem value="slave">Slave</MenuItem>
|
|
</TextField>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained" disabled={instanceSync.modeFromEnv}>
|
|
Save instance mode
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{isSlave && (
|
|
<Stack spacing={2} sx={{ mt: 3 }}>
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
Master Connection
|
|
</Typography>
|
|
<Stack component="form" action={slaveTokenFormAction} spacing={2}>
|
|
{instanceSync.tokenFromEnv && (
|
|
<Alert severity="info">
|
|
Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime.
|
|
</Alert>
|
|
)}
|
|
{slaveTokenState?.message && (
|
|
<Alert severity={slaveTokenState.success ? "success" : "error"}>
|
|
{slaveTokenState.message}
|
|
</Alert>
|
|
)}
|
|
{instanceSync.slave?.hasToken && !instanceSync.tokenFromEnv && (
|
|
<Alert severity="info">
|
|
A master sync token is configured. Leave the token field blank to keep it, or select "Remove existing token" to delete it.
|
|
</Alert>
|
|
)}
|
|
<TextField
|
|
name="masterToken"
|
|
label="Master sync token"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder="Enter new token"
|
|
disabled={instanceSync.tokenFromEnv}
|
|
fullWidth
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox name="clearToken" />}
|
|
label="Remove existing token"
|
|
disabled={!instanceSync.slave?.hasToken || instanceSync.tokenFromEnv}
|
|
/>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained" disabled={instanceSync.tokenFromEnv}>
|
|
Save master token
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
<Alert severity={instanceSync.slave?.lastSyncError ? "warning" : "info"}>
|
|
{instanceSync.slave?.lastSyncAt
|
|
? `Last sync: ${instanceSync.slave.lastSyncAt}${instanceSync.slave.lastSyncError ? ` (${instanceSync.slave.lastSyncError})` : ""}`
|
|
: "No sync payload has been received yet."}
|
|
</Alert>
|
|
</Stack>
|
|
)}
|
|
|
|
{isMaster && (
|
|
<Stack spacing={2} sx={{ mt: 3 }}>
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
Slave Instances
|
|
</Typography>
|
|
<Stack component="form" action={slaveInstanceFormAction} spacing={2}>
|
|
{slaveInstanceState?.message && (
|
|
<Alert severity={slaveInstanceState.success ? "success" : "error"}>
|
|
{slaveInstanceState.message}
|
|
</Alert>
|
|
)}
|
|
<TextField name="name" label="Instance name" placeholder="Edge node EU-1" fullWidth />
|
|
<TextField name="baseUrl" label="Base URL" placeholder="https://slave-1.example.com" fullWidth />
|
|
<TextField name="apiToken" label="Slave API token" type="password" autoComplete="new-password" fullWidth />
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Add slave instance
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
|
|
<Stack component="form" action={syncFormAction} spacing={2}>
|
|
{syncState?.message && (
|
|
<Alert severity={syncState.success ? "success" : "warning"}>
|
|
{syncState.message}
|
|
</Alert>
|
|
)}
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="outlined">
|
|
Sync now
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{instanceSync.master?.instances.length === 0 && instanceSync.master?.envInstances.length === 0 && (
|
|
<Alert severity="info">No slave instances configured yet.</Alert>
|
|
)}
|
|
|
|
{instanceSync.master?.envInstances && instanceSync.master.envInstances.length > 0 && (
|
|
<>
|
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mt: 1 }}>
|
|
Environment-configured instances (via INSTANCE_SLAVES)
|
|
</Typography>
|
|
{instanceSync.master.envInstances.map((instance, index) => (
|
|
<Box
|
|
key={`env-${index}`}
|
|
sx={{
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
borderRadius: 2,
|
|
p: 2,
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 2,
|
|
bgcolor: "action.hover"
|
|
}}
|
|
>
|
|
<Box>
|
|
<Typography fontWeight={600}>{instance.name}</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{instance.url}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Configured via environment variable
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{instanceSync.master?.instances && instanceSync.master.instances.length > 0 && (
|
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mt: 1 }}>
|
|
UI-configured instances
|
|
</Typography>
|
|
)}
|
|
{instanceSync.master?.instances.map((instance) => (
|
|
<Box
|
|
key={instance.id}
|
|
sx={{
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
borderRadius: 2,
|
|
p: 2,
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 2
|
|
}}
|
|
>
|
|
<Box>
|
|
<Typography fontWeight={600}>{instance.name}</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{instance.base_url}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
|
|
</Typography>
|
|
{instance.last_sync_error && (
|
|
<Typography variant="caption" color="error" display="block">
|
|
{instance.last_sync_error}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<Stack direction="row" spacing={1}>
|
|
<Box component="form" action={toggleSlaveInstanceAction}>
|
|
<input type="hidden" name="instanceId" value={instance.id} />
|
|
<input type="hidden" name="enabled" value={instance.enabled ? "" : "on"} />
|
|
<Button type="submit" variant="outlined" color={instance.enabled ? "warning" : "success"}>
|
|
{instance.enabled ? "Disable" : "Enable"}
|
|
</Button>
|
|
</Box>
|
|
<Box component="form" action={deleteSlaveInstanceAction}>
|
|
<input type="hidden" name="instanceId" value={instance.id} />
|
|
<Button type="submit" variant="outlined" color="error">
|
|
Remove
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
General
|
|
</Typography>
|
|
<Stack component="form" action={generalFormAction} spacing={2}>
|
|
{generalState?.message && (
|
|
<Alert severity={generalState.success ? "success" : "error"}>
|
|
{generalState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={generalOverride}
|
|
onChange={(event) => setGeneralOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<TextField
|
|
name="primaryDomain"
|
|
label="Primary domain"
|
|
defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"}
|
|
required
|
|
disabled={isSlave && !generalOverride}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
name="acmeEmail"
|
|
label="ACME contact email"
|
|
type="email"
|
|
defaultValue={general?.acmeEmail ?? ""}
|
|
disabled={isSlave && !generalOverride}
|
|
fullWidth
|
|
/>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save general settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Cloudflare DNS
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates.
|
|
</Typography>
|
|
{cloudflare.hasToken && (
|
|
<Alert severity="info">
|
|
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
|
|
</Alert>
|
|
)}
|
|
<Stack component="form" action={cloudflareFormAction} spacing={2}>
|
|
{cloudflareState?.message && (
|
|
<Alert severity={cloudflareState.success ? "success" : "warning"}>
|
|
{cloudflareState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={cloudflareOverride}
|
|
onChange={(event) => setCloudflareOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<TextField
|
|
name="apiToken"
|
|
label="API token"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder="Enter new token"
|
|
disabled={isSlave && !cloudflareOverride}
|
|
fullWidth
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox name="clearToken" />}
|
|
label="Remove existing token"
|
|
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
|
|
/>
|
|
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} fullWidth />
|
|
<TextField name="accountId" label="Account ID" defaultValue={cloudflare.accountId ?? ""} disabled={isSlave && !cloudflareOverride} fullWidth />
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save Cloudflare settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
DNS Resolvers
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Configure custom DNS resolvers for ACME DNS-01 challenges. These resolvers will be used to verify DNS records during certificate issuance.
|
|
</Typography>
|
|
<Stack component="form" action={dnsFormAction} spacing={2}>
|
|
{dnsState?.message && (
|
|
<Alert severity={dnsState.success ? "success" : "error"}>
|
|
{dnsState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={dnsOverride}
|
|
onChange={(event) => setDnsOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Checkbox name="enabled" defaultChecked={dns?.enabled ?? false} disabled={isSlave && !dnsOverride} />}
|
|
label="Enable custom DNS resolvers"
|
|
/>
|
|
<TextField
|
|
name="resolvers"
|
|
label="Primary DNS Resolvers"
|
|
placeholder="1.1.1.1 8.8.8.8"
|
|
defaultValue={dns?.resolvers?.join("\n") ?? ""}
|
|
helperText="One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for ACME DNS verification."
|
|
multiline
|
|
minRows={2}
|
|
disabled={isSlave && !dnsOverride}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
name="fallbacks"
|
|
label="Fallback DNS Resolvers (Optional)"
|
|
placeholder="8.8.4.4 1.0.0.1"
|
|
defaultValue={dns?.fallbacks?.join("\n") ?? ""}
|
|
helperText="Fallback resolvers if primary fails. One per line."
|
|
multiline
|
|
minRows={2}
|
|
disabled={isSlave && !dnsOverride}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
name="timeout"
|
|
label="DNS Query Timeout"
|
|
placeholder="5s"
|
|
defaultValue={dns?.timeout ?? ""}
|
|
helperText="Timeout for DNS queries (e.g., 5s, 10s)"
|
|
disabled={isSlave && !dnsOverride}
|
|
fullWidth
|
|
/>
|
|
<Alert severity="info">
|
|
Custom DNS resolvers are useful when your DNS provider has slow propagation or when using split-horizon DNS.
|
|
Common public resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
|
|
</Alert>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save DNS settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Upstream DNS Pinning
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Optionally resolve upstream hostnames when applying config and pin reverse proxy upstream dials to IP addresses.
|
|
This can avoid runtime DNS churn and lets you force IPv6, IPv4, or both (IPv6 preferred).
|
|
</Typography>
|
|
<Stack component="form" action={upstreamDnsResolutionFormAction} spacing={2}>
|
|
{upstreamDnsResolutionState?.message && (
|
|
<Alert severity={upstreamDnsResolutionState.success ? "success" : "error"}>
|
|
{upstreamDnsResolutionState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={upstreamDnsResolutionOverride}
|
|
onChange={(event) => setUpstreamDnsResolutionOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Checkbox name="enabled" defaultChecked={upstreamDnsResolution?.enabled ?? false} disabled={isSlave && !upstreamDnsResolutionOverride} />}
|
|
label="Enable upstream DNS pinning during config apply"
|
|
/>
|
|
<TextField
|
|
name="family"
|
|
label="Address Family Preference"
|
|
select
|
|
defaultValue={upstreamDnsResolution?.family ?? "both"}
|
|
helperText="Both resolves AAAA + A with IPv6 preferred ordering."
|
|
disabled={isSlave && !upstreamDnsResolutionOverride}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="both">Both (Prefer IPv6)</MenuItem>
|
|
<MenuItem value="ipv6">IPv6 only</MenuItem>
|
|
<MenuItem value="ipv4">IPv4 only</MenuItem>
|
|
</TextField>
|
|
<Alert severity="info">
|
|
Host-level settings can override this default. Resolution happens at config save/reload time and resolved IPs are written into
|
|
Caddy's active config. If one handler has multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those
|
|
HTTPS upstreams to avoid SNI mismatch.
|
|
</Alert>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save upstream DNS pinning settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Authentik Defaults
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Set default Authentik forward authentication values. These will be pre-filled when creating new proxy hosts but can be customized per host.
|
|
</Typography>
|
|
<Stack component="form" action={authentikFormAction} spacing={2}>
|
|
{authentikState?.message && (
|
|
<Alert severity={authentikState.success ? "success" : "error"}>
|
|
{authentikState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={authentikOverride}
|
|
onChange={(event) => setAuthentikOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<TextField
|
|
name="outpostDomain"
|
|
label="Outpost Domain"
|
|
placeholder="outpost.goauthentik.io"
|
|
defaultValue={authentik?.outpostDomain ?? ""}
|
|
helperText="Authentik outpost domain"
|
|
required
|
|
disabled={isSlave && !authentikOverride}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
name="outpostUpstream"
|
|
label="Outpost Upstream"
|
|
placeholder="http://authentik-server:9000"
|
|
defaultValue={authentik?.outpostUpstream ?? ""}
|
|
helperText="Internal URL of Authentik outpost"
|
|
required
|
|
disabled={isSlave && !authentikOverride}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
name="authEndpoint"
|
|
label="Authpost Endpoint"
|
|
placeholder="/outpost.goauthentik.io/auth/caddy"
|
|
defaultValue={authentik?.authEndpoint ?? ""}
|
|
helperText="Authpost endpoint path"
|
|
disabled={isSlave && !authentikOverride}
|
|
fullWidth
|
|
/>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save Authentik defaults
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Metrics & Monitoring
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Enable Caddy metrics exposure for monitoring with Prometheus, Grafana, or other observability tools.
|
|
Metrics will be available at http://caddy:{metrics?.port ?? 9090}/metrics on a separate port (NOT the admin API port for security).
|
|
</Typography>
|
|
<Stack component="form" action={metricsFormAction} spacing={2}>
|
|
{metricsState?.message && (
|
|
<Alert severity={metricsState.success ? "success" : "warning"}>
|
|
{metricsState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={metricsOverride}
|
|
onChange={(event) => setMetricsOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Checkbox name="enabled" defaultChecked={metrics?.enabled ?? false} disabled={isSlave && !metricsOverride} />}
|
|
label="Enable metrics endpoint"
|
|
/>
|
|
<TextField
|
|
name="port"
|
|
label="Metrics Port"
|
|
type="number"
|
|
defaultValue={metrics?.port ?? 9090}
|
|
helperText="Port to expose metrics on (default: 9090, separate from admin API on 2019)"
|
|
disabled={isSlave && !metricsOverride}
|
|
fullWidth
|
|
/>
|
|
<Alert severity="info">
|
|
After enabling metrics, configure your monitoring tool to scrape http://caddy-proxy-manager-caddy:{metrics?.port ?? 9090}/metrics from within the Docker network.
|
|
To expose metrics externally, add a port mapping like "{metrics?.port ?? 9090}:{metrics?.port ?? 9090}" in docker-compose.yml.
|
|
</Alert>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save metrics settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Access Logging
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Enable HTTP access logging to track all requests going through your proxy hosts.
|
|
Logs will be stored in the caddy-logs directory and mounted at /logs/access.log inside the container.
|
|
</Typography>
|
|
<Stack component="form" action={loggingFormAction} spacing={2}>
|
|
{loggingState?.message && (
|
|
<Alert severity={loggingState.success ? "success" : "warning"}>
|
|
{loggingState.message}
|
|
</Alert>
|
|
)}
|
|
{isSlave && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
name="overrideEnabled"
|
|
checked={loggingOverride}
|
|
onChange={(event) => setLoggingOverride(event.target.checked)}
|
|
/>
|
|
}
|
|
label="Override master settings"
|
|
/>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Checkbox name="enabled" defaultChecked={logging?.enabled ?? false} disabled={isSlave && !loggingOverride} />}
|
|
label="Enable access logging"
|
|
/>
|
|
<TextField
|
|
name="format"
|
|
label="Log Format"
|
|
select
|
|
defaultValue={logging?.format ?? "json"}
|
|
helperText="Format for access logs"
|
|
disabled={isSlave && !loggingOverride}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="json">JSON</MenuItem>
|
|
<MenuItem value="console">Console (Common Log Format)</MenuItem>
|
|
</TextField>
|
|
<Alert severity="info">
|
|
Access logs are stored in the caddy-logs Docker volume.
|
|
You can view them with: docker exec caddy-proxy-manager-caddy tail -f /logs/access.log
|
|
</Alert>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save logging settings
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Global Geoblocking
|
|
</Typography>
|
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
|
Configure default geoblocking rules applied to all proxy hosts. Per-host rules can merge with or override these global defaults.
|
|
</Typography>
|
|
<Stack component="form" action={geoBlockFormAction} spacing={2}>
|
|
{geoBlockState?.message && (
|
|
<Alert severity={geoBlockState.success ? "success" : "error"}>
|
|
{geoBlockState.message}
|
|
</Alert>
|
|
)}
|
|
<GeoBlockFields
|
|
initialValues={{ geoblock: globalGeoBlock ?? null, geoblock_mode: "merge" }}
|
|
showModeSelector={false}
|
|
/>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">
|
|
Save geoblocking settings
|
|
</Button>
|
|
</Box>
|
|
</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>
|
|
);
|
|
}
|