From ec580d2385523e8c3d288d2f5e8fbb1d6484ddd1 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:07:51 +0100 Subject: [PATCH] Add the ability to log to loki --- app/(dashboard)/settings/SettingsClient.tsx | 89 ++++++++++++++++++++- app/(dashboard)/settings/actions.ts | 80 +++++++++++++++++- app/(dashboard)/settings/page.tsx | 20 ++++- docker/caddy/Dockerfile | 1 + src/lib/caddy.ts | 74 ++++++++++++++++- src/lib/settings.ts | 16 ++++ 6 files changed, 271 insertions(+), 9 deletions(-) diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index 668e60e0..3898f24c 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -2,11 +2,12 @@ import { useFormState } from "react-dom"; import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; -import type { GeneralSettings, AuthentikSettings, MetricsSettings } from "@/src/lib/settings"; +import type { GeneralSettings, AuthentikSettings, LoggingSettings, MetricsSettings } from "@/src/lib/settings"; import { updateCloudflareSettingsAction, updateGeneralSettingsAction, updateAuthentikSettingsAction, + updateLoggingSettingsAction, updateMetricsSettingsAction } from "./actions"; @@ -18,13 +19,21 @@ type Props = { accountId?: string; }; authentik: AuthentikSettings | null; + logging: { + enabled: boolean; + lokiUrl?: string; + lokiUsername?: string; + hasPassword: boolean; + labels?: Record; + } | null; metrics: MetricsSettings | null; }; -export default function SettingsClient({ general, cloudflare, authentik, metrics }: Props) { +export default function SettingsClient({ general, cloudflare, authentik, logging, metrics }: Props) { const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null); const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null); + const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null); const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null); return ( @@ -162,6 +171,82 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics + + + + Logging + + + Enable comprehensive request logging to Loki for debugging and monitoring. + You must deploy your own Loki instance and provide its URL. + + {logging?.hasPassword && ( + + A Loki password is already configured. Leave the password field blank to keep it, or enter a new password to update it. + + )} + + {loggingState?.message && ( + + {loggingState.message} + + )} + } + label="Enable request logging" + /> + + + + } + label="Remove existing password" + disabled={!logging?.hasPassword} + /> + + + After enabling logging, all Caddy requests will be sent to your Loki instance. + You can query and visualize logs in Grafana using the Loki datasource. + + + + + + + + diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 6eee178f..15f9c670 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -3,7 +3,15 @@ import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/src/lib/auth"; import { applyCaddyConfig } from "@/src/lib/caddy"; -import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings } from "@/src/lib/settings"; +import { + getCloudflareSettings, + getLoggingSettings, + saveAuthentikSettings, + saveCloudflareSettings, + saveGeneralSettings, + saveLoggingSettings, + saveMetricsSettings +} from "@/src/lib/settings"; type ActionResult = { success: boolean; @@ -118,3 +126,73 @@ export async function updateMetricsSettingsAction(_prevState: ActionResult | nul return { success: false, message: error instanceof Error ? error.message : "Failed to save metrics settings" }; } } + +export async function updateLoggingSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise { + try { + await requireAdmin(); + const enabled = formData.get("enabled") === "on"; + const lokiUrl = formData.get("lokiUrl") ? String(formData.get("lokiUrl")).trim() : undefined; + const lokiUsername = formData.get("lokiUsername") ? String(formData.get("lokiUsername")).trim() : undefined; + const rawPassword = formData.get("lokiPassword") ? String(formData.get("lokiPassword")).trim() : ""; + const clearPassword = formData.get("clearPassword") === "on"; + const labelsStr = formData.get("labels") ? String(formData.get("labels")).trim() : ""; + + // Get current settings to preserve existing password if needed + const current = await getLoggingSettings(); + + // Validate Loki URL if logging is enabled + if (enabled && !lokiUrl) { + return { success: false, message: "Loki URL is required when logging is enabled" }; + } + + if (enabled && lokiUrl) { + try { + new URL(lokiUrl); + } catch { + return { success: false, message: "Invalid Loki URL format. Must be a valid HTTP/HTTPS URL." }; + } + } + + // Parse labels JSON if provided + let labels: Record | undefined; + if (labelsStr && labelsStr.length > 0) { + try { + labels = JSON.parse(labelsStr); + if (typeof labels !== "object" || Array.isArray(labels)) { + return { success: false, message: "Labels must be a JSON object" }; + } + } catch { + return { success: false, message: "Invalid labels JSON format" }; + } + } + + // Handle password: clear if checkbox is checked, update if new password provided, otherwise keep existing + const lokiPassword = clearPassword ? "" : rawPassword || current?.lokiPassword || ""; + + await saveLoggingSettings({ + enabled, + lokiUrl, + lokiUsername: lokiUsername && lokiUsername.length > 0 ? lokiUsername : undefined, + lokiPassword: lokiPassword && lokiPassword.length > 0 ? lokiPassword : undefined, + labels + }); + + // Apply config to enable/disable logging + try { + await applyCaddyConfig(); + revalidatePath("/settings"); + return { success: true, message: "Logging settings saved and applied successfully" }; + } catch (error) { + console.error("Failed to apply Caddy config:", error); + revalidatePath("/settings"); + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + return { + success: true, + message: `Settings saved, but could not apply to Caddy: ${errorMsg}` + }; + } + } catch (error) { + console.error("Failed to save logging settings:", error); + return { success: false, message: error instanceof Error ? error.message : "Failed to save logging settings" }; + } +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 9e3e8b75..37f96d2a 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,15 +1,22 @@ import SettingsClient from "./SettingsClient"; -import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings } from "@/src/lib/settings"; +import { + getAuthentikSettings, + getCloudflareSettings, + getGeneralSettings, + getLoggingSettings, + getMetricsSettings +} from "@/src/lib/settings"; import { requireAdmin } from "@/src/lib/auth"; export default async function SettingsPage() { await requireAdmin(); - const [general, cloudflare, authentik, metrics] = await Promise.all([ + const [general, cloudflare, authentik, metrics, logging] = await Promise.all([ getGeneralSettings(), getCloudflareSettings(), getAuthentikSettings(), - getMetricsSettings() + getMetricsSettings(), + getLoggingSettings() ]); return ( @@ -22,6 +29,13 @@ export default async function SettingsPage() { }} authentik={authentik} metrics={metrics} + logging={logging ? { + enabled: logging.enabled, + lokiUrl: logging.lokiUrl, + lokiUsername: logging.lokiUsername, + hasPassword: Boolean(logging.lokiPassword), + labels: logging.labels + } : null} /> ); } diff --git a/docker/caddy/Dockerfile b/docker/caddy/Dockerfile index 577b1579..32cab599 100644 --- a/docker/caddy/Dockerfile +++ b/docker/caddy/Dockerfile @@ -9,6 +9,7 @@ RUN go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest RUN xcaddy build \ --with github.com/caddy-dns/cloudflare \ --with github.com/mholt/caddy-l4 \ + --with github.com/trea/caddy-loki-logger \ --output /usr/bin/caddy FROM ubuntu:24.04 diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 1f7b9cca..b4c81a3b 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -3,7 +3,13 @@ import { join } from "node:path"; import crypto from "node:crypto"; import db, { nowIso } from "./db"; import { config } from "./config"; -import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, setSetting } from "./settings"; +import { + getCloudflareSettings, + getGeneralSettings, + getLoggingSettings, + getMetricsSettings, + setSetting +} from "./settings"; import { accessListEntries, certificates, @@ -782,6 +788,60 @@ async function buildTlsAutomation( }; } +function buildLoggingApp(loggingSettings: NonNullable>>) { + if (!loggingSettings.lokiUrl) { + return null; + } + + const lokiWriterConfig: Record = { + output: "loki", + url: loggingSettings.lokiUrl + }; + + // Add basic auth if provided + if (loggingSettings.lokiUsername && loggingSettings.lokiPassword) { + lokiWriterConfig.username = loggingSettings.lokiUsername; + lokiWriterConfig.password = loggingSettings.lokiPassword; + } + + // Add custom labels if provided + if (loggingSettings.labels && Object.keys(loggingSettings.labels).length > 0) { + lokiWriterConfig.labels = loggingSettings.labels; + } + + return { + logs: { + // Access log for all HTTP requests + access: { + encoder: { + format: "json", + message_key: "msg", + level_key: "level", + time_key: "ts", + name_key: "logger" + }, + writer: lokiWriterConfig, + level: "INFO", + include: ["http.log.access.*"] + }, + // Default log for errors and other messages + default: { + encoder: { + format: "json", + message_key: "msg", + level_key: "level", + time_key: "ts", + name_key: "logger", + caller_key: "caller", + stacktrace_key: "stacktrace" + }, + writer: lokiWriterConfig, + level: "WARN" + } + } + }; +} + async function buildCaddyDocument() { const [proxyHostRecords, redirectHostRecords, deadHostRecords, certRows, accessListEntryRecords] = await Promise.all([ db @@ -930,6 +990,11 @@ async function buildCaddyDocument() { const metricsEnabled = metricsSettings?.enabled ?? false; const metricsPort = metricsSettings?.port ?? 9090; + // Check if logging should be enabled + const loggingSettings = await getLoggingSettings(); + const loggingEnabled = loggingSettings?.enabled ?? false; + const loggingApp = loggingEnabled && loggingSettings ? buildLoggingApp(loggingSettings) : null; + const servers: Record = {}; // Main HTTP/HTTPS server for proxy hosts @@ -940,7 +1005,9 @@ async function buildCaddyDocument() { // Only disable automatic HTTPS if we have TLS automation policies // This allows Caddy to handle HTTP-01 challenges for managed certificates ...(tlsApp ? {} : { automatic_https: { disable: true } }), - ...(hasTls ? { tls_connection_policies: tlsConnectionPolicies } : {}) + ...(hasTls ? { tls_connection_policies: tlsConnectionPolicies } : {}), + // Enable access logging if configured + ...(loggingEnabled ? { logs: { default_logger_name: "access" } } : {}) }; } @@ -972,7 +1039,8 @@ async function buildCaddyDocument() { }, apps: { ...httpApp, - ...(tlsApp ? { tls: tlsApp } : {}) + ...(tlsApp ? { tls: tlsApp } : {}), + ...(loggingApp ? { logging: loggingApp } : {}) } }; } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 4c271d2e..094507bf 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -26,6 +26,14 @@ export type MetricsSettings = { port?: number; // Port to expose metrics on (default: 9090, separate from admin API) }; +export type LoggingSettings = { + enabled: boolean; + lokiUrl?: string; // URL of Loki instance (e.g., http://loki:3100) + lokiUsername?: string; // Optional username for Loki authentication + lokiPassword?: string; // Optional password for Loki authentication + labels?: Record; // Optional custom labels for logs +}; + export async function getSetting(key: string): Promise> { const setting = await db.query.settings.findFirst({ where: (table, { eq }) => eq(table.key, key) @@ -94,3 +102,11 @@ export async function getMetricsSettings(): Promise { export async function saveMetricsSettings(settings: MetricsSettings): Promise { await setSetting("metrics", settings); } + +export async function getLoggingSettings(): Promise { + return await getSetting("logging"); +} + +export async function saveLoggingSettings(settings: LoggingSettings): Promise { + await setSetting("logging", settings); +}