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" && ( + <> + + + + )} Managed by Caddy (Auto) {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" && ( + <> + + + + )} Managed by Caddy (Auto) {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));