diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx
index 20f9cee4..0716b73e 100644
--- a/app/(dashboard)/DashboardLayoutClient.tsx
+++ b/app/(dashboard)/DashboardLayoutClient.tsx
@@ -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 },
diff --git a/app/(dashboard)/dead-hosts/DeadHostsClient.tsx b/app/(dashboard)/dead-hosts/DeadHostsClient.tsx
deleted file mode 100644
index 51585ab3..00000000
--- a/app/(dashboard)/dead-hosts/DeadHostsClient.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Dead Hosts
-
- Serve friendly status pages for domains without upstreams.
-
-
-
- {hosts.map((host) => (
-
-
-
-
-
- {host.name}
-
-
- {host.domains.join(", ")}
-
-
- handleToggleEnabled(host.id, e.target.checked)}
- color="success"
- />
-
-
-
- } sx={{ px: 0 }}>
- Edit
-
-
- updateDeadHostAction(host.id, formData)} spacing={2}>
-
-
-
-
-
-
- } label="Enabled" />
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
- Create dead host
-
-
-
-
-
-
-
-
- } label="Enabled" />
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/(dashboard)/dead-hosts/actions.ts b/app/(dashboard)/dead-hosts/actions.ts
deleted file mode 100644
index abe9a62b..00000000
--- a/app/(dashboard)/dead-hosts/actions.ts
+++ /dev/null
@@ -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 {
- 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");
- }
-}
diff --git a/app/(dashboard)/dead-hosts/page.tsx b/app/(dashboard)/dead-hosts/page.tsx
deleted file mode 100644
index b608679d..00000000
--- a/app/(dashboard)/dead-hosts/page.tsx
+++ /dev/null
@@ -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 ;
-}
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index b6ac0849..d5cb2db6 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -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 {
- 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: , count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Redirects", icon: , count: redirectHostsCount, href: "/redirects" },
- { label: "Dead Hosts", icon: , count: deadHostsCount, href: "/dead-hosts" },
{ label: "Certificates", icon: , count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: , count: accessListsCount, href: "/access-lists" }
];
diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
index bfd3461d..83a13b26 100644
--- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
+++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
@@ -82,12 +82,18 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut
},
{
id: "upstreams",
- label: "Upstreams",
+ label: "Target",
render: (host: ProxyHost) => (
-
- {host.upstreams[0]}
- {host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
-
+ host.response_mode === "static" ? (
+
+ Static Response ({host.static_status_code ?? 503})
+
+ ) : (
+
+ {host.upstreams[0]}
+ {host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
+
+ )
)
},
{
diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts
index 1ec3e10d..921b6f4c 100644
--- a/app/(dashboard)/proxy-hosts/actions.ts
+++ b/app/(dashboard)/proxy-hosts/actions.ts
@@ -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
);
diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts
index a801d2db..9a1b16f9 100644
--- a/app/api/instances/sync/route.ts
+++ b/app/api/instances/sync/route.ts
@@ -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)
);
}
diff --git a/drizzle/0004_slimy_grim_reaper.sql b/drizzle/0004_slimy_grim_reaper.sql
new file mode 100644
index 00000000..2615c3c7
--- /dev/null
+++ b/drizzle/0004_slimy_grim_reaper.sql
@@ -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;
\ No newline at end of file
diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json
new file mode 100644
index 00000000..d2f838aa
--- /dev/null
+++ b/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,1137 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "7e98b252-3ad2-4fd7-b8b1-b6e71a546310",
+ "prevId": "8cf688e3-352b-491e-91f4-53695e730d8c",
+ "tables": {
+ "access_list_entries": {
+ "name": "access_list_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "access_list_id": {
+ "name": "access_list_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "access_list_entries_list_idx": {
+ "name": "access_list_entries_list_idx",
+ "columns": [
+ "access_list_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "access_list_entries_access_list_id_access_lists_id_fk": {
+ "name": "access_list_entries_access_list_id_access_lists_id_fk",
+ "tableFrom": "access_list_entries",
+ "tableTo": "access_lists",
+ "columnsFrom": [
+ "access_list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "access_lists": {
+ "name": "access_lists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "access_lists_created_by_users_id_fk": {
+ "name": "access_lists_created_by_users_id_fk",
+ "tableFrom": "access_lists",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "api_tokens": {
+ "name": "api_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_tokens_token_hash_unique": {
+ "name": "api_tokens_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_tokens_created_by_users_id_fk": {
+ "name": "api_tokens_created_by_users_id_fk",
+ "tableFrom": "api_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_events": {
+ "name": "audit_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_events_user_id_users_id_fk": {
+ "name": "audit_events_user_id_users_id_fk",
+ "tableFrom": "audit_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "certificates": {
+ "name": "certificates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain_names": {
+ "name": "domain_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auto_renew": {
+ "name": "auto_renew",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "provider_options": {
+ "name": "provider_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "certificate_pem": {
+ "name": "certificate_pem",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "private_key_pem": {
+ "name": "private_key_pem",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "certificates_created_by_users_id_fk": {
+ "name": "certificates_created_by_users_id_fk",
+ "tableFrom": "certificates",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "instances": {
+ "name": "instances",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_token": {
+ "name": "api_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_error": {
+ "name": "last_sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "instances_base_url_unique": {
+ "name": "instances_base_url_unique",
+ "columns": [
+ "base_url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_states": {
+ "name": "oauth_states",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code_verifier": {
+ "name": "code_verifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirect_to": {
+ "name": "redirect_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "oauth_state_unique": {
+ "name": "oauth_state_unique",
+ "columns": [
+ "state"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pending_oauth_links": {
+ "name": "pending_oauth_links",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_email": {
+ "name": "user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pending_oauth_user_provider_unique": {
+ "name": "pending_oauth_user_provider_unique",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "pending_oauth_links_user_id_users_id_fk": {
+ "name": "pending_oauth_links_user_id_users_id_fk",
+ "tableFrom": "pending_oauth_links",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "proxy_hosts": {
+ "name": "proxy_hosts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domains": {
+ "name": "domains",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "upstreams": {
+ "name": "upstreams",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate_id": {
+ "name": "certificate_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_list_id": {
+ "name": "access_list_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ssl_forced": {
+ "name": "ssl_forced",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hsts_enabled": {
+ "name": "hsts_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hsts_subdomains": {
+ "name": "hsts_subdomains",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "allow_websocket": {
+ "name": "allow_websocket",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "preserve_host_header": {
+ "name": "preserve_host_header",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "meta": {
+ "name": "meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "skip_https_hostname_validation": {
+ "name": "skip_https_hostname_validation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "response_mode": {
+ "name": "response_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'proxy'"
+ },
+ "static_status_code": {
+ "name": "static_status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 503
+ },
+ "static_response_body": {
+ "name": "static_response_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "proxy_hosts_certificate_id_certificates_id_fk": {
+ "name": "proxy_hosts_certificate_id_certificates_id_fk",
+ "tableFrom": "proxy_hosts",
+ "tableTo": "certificates",
+ "columnsFrom": [
+ "certificate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "proxy_hosts_access_list_id_access_lists_id_fk": {
+ "name": "proxy_hosts_access_list_id_access_lists_id_fk",
+ "tableFrom": "proxy_hosts",
+ "tableTo": "access_lists",
+ "columnsFrom": [
+ "access_list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "proxy_hosts_owner_user_id_users_id_fk": {
+ "name": "proxy_hosts_owner_user_id_users_id_fk",
+ "tableFrom": "proxy_hosts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "redirect_hosts": {
+ "name": "redirect_hosts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domains": {
+ "name": "domains",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "destination": {
+ "name": "destination",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 302
+ },
+ "preserve_query": {
+ "name": "preserve_query",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "redirect_hosts_created_by_users_id_fk": {
+ "name": "redirect_hosts_created_by_users_id_fk",
+ "tableFrom": "redirect_hosts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "settings": {
+ "name": "settings",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ },
+ "users_provider_subject_idx": {
+ "name": "users_provider_subject_idx",
+ "columns": [
+ "provider",
+ "subject"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 1ca650c8..3944474c 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -29,6 +29,13 @@
"when": 1769262874211,
"tag": "0003_instances",
"breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "6",
+ "when": 1770395358533,
+ "tag": "0004_slimy_grim_reaper",
+ "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 ff4ffdef..9df93754 100644
--- a/src/components/proxy-hosts/HostDialogs.tsx
+++ b/src/components/proxy-hosts/HostDialogs.tsx
@@ -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(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 (
-
+
+ Response Mode
+ setResponseMode(e.target.value as ResponseMode)}
+ >
+ } label="Proxy to Upstream" />
+ } label="Static Response" />
+
+
+ {responseMode === "proxy" && (
+
+ )}
+ {responseMode === "static" && (
+ <>
+
+
+ >
+ )}
{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(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 (
-
+
+ Response Mode
+ setResponseMode(e.target.value as ResponseMode)}
+ >
+ } label="Proxy to Upstream" />
+ } label="Static Response" />
+
+
+ {responseMode === "proxy" && (
+
+ )}
+ {responseMode === "static" && (
+ <>
+
+
+ >
+ )}
{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
diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts
index ebe71c38..5838fb4b 100644
--- a/src/lib/caddy.ts
+++ b/src/lib/caddy.ts
@@ -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[] = [];
+
+ // Build static response handler
+ const staticResponseHandler: Record = {
+ 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(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(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,
managedCertificatesWithAutomation: Set,
@@ -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;
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 950aec69..8036951e 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -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",
{
diff --git a/src/lib/instance-sync.ts b/src/lib/instance-sync.ts
index a6fa72e0..9640b94b 100644
--- a/src/lib/instance-sync.ts
+++ b/src/lib/instance-sync.ts
@@ -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;
proxyHosts: Array;
redirectHosts: Array;
- deadHosts: Array;
};
};
@@ -230,13 +229,12 @@ export async function clearSyncedSetting(key: string): Promise {
}
export async function buildSyncPayload(): Promise {
- 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 {
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 {
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();
- }
});
}
diff --git a/src/lib/models/dead-hosts.ts b/src/lib/models/dead-hosts.ts
deleted file mode 100644
index 2a5954e2..00000000
--- a/src/lib/models/dead-hosts.ts
+++ /dev/null
@@ -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 {
- const hosts = await db.select().from(deadHosts).orderBy(desc(deadHosts.createdAt));
- return hosts.map(parse);
-}
-
-export async function getDeadHost(id: number): Promise {
- 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, 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();
-}
diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts
index 4e6d901c..b5bb4f91 100644
--- a/src/lib/models/proxy-hosts.ts
+++ b/src/lib/models/proxy-hosts.ts
@@ -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
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
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));