deprecate deadhosts, move it to the GUI as a custom response feature

This commit is contained in:
fuomag9
2026-02-07 00:51:48 +01:00
parent 8b7982059a
commit 6d56cf2288
17 changed files with 1403 additions and 497 deletions

View File

@@ -23,7 +23,6 @@ import MenuIcon from "@mui/icons-material/Menu";
import DashboardIcon from "@mui/icons-material/Dashboard";
import DnsIcon from "@mui/icons-material/Dns";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ReportProblemIcon from "@mui/icons-material/ReportProblem";
import SecurityIcon from "@mui/icons-material/Security";
import ShieldIcon from "@mui/icons-material/Shield";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -41,7 +40,6 @@ const NAV_ITEMS = [
{ href: "/", label: "Overview", icon: DashboardIcon },
{ href: "/proxy-hosts", label: "Proxy Hosts", icon: DnsIcon },
{ href: "/redirects", label: "Redirects", icon: SwapHorizIcon },
{ href: "/dead-hosts", label: "Dead Hosts", icon: ReportProblemIcon },
{ href: "/access-lists", label: "Access Lists", icon: SecurityIcon },
{ href: "/certificates", label: "Certificates", icon: ShieldIcon },
{ href: "/settings", label: "Settings", icon: SettingsIcon },

View File

@@ -1,160 +0,0 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
Stack,
Switch,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { DeadHost } from "@/src/lib/models/dead-hosts";
import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction, toggleDeadHostAction } from "./actions";
type Props = {
hosts: DeadHost[];
};
export default function DeadHostsClient({ hosts }: Props) {
const handleToggleEnabled = async (id: number, enabled: boolean) => {
await toggleDeadHostAction(id, enabled);
};
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Dead Hosts
</Typography>
<Typography color="text.secondary">Serve friendly status pages for domains without upstreams.</Typography>
</Stack>
<Stack spacing={3}>
{hosts.map((host) => (
<Card key={host.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{host.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{host.domains.join(", ")}
</Typography>
</Box>
<Switch
checked={host.enabled}
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
color="success"
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateDeadHostAction(host.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={host.name} fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={host.domains.join("\n")}
multiline
minRows={2}
fullWidth
/>
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 599 }}
defaultValue={host.status_code}
fullWidth
/>
<TextField
name="response_body"
label="Response body"
defaultValue={host.response_body ?? ""}
multiline
minRows={3}
fullWidth
/>
<Box>
<input type="hidden" name="enabled_present" value="1" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked={host.enabled} />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteDeadHostAction.bind(null, host.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create dead host
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createDeadHostAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Maintenance page" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="offline.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 599 }}
defaultValue={503}
fullWidth
/>
<TextField
name="response_body"
label="Response body"
placeholder="Service unavailable"
multiline
minRows={3}
fullWidth
/>
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Dead Host
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -1,69 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts";
import { actionSuccess, actionError, type ActionState } from "@/src/lib/actions";
function parseDomains(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export async function createDeadHostAction(formData: FormData) {
const session = await requireAdmin();
const userId = Number(session.user.id);
await createDeadHost(
{
name: String(formData.get("name") ?? "Dead host"),
domains: parseDomains(formData.get("domains")),
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : 503,
response_body: formData.get("response_body") ? String(formData.get("response_body")) : null,
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
userId
);
revalidatePath("/dead-hosts");
}
export async function updateDeadHostAction(id: number, formData: FormData) {
const session = await requireAdmin();
const userId = Number(session.user.id);
await updateDeadHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseDomains(formData.get("domains")) : undefined,
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : undefined,
response_body: formData.get("response_body") ? String(formData.get("response_body")) : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
userId
);
revalidatePath("/dead-hosts");
}
export async function deleteDeadHostAction(id: number) {
const session = await requireAdmin();
const userId = Number(session.user.id);
await deleteDeadHost(id, userId);
revalidatePath("/dead-hosts");
}
export async function toggleDeadHostAction(id: number, enabled: boolean): Promise<ActionState> {
try {
const session = await requireAdmin();
const userId = Number(session.user.id);
await updateDeadHost(id, { enabled }, userId);
revalidatePath("/dead-hosts");
return actionSuccess(`Dead host ${enabled ? "enabled" : "disabled"}.`);
} catch (error) {
return actionError(error, "Failed to toggle dead host");
}
}

View File

@@ -1,9 +0,0 @@
import DeadHostsClient from "./DeadHostsClient";
import { listDeadHosts } from "@/src/lib/models/dead-hosts";
import { requireAdmin } from "@/src/lib/auth";
export default async function DeadHostsPage() {
await requireAdmin();
const hosts = await listDeadHosts();
return <DeadHostsClient hosts={hosts} />;
}

View File

@@ -5,14 +5,12 @@ import {
accessLists,
auditEvents,
certificates,
deadHosts,
proxyHosts,
redirectHosts
} from "@/src/lib/db/schema";
import { count, desc } from "drizzle-orm";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import TurnRightIcon from "@mui/icons-material/TurnRight";
import BlockIcon from "@mui/icons-material/Block";
import SecurityIcon from "@mui/icons-material/Security";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import { ReactNode } from "react";
@@ -25,24 +23,21 @@ type StatCard = {
};
async function loadStats(): Promise<StatCard[]> {
const [proxyHostCountResult, redirectHostCountResult, deadHostCountResult, certificateCountResult, accessListCountResult] =
const [proxyHostCountResult, redirectHostCountResult, certificateCountResult, accessListCountResult] =
await Promise.all([
db.select({ value: count() }).from(proxyHosts),
db.select({ value: count() }).from(redirectHosts),
db.select({ value: count() }).from(deadHosts),
db.select({ value: count() }).from(certificates),
db.select({ value: count() }).from(accessLists)
]);
const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0;
const redirectHostsCount = redirectHostCountResult[0]?.value ?? 0;
const deadHostsCount = deadHostCountResult[0]?.value ?? 0;
const certificatesCount = certificateCountResult[0]?.value ?? 0;
const accessListsCount = accessListCountResult[0]?.value ?? 0;
return [
{ label: "Proxy Hosts", icon: <SwapHorizIcon fontSize="large" />, count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Redirects", icon: <TurnRightIcon fontSize="large" />, count: redirectHostsCount, href: "/redirects" },
{ label: "Dead Hosts", icon: <BlockIcon fontSize="large" />, count: deadHostsCount, href: "/dead-hosts" },
{ label: "Certificates", icon: <SecurityIcon fontSize="large" />, count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: <VpnKeyIcon fontSize="large" />, count: accessListsCount, href: "/access-lists" }
];

View File

@@ -82,12 +82,18 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut
},
{
id: "upstreams",
label: "Upstreams",
label: "Target",
render: (host: ProxyHost) => (
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{host.upstreams[0]}
{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
</Typography>
host.response_mode === "static" ? (
<Typography variant="body2" color="warning.main" sx={{ fontStyle: 'italic' }}>
Static Response ({host.static_status_code ?? 503})
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{host.upstreams[0]}
{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
</Typography>
)
)
},
{

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput, type LoadBalancerInput, type LoadBalancingPolicy, type DnsResolverInput } from "@/src/lib/models/proxy-hosts";
import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput, type LoadBalancerInput, type LoadBalancingPolicy, type DnsResolverInput, type ResponseMode } from "@/src/lib/models/proxy-hosts";
import { getCertificate } from "@/src/lib/models/certificates";
import { getCloudflareSettings } from "@/src/lib/settings";
@@ -270,6 +270,28 @@ function parseLoadBalancerConfig(formData: FormData): LoadBalancerInput | undefi
return Object.keys(result).length > 0 ? result : undefined;
}
function parseResponseMode(value: FormDataEntryValue | null): ResponseMode {
if (!value || typeof value !== "string") {
return "proxy";
}
return value === "static" ? "static" : "proxy";
}
function parseStatusCode(value: FormDataEntryValue | null): number | null {
if (!value || typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed === "") {
return null;
}
const num = parseInt(trimmed, 10);
if (!Number.isFinite(num) || num < 100 || num > 599) {
return 200;
}
return num;
}
function parseDnsResolverConfig(formData: FormData): DnsResolverInput | undefined {
if (!formData.has("dns_present")) {
return undefined;
@@ -348,6 +370,8 @@ export async function createProxyHostAction(
console.warn(`[createProxyHostAction] ${warning}`);
}
const responseMode = parseResponseMode(formData.get("response_mode"));
await createProxyHost(
{
name: String(formData.get("name") ?? "Untitled"),
@@ -362,7 +386,10 @@ export async function createProxyHostAction(
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData)
dns_resolver: parseDnsResolverConfig(formData),
response_mode: responseMode,
static_status_code: parseStatusCode(formData.get("static_status_code")),
static_response_body: parseOptionalText(formData.get("static_response_body"))
},
userId
);
@@ -410,6 +437,8 @@ export async function updateProxyHostAction(
}
}
const responseMode = formData.has("response_mode") ? parseResponseMode(formData.get("response_mode")) : undefined;
await updateProxyHost(
id,
{
@@ -431,7 +460,10 @@ export async function updateProxyHostAction(
: undefined,
authentik: parseAuthentikConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData)
dns_resolver: parseDnsResolverConfig(formData),
response_mode: responseMode,
static_status_code: formData.has("static_status_code") ? parseStatusCode(formData.get("static_status_code")) : undefined,
static_response_body: formData.has("static_response_body") ? parseOptionalText(formData.get("static_response_body")) : undefined
},
userId
);

View File

@@ -138,7 +138,10 @@ function isProxyHost(value: unknown): value is SyncPayload["data"]["proxyHosts"]
isBoolean(value.enabled) &&
isString(value.createdAt) &&
isString(value.updatedAt) &&
isBoolean(value.skipHttpsHostnameValidation)
isBoolean(value.skipHttpsHostnameValidation) &&
isString(value.responseMode) &&
isNullableNumber(value.staticStatusCode) &&
isNullableString(value.staticResponseBody)
);
}
@@ -158,21 +161,6 @@ function isRedirectHost(value: unknown): value is SyncPayload["data"]["redirectH
);
}
function isDeadHost(value: unknown): value is SyncPayload["data"]["deadHosts"][number] {
if (!isRecord(value)) return false;
return (
isNumber(value.id) &&
isString(value.name) &&
isString(value.domains) &&
isNumber(value.statusCode) &&
isNullableString(value.responseBody) &&
isBoolean(value.enabled) &&
isNullableNumber(value.createdBy) &&
isString(value.createdAt) &&
isString(value.updatedAt)
);
}
/**
* Validates that the payload has the expected structure for syncing
*/
@@ -210,8 +198,7 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload {
validateArray(d.accessLists, isAccessList) &&
validateArray(d.accessListEntries, isAccessListEntry) &&
validateArray(d.proxyHosts, isProxyHost) &&
validateArray(d.redirectHosts, isRedirectHost) &&
validateArray(d.deadHosts, isDeadHost)
validateArray(d.redirectHosts, isRedirectHost)
);
}

View File

@@ -0,0 +1,4 @@
DROP TABLE `dead_hosts`;--> statement-breakpoint
ALTER TABLE `proxy_hosts` ADD `response_mode` text DEFAULT 'proxy' NOT NULL;--> statement-breakpoint
ALTER TABLE `proxy_hosts` ADD `static_status_code` integer DEFAULT 200;--> statement-breakpoint
ALTER TABLE `proxy_hosts` ADD `static_response_body` text;

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1769262874211,
"tag": "0003_instances",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1770395358533,
"tag": "0004_slimy_grim_reaper",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,7 @@
import { Alert, Box, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { Alert, Box, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
import { useFormState } from "react-dom";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import {
createProxyHostAction,
deleteProxyHostAction,
@@ -10,7 +10,7 @@ import {
import { INITIAL_ACTION_STATE } from "@/src/lib/actions";
import { AccessList } from "@/src/lib/models/access-lists";
import { Certificate } from "@/src/lib/models/certificates";
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { ProxyHost, ResponseMode } from "@/src/lib/models/proxy-hosts";
import { AuthentikSettings } from "@/src/lib/settings";
import { AppDialog } from "@/src/components/ui/AppDialog";
import { AuthentikFields } from "./AuthentikFields";
@@ -35,6 +35,7 @@ export function CreateHostDialog({
initialData?: ProxyHost | null;
}) {
const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE);
const [responseMode, setResponseMode] = useState<ResponseMode>(initialData?.response_mode ?? "proxy");
useEffect(() => {
if (state.status === "success") {
@@ -42,6 +43,13 @@ export function CreateHostDialog({
}
}, [state.status, onClose]);
// Reset response mode when dialog opens/closes
useEffect(() => {
if (open) {
setResponseMode(initialData?.response_mode ?? "proxy");
}
}, [open, initialData]);
return (
<AppDialog
open={open}
@@ -84,7 +92,43 @@ export function CreateHostDialog({
required
fullWidth
/>
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
<FormControl component="fieldset">
<FormLabel component="legend">Response Mode</FormLabel>
<RadioGroup
row
name="response_mode"
value={responseMode}
onChange={(e) => setResponseMode(e.target.value as ResponseMode)}
>
<FormControlLabel value="proxy" control={<Radio />} label="Proxy to Upstream" />
<FormControlLabel value="static" control={<Radio />} label="Static Response" />
</RadioGroup>
</FormControl>
{responseMode === "proxy" && (
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
)}
{responseMode === "static" && (
<>
<TextField
name="static_status_code"
label="Status Code"
type="number"
defaultValue={initialData?.static_status_code ?? 200}
helperText="HTTP status code to return (e.g., 200 for OK, 503 for Service Unavailable)"
fullWidth
/>
<TextField
name="static_response_body"
label="Response Body"
placeholder=""
defaultValue={initialData?.static_response_body ?? ""}
helperText="Optional body text to return in the response"
multiline
minRows={3}
fullWidth
/>
</>
)}
<TextField select name="certificate_id" label="Certificate" defaultValue={initialData?.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
@@ -116,7 +160,7 @@ export function CreateHostDialog({
label="Custom Reverse Proxy (JSON)"
placeholder='{"headers": {"request": {...}}}'
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
helperText="Deep-merge into reverse_proxy handler"
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
multiline
minRows={3}
fullWidth
@@ -143,6 +187,7 @@ export function EditHostDialog({
accessLists: AccessList[];
}) {
const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
const [responseMode, setResponseMode] = useState<ResponseMode>(host.response_mode);
useEffect(() => {
if (state.status === "success") {
@@ -150,6 +195,11 @@ export function EditHostDialog({
}
}, [state.status, onClose]);
// Reset response mode when host changes
useEffect(() => {
setResponseMode(host.response_mode);
}, [host]);
return (
<AppDialog
open={open}
@@ -182,7 +232,43 @@ export function EditHostDialog({
minRows={2}
fullWidth
/>
<UpstreamInput defaultUpstreams={host.upstreams} />
<FormControl component="fieldset">
<FormLabel component="legend">Response Mode</FormLabel>
<RadioGroup
row
name="response_mode"
value={responseMode}
onChange={(e) => setResponseMode(e.target.value as ResponseMode)}
>
<FormControlLabel value="proxy" control={<Radio />} label="Proxy to Upstream" />
<FormControlLabel value="static" control={<Radio />} label="Static Response" />
</RadioGroup>
</FormControl>
{responseMode === "proxy" && (
<UpstreamInput defaultUpstreams={host.upstreams} />
)}
{responseMode === "static" && (
<>
<TextField
name="static_status_code"
label="Status Code"
type="number"
defaultValue={host.static_status_code ?? 200}
helperText="HTTP status code to return (e.g., 200 for OK, 503 for Service Unavailable)"
fullWidth
/>
<TextField
name="static_response_body"
label="Response Body"
placeholder=""
defaultValue={host.static_response_body ?? ""}
helperText="Optional body text to return in the response"
multiline
minRows={3}
fullWidth
/>
</>
)}
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
@@ -212,7 +298,7 @@ export function EditHostDialog({
name="custom_reverse_proxy_json"
label="Custom Reverse Proxy (JSON)"
defaultValue={host.custom_reverse_proxy_json ?? ""}
helperText="Deep-merge into reverse_proxy handler"
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
multiline
minRows={3}
fullWidth

View File

@@ -8,7 +8,6 @@ import { syncInstances } from "./instance-sync";
import {
accessListEntries,
certificates,
deadHosts,
proxyHosts,
redirectHosts
} from "./db/schema";
@@ -48,6 +47,9 @@ type ProxyHostRow = {
skip_https_hostname_validation: number;
meta: string | null;
enabled: number;
response_mode: string;
static_status_code: number | null;
static_response_body: string | null;
};
type DnsResolverMeta = {
@@ -155,15 +157,6 @@ type RedirectHostRow = {
enabled: number;
};
type DeadHostRow = {
id: number;
name: string;
domains: string;
status_code: number;
response_body: string | null;
enabled: number;
};
type AccessListEntryRow = {
access_list_id: number;
username: string;
@@ -332,6 +325,59 @@ function buildProxyRoutes(
if (domains.length === 0) {
continue;
}
// Handle static response mode
if (row.response_mode === "static") {
const staticHandlers: Record<string, unknown>[] = [];
// Build static response handler
const staticResponseHandler: Record<string, unknown> = {
handler: "static_response",
status_code: row.static_status_code ?? 200,
body: row.static_response_body ?? ""
};
// Add HSTS header if enabled
if (row.hsts_enabled) {
const hstsValue = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
staticResponseHandler.headers = {
"Strict-Transport-Security": [hstsValue]
};
}
staticHandlers.push(staticResponseHandler);
// SSL redirect for static responses
if (row.ssl_forced) {
routes.push({
match: [
{
host: domains,
expression: '{http.request.scheme} == "http"'
}
],
handle: [
{
handler: "static_response",
status_code: 308,
headers: {
Location: ["https://{http.request.host}{http.request.uri}"]
}
}
],
terminal: true
});
}
routes.push({
match: [{ host: domains }],
handle: staticHandlers,
terminal: true
});
continue;
}
// Proxy mode: require upstreams
const upstreams = parseJson<string[]>(row.upstreams, []);
if (upstreams.length === 0) {
continue;
@@ -720,25 +766,6 @@ function buildRedirectRoutes(rows: RedirectHostRow[]): CaddyHttpRoute[] {
});
}
function buildDeadRoutes(rows: DeadHostRow[]): CaddyHttpRoute[] {
return rows
.filter((row) => Boolean(row.enabled))
.map((row) => ({
match: [{ host: parseJson<string[]>(row.domains, []) }],
handle: [
{
handler: "static_response",
status_code: row.status_code,
body: row.response_body ?? "Service unavailable",
headers: {
"Strict-Transport-Security": ["max-age=63072000"]
}
}
],
terminal: true
}));
}
function buildTlsConnectionPolicies(
usage: Map<number, CertificateUsage>,
managedCertificatesWithAutomation: Set<number>,
@@ -934,7 +961,7 @@ async function buildTlsAutomation(
}
async function buildCaddyDocument() {
const [proxyHostRecords, redirectHostRecords, deadHostRecords, certRows, accessListEntryRecords] = await Promise.all([
const [proxyHostRecords, redirectHostRecords, certRows, accessListEntryRecords] = await Promise.all([
db
.select({
id: proxyHosts.id,
@@ -950,7 +977,10 @@ async function buildCaddyDocument() {
preserveHostHeader: proxyHosts.preserveHostHeader,
skipHttpsHostnameValidation: proxyHosts.skipHttpsHostnameValidation,
meta: proxyHosts.meta,
enabled: proxyHosts.enabled
enabled: proxyHosts.enabled,
responseMode: proxyHosts.responseMode,
staticStatusCode: proxyHosts.staticStatusCode,
staticResponseBody: proxyHosts.staticResponseBody
})
.from(proxyHosts),
db
@@ -964,16 +994,6 @@ async function buildCaddyDocument() {
enabled: redirectHosts.enabled
})
.from(redirectHosts),
db
.select({
id: deadHosts.id,
name: deadHosts.name,
domains: deadHosts.domains,
statusCode: deadHosts.statusCode,
responseBody: deadHosts.responseBody,
enabled: deadHosts.enabled
})
.from(deadHosts),
db
.select({
id: certificates.id,
@@ -1009,7 +1029,10 @@ async function buildCaddyDocument() {
preserve_host_header: h.preserveHostHeader ? 1 : 0,
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
meta: h.meta,
enabled: h.enabled ? 1 : 0
enabled: h.enabled ? 1 : 0,
response_mode: h.responseMode,
static_status_code: h.staticStatusCode,
static_response_body: h.staticResponseBody
}));
const redirectHostRows: RedirectHostRow[] = redirectHostRecords.map((h) => ({
@@ -1022,15 +1045,6 @@ async function buildCaddyDocument() {
enabled: h.enabled ? 1 : 0
}));
const deadHostRows: DeadHostRow[] = deadHostRecords.map((h) => ({
id: h.id,
name: h.name,
domains: h.domains,
status_code: h.statusCode,
response_body: h.responseBody,
enabled: h.enabled ? 1 : 0
}));
const certRowsMapped: CertificateRow[] = certRows.map((c: typeof certRows[0]) => ({
id: c.id,
name: c.name,
@@ -1070,8 +1084,7 @@ async function buildCaddyDocument() {
const httpRoutes: CaddyHttpRoute[] = [
...buildProxyRoutes(proxyHostRows, accessMap, readyCertificates, autoManagedDomains),
...buildRedirectRoutes(redirectHostRows),
...buildDeadRoutes(deadHostRows)
...buildRedirectRoutes(redirectHostRows)
];
const hasTls = tlsConnectionPolicies.length > 0;

View File

@@ -147,7 +147,12 @@ export const proxyHosts = sqliteTable("proxy_hosts", {
updatedAt: text("updated_at").notNull(),
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
// Response mode: 'proxy' (default) or 'static'
responseMode: text("response_mode").notNull().default("proxy"),
// Static response fields (used when responseMode is 'static')
staticStatusCode: integer("static_status_code").default(200),
staticResponseBody: text("static_response_body")
});
export const redirectHosts = sqliteTable("redirect_hosts", {
@@ -163,18 +168,6 @@ export const redirectHosts = sqliteTable("redirect_hosts", {
updatedAt: text("updated_at").notNull()
});
export const deadHosts = sqliteTable("dead_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
domains: text("domains").notNull(),
statusCode: integer("status_code").notNull().default(503),
responseBody: text("response_body"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const apiTokens = sqliteTable(
"api_tokens",
{

View File

@@ -1,5 +1,5 @@
import db, { nowIso } from "./db";
import { accessListEntries, accessLists, certificates, deadHosts, proxyHosts, redirectHosts } from "./db/schema";
import { accessListEntries, accessLists, certificates, proxyHosts, redirectHosts } from "./db/schema";
import { getSetting, setSetting } from "./settings";
import { recordInstanceSyncResult, updateInstance } from "./models/instances";
import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret";
@@ -24,7 +24,6 @@ export type SyncPayload = {
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
redirectHosts: Array<typeof redirectHosts.$inferSelect>;
deadHosts: Array<typeof deadHosts.$inferSelect>;
};
};
@@ -230,13 +229,12 @@ export async function clearSyncedSetting(key: string): Promise<void> {
}
export async function buildSyncPayload(): Promise<SyncPayload> {
const [certRows, accessListRows, accessEntryRows, proxyRows, redirectRows, deadRows] = await Promise.all([
const [certRows, accessListRows, accessEntryRows, proxyRows, redirectRows] = await Promise.all([
db.select().from(certificates),
db.select().from(accessLists),
db.select().from(accessListEntries),
db.select().from(proxyHosts),
db.select().from(redirectHosts),
db.select().from(deadHosts)
db.select().from(redirectHosts)
]);
const settings = {
@@ -263,11 +261,6 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
createdBy: null
}));
const sanitizedDeadHosts = deadRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedProxyHosts = proxyRows.map((row) => ({
...row,
ownerUserId: null
@@ -281,8 +274,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
accessLists: sanitizedAccessLists,
accessListEntries: accessEntryRows,
proxyHosts: sanitizedProxyHosts,
redirectHosts: sanitizedRedirects,
deadHosts: sanitizedDeadHosts
redirectHosts: sanitizedRedirects
}
};
}
@@ -416,7 +408,6 @@ export async function applySyncPayload(payload: SyncPayload) {
db.transaction((tx) => {
tx.delete(proxyHosts).run();
tx.delete(redirectHosts).run();
tx.delete(deadHosts).run();
tx.delete(accessListEntries).run();
tx.delete(accessLists).run();
tx.delete(certificates).run();
@@ -436,8 +427,5 @@ export async function applySyncPayload(payload: SyncPayload) {
if (payload.data.redirectHosts.length > 0) {
tx.insert(redirectHosts).values(payload.data.redirectHosts).run();
}
if (payload.data.deadHosts.length > 0) {
tx.insert(deadHosts).values(payload.data.deadHosts).run();
}
});
}

View File

@@ -1,129 +0,0 @@
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { deadHosts } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type DeadHost = {
id: number;
name: string;
domains: string[];
status_code: number;
response_body: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type DeadHostInput = {
name: string;
domains: string[];
status_code?: number;
response_body?: string | null;
enabled?: boolean;
};
type DeadHostRow = typeof deadHosts.$inferSelect;
function parse(row: DeadHostRow): DeadHost {
return {
id: row.id,
name: row.name,
domains: JSON.parse(row.domains),
status_code: row.statusCode,
response_body: row.responseBody,
enabled: row.enabled,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listDeadHosts(): Promise<DeadHost[]> {
const hosts = await db.select().from(deadHosts).orderBy(desc(deadHosts.createdAt));
return hosts.map(parse);
}
export async function getDeadHost(id: number): Promise<DeadHost | null> {
const host = await db.query.deadHosts.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return host ? parse(host) : null;
}
export async function createDeadHost(input: DeadHostInput, actorUserId: number) {
if (!input.domains || input.domains.length === 0) {
throw new Error("At least one domain is required");
}
const now = nowIso();
const [record] = await db
.insert(deadHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
statusCode: input.status_code ?? 503,
responseBody: input.response_body ?? null,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
})
.returning();
if (!record) {
throw new Error("Failed to create dead host");
}
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "dead_host",
entityId: record.id,
summary: `Created dead host ${input.name}`
});
await applyCaddyConfig();
return (await getDeadHost(record.id))!;
}
export async function updateDeadHost(id: number, input: Partial<DeadHostInput>, actorUserId: number) {
const existing = await getDeadHost(id);
if (!existing) {
throw new Error("Dead host not found");
}
const now = nowIso();
await db
.update(deadHosts)
.set({
name: input.name ?? existing.name,
domains: JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
statusCode: input.status_code ?? existing.status_code,
responseBody: input.response_body ?? existing.response_body,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
})
.where(eq(deadHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "dead_host",
entityId: id,
summary: `Updated dead host ${input.name ?? existing.name}`
});
await applyCaddyConfig();
return (await getDeadHost(id))!;
}
export async function deleteDeadHost(id: number, actorUserId: number) {
const existing = await getDeadHost(id);
if (!existing) {
throw new Error("Dead host not found");
}
await db.delete(deadHosts).where(eq(deadHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "dead_host",
entityId: id,
summary: `Deleted dead host ${existing.name}`
});
await applyCaddyConfig();
}

View File

@@ -176,6 +176,8 @@ type ProxyHostMeta = {
dns_resolver?: DnsResolverMeta;
};
export type ResponseMode = "proxy" | "static";
export type ProxyHost = {
id: number;
name: string;
@@ -197,12 +199,15 @@ export type ProxyHost = {
authentik: ProxyHostAuthentikConfig | null;
load_balancer: LoadBalancerConfig | null;
dns_resolver: DnsResolverConfig | null;
response_mode: ResponseMode;
static_status_code: number | null;
static_response_body: string | null;
};
export type ProxyHostInput = {
name: string;
domains: string[];
upstreams: string[];
upstreams?: string[];
certificate_id?: number | null;
access_list_id?: number | null;
ssl_forced?: boolean;
@@ -217,6 +222,9 @@ export type ProxyHostInput = {
authentik?: ProxyHostAuthentikInput | null;
load_balancer?: LoadBalancerInput | null;
dns_resolver?: DnsResolverInput | null;
response_mode?: ResponseMode;
static_status_code?: number | null;
static_response_body?: string | null;
};
type ProxyHostRow = typeof proxyHosts.$inferSelect;
@@ -1133,6 +1141,7 @@ function dehydrateDnsResolver(config: DnsResolverConfig | null): DnsResolverMeta
function parseProxyHost(row: ProxyHostRow): ProxyHost {
const meta = parseMeta(row.meta ?? null);
const responseMode = row.responseMode === "static" ? "static" : "proxy";
return {
id: row.id,
name: row.name,
@@ -1153,7 +1162,10 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
authentik: hydrateAuthentik(meta.authentik),
load_balancer: hydrateLoadBalancer(meta.load_balancer),
dns_resolver: hydrateDnsResolver(meta.dns_resolver)
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
response_mode: responseMode,
static_status_code: row.staticStatusCode ?? null,
static_response_body: row.staticResponseBody ?? null
};
}
@@ -1166,18 +1178,23 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
if (!input.domains || input.domains.length === 0) {
throw new Error("At least one domain must be specified");
}
if (!input.upstreams || input.upstreams.length === 0) {
throw new Error("At least one upstream must be specified");
const responseMode = input.response_mode === "static" ? "static" : "proxy";
// Only require upstreams in proxy mode
if (responseMode === "proxy" && (!input.upstreams || input.upstreams.length === 0)) {
throw new Error("At least one upstream must be specified for proxy mode");
}
const now = nowIso();
const meta = buildMeta({}, input);
const upstreams = input.upstreams ?? [];
const [record] = await db
.insert(proxyHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
upstreams: JSON.stringify(Array.from(new Set(upstreams.map((u) => u.trim())))),
certificateId: input.certificate_id ?? null,
accessListId: input.access_list_id ?? null,
ownerUserId: actorUserId,
@@ -1190,7 +1207,10 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? false,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now
updatedAt: now,
responseMode,
staticStatusCode: input.static_status_code ?? 200,
staticResponseBody: input.static_response_body ?? null
})
.returning();
@@ -1224,6 +1244,10 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
throw new Error("Proxy host not found");
}
const responseMode = input.response_mode !== undefined
? (input.response_mode === "static" ? "static" : "proxy")
: existing.response_mode;
const domains = input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains);
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
const existingMeta: ProxyHostMeta = {
@@ -1252,7 +1276,10 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
meta,
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
updatedAt: now,
responseMode,
staticStatusCode: input.static_status_code !== undefined ? input.static_status_code : existing.static_status_code,
staticResponseBody: input.static_response_body !== undefined ? input.static_response_body : existing.static_response_body
})
.where(eq(proxyHosts.id, id));