From 2c8b7b5ca5ccc031f60f4c3b627483bade477ecc Mon Sep 17 00:00:00 2001 From: Siddharth Bansal Date: Sun, 19 Apr 2026 12:27:16 +0530 Subject: [PATCH 1/7] (fix): Added a logrotate function to the crowdsec.go installer file --- install/crowdsec.go | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/install/crowdsec.go b/install/crowdsec.go index c75dccf32..d2817a76c 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "gopkg.in/yaml.v3" @@ -40,6 +41,8 @@ func installCrowdsec(config Config) error { os.Exit(1) } + setupTraefikLogRotate() + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) os.Exit(1) @@ -208,3 +211,80 @@ func CheckAndAddCrowdsecDependency(composePath string) error { fmt.Println("Added dependency of crowdsec to traefik") return nil } + +// setupTraefikLogRotate writes a logrotate config for the Traefik access log +// that CrowdSec depends on. This is only needed when CrowdSec is installed +// because the default Pangolin install does not enable Traefik access logs. +// +// copytruncate is used so Traefik does not need to be restarted or sent a +// signal after rotation — it keeps writing to the same file descriptor while +// the rotated copy is made and the original is truncated in place. +func setupTraefikLogRotate() { + const logrotateDir = "/etc/logrotate.d" + const logrotateFile = "/etc/logrotate.d/pangolin-traefik" + + // Resolve the absolute path to the install directory so the logrotate + // config references the correct log file regardless of where the user + // installed Pangolin. + installDir, err := filepath.Abs(".") + if err != nil { + fmt.Printf("[logrotate] Warning: could not resolve install directory: %v\n", err) + fmt.Println("[logrotate] Skipping logrotate setup. Set it up manually:") + printLogrotateConfig("/config/traefik/logs/access.log") + return + } + + logPath := filepath.Join(installDir, "config/traefik/logs/access.log") + + if os.Geteuid() != 0 { + fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.") + fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,") + fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:") + printLogrotateConfig(logPath) + return + } + + config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec. +# Generated by the Pangolin installer. Safe to edit. +%s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate +} +`, logPath) + + if err := os.MkdirAll(logrotateDir, 0755); err != nil { + fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err) + return + } + + if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil { + fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err) + fmt.Println("[logrotate] Set it up manually:") + printLogrotateConfig(logPath) + return + } + + fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile) + fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.") +} + +// printLogrotateConfig prints a logrotate config block to stdout so users can +// set it up manually when the installer cannot write to /etc. +func printLogrotateConfig(logPath string) { + fmt.Printf(` + %s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate + } +`, logPath) +} From 473bce856d4d864875caa6786a4185bbe15802d2 Mon Sep 17 00:00:00 2001 From: Siddharth Bansal Date: Mon, 20 Apr 2026 21:36:42 +0530 Subject: [PATCH 2/7] Pass installdir as a parameter --- install/crowdsec.go | 17 +++-------------- install/main.go | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/install/crowdsec.go b/install/crowdsec.go index d2817a76c..8dff42d99 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -12,7 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -func installCrowdsec(config Config) error { +func installCrowdsec(config Config, installDir string) error { if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) @@ -41,7 +41,7 @@ func installCrowdsec(config Config) error { os.Exit(1) } - setupTraefikLogRotate() + setupTraefikLogRotate(installDir) if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) @@ -219,21 +219,10 @@ func CheckAndAddCrowdsecDependency(composePath string) error { // copytruncate is used so Traefik does not need to be restarted or sent a // signal after rotation — it keeps writing to the same file descriptor while // the rotated copy is made and the original is truncated in place. -func setupTraefikLogRotate() { +func setupTraefikLogRotate(installDir string) { const logrotateDir = "/etc/logrotate.d" const logrotateFile = "/etc/logrotate.d/pangolin-traefik" - // Resolve the absolute path to the install directory so the logrotate - // config references the correct log file regardless of where the user - // installed Pangolin. - installDir, err := filepath.Abs(".") - if err != nil { - fmt.Printf("[logrotate] Warning: could not resolve install directory: %v\n", err) - fmt.Println("[logrotate] Skipping logrotate setup. Set it up manually:") - printLogrotateConfig("/config/traefik/logs/access.log") - return - } - logPath := filepath.Join(installDir, "config/traefik/logs/access.log") if os.Geteuid() != 0 { diff --git a/install/main.go b/install/main.go index a38d78fc6..13e506d06 100644 --- a/install/main.go +++ b/install/main.go @@ -259,7 +259,7 @@ func main() { } config.DoCrowdsecInstall = true - err := installCrowdsec(config) + err := installCrowdsec(config, installDir) if err != nil { fmt.Printf("Error installing CrowdSec: %v\n", err) return From fc69364feba0439c89231a2aace05f0be9e965ea Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:36:00 -0700 Subject: [PATCH 3/7] Show cert status --- src/components/InternalResourceForm.tsx | 63 ++++++++++++++++--------- src/components/ResourceInfoBox.tsx | 10 ++-- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 3a693d82b..6d7fd1537 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -54,6 +54,7 @@ import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; +import CertificateStatus from "@app/components/CertificateStatus"; // --- Helpers (shared) --- @@ -1072,28 +1073,48 @@ export function InternalResourceForm({ }} /> - ( - - - + ( + + + + + + )} + /> + {variant === "edit" && + resource?.domainId && + httpConfigFullDomain && + form.watch("ssl") && ( +
+ + {t("certificateStatus")}: + + - - - )} - /> +
+ )} + ) : (
diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 8006612a9..ad3cb5f34 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -30,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* 4 cols because of the certs */} {t("identifier")} @@ -43,7 +43,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { URL - + @@ -133,8 +136,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* Certificate Status Column */} {resource.http && resource.domainId && - resource.fullDomain && - env.flags.usePangolinDns && ( + resource.fullDomain && ( {t("certificateStatus", { From 90a2ed2f106c3acda45a57d4f7913c20fcb28a66 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:39:04 -0700 Subject: [PATCH 4/7] Create pending cert --- server/routers/siteResource/createSiteResource.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 29fc8c213..242bbf860 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -31,6 +31,8 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { build } from "@server/build"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -494,6 +496,10 @@ export async function createSiteResource( `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` ); + if (ssl && mode === "http" && domainId && fullDomain && build != "oss") { + await createCertificate(domainId, fullDomain, db); + } + return response(res, { data: newSiteResource, success: true, From bcb5b7b4a7103708e5f7ad94da52977b09339908 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:44:35 -0700 Subject: [PATCH 5/7] Show status in messages --- server/emails/templates/AlertNotification.tsx | 69 ++++++++++++++----- .../lib/alerts/events/healthCheckEvents.ts | 2 + .../lib/alerts/events/resourceEvents.ts | 2 + .../private/lib/alerts/events/siteEvents.ts | 2 + server/private/lib/alerts/sendAlertWebhook.ts | 33 +++++++++ 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index b540142d2..5542384a9 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -36,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): { heading: string; previewText: string; summary: string; - statusLabel: string; - statusColor: string; + statusLabel: string | null; + statusColor: string | null; } { switch (eventType) { case "site_online": @@ -63,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): { heading: "Site Status Changed", previewText: "A site in your organization has changed status.", summary: "A site in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; case "health_check_healthy": return { @@ -93,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): { "A health check in your organization has changed status.", summary: "A health check in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; case "resource_healthy": return { @@ -120,8 +120,8 @@ function getEventMeta(eventType: AlertEventType): { previewText: "A resource in your organization has changed status.", summary: "A resource in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; default: return { @@ -135,11 +135,26 @@ function getEventMeta(eventType: AlertEventType): { } } +function resolveToggleStatus(status: unknown): { label: string; color: string } { + switch (String(status).toLowerCase()) { + case "online": + return { label: "Online", color: "#16a34a" }; + case "offline": + return { label: "Offline", color: "#dc2626" }; + case "healthy": + return { label: "Healthy", color: "#16a34a" }; + case "unhealthy": + return { label: "Unhealthy", color: "#dc2626" }; + default: + return { label: String(status ?? "Unknown"), color: "#f59e0b" }; + } +} + function formatDataItems( data: Record ): { label: string; value: React.ReactNode }[] { return Object.entries(data) - .filter(([key]) => key !== "orgId") + .filter(([key]) => key !== "orgId" && key !== "status") .map(([key, value]) => ({ label: key .replace(/([A-Z])/g, " $1") @@ -154,16 +169,36 @@ export const AlertNotification = (props: AlertNotificationProps) => { const meta = getEventMeta(eventType); const dataItems = formatDataItems(data); + const isToggle = + eventType === "site_toggle" || + eventType === "health_check_toggle" || + eventType === "resource_toggle"; + + const resolvedStatus = isToggle + ? resolveToggleStatus(data.status) + : meta.statusLabel != null + ? { label: meta.statusLabel, color: meta.statusColor! } + : null; + const allItems: { label: string; value: React.ReactNode }[] = [ { label: "Organization", value: orgId }, - { - label: "Status", - value: ( - - {meta.statusLabel} - - ) - }, + ...(resolvedStatus != null + ? [ + { + label: "Status", + value: ( + + {resolvedStatus.label} + + ) + } + ] + : []), { label: "Time", value: new Date().toUTCString() }, ...dataItems ]; diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index c2ba25b28..4851f08c4 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -76,6 +76,7 @@ export async function fireHealthCheckHealthyAlert( healthCheckId, data: { healthCheckId, + status: "healthy", ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } @@ -133,6 +134,7 @@ export async function fireHealthCheckUnhealthyAlert( healthCheckId, data: { healthCheckId, + status: "unhealthy", ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index c2d6d3725..8c20bc5a1 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -61,6 +61,7 @@ export async function fireResourceHealthyAlert( resourceId, data: { resourceId, + status: "healthy", ...(resourceName != null ? { resourceName } : {}), ...extra } @@ -115,6 +116,7 @@ export async function fireResourceUnhealthyAlert( resourceId, data: { resourceId, + status: "unhealthy", ...(resourceName != null ? { resourceName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 580e00848..562accc18 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -63,6 +63,7 @@ export async function fireSiteOnlineAlert( siteId, data: { siteId, + status: "online", ...(siteName != null ? { siteName } : {}), ...extra } @@ -143,6 +144,7 @@ export async function fireSiteOfflineAlert( siteId, data: { siteId, + status: "offline", ...(siteName != null ? { siteName } : {}), ...extra } diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 5656026bc..2dd0eb600 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -42,6 +42,7 @@ export async function sendAlertWebhook( const payload = { event: context.eventType, timestamp: new Date().toISOString(), + status: deriveStatus(context.eventType, context.data), data: { orgId: context.orgId, ...context.data @@ -117,6 +118,38 @@ export async function sendAlertWebhook( throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); } +// --------------------------------------------------------------------------- +// Status derivation +// --------------------------------------------------------------------------- + +function deriveStatus( + eventType: AlertContext["eventType"], + data: Record +): string { + switch (eventType) { + case "site_online": + return "online"; + case "site_offline": + return "offline"; + case "site_toggle": + return String(data.status ?? "unknown"); + case "health_check_healthy": + case "resource_healthy": + return "healthy"; + case "health_check_unhealthy": + case "resource_unhealthy": + return "unhealthy"; + case "health_check_toggle": + case "resource_toggle": + return String(data.status ?? "unknown"); + default: { + const _exhaustive: never = eventType; + void _exhaustive; + return "unknown"; + } + } +} + // --------------------------------------------------------------------------- // Header construction (mirrors HttpLogDestination.buildHeaders) // --------------------------------------------------------------------------- From 230f77118a49da8aa156b4181cf30ddba3de7445 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 21:11:52 -0700 Subject: [PATCH 6/7] Also check when getting the cert --- server/private/routers/certificates/createCertificate.ts | 5 ----- server/private/routers/certificates/getCertificate.ts | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 3aa0c6873..4f7bb7fe8 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db"; import logger from "@server/logger"; import { Transaction } from "@server/db"; import { eq, or, and, like } from "drizzle-orm"; -import privateConfig from "#private/lib/config"; /** * Checks if a certificate exists for the given domain. @@ -27,10 +26,6 @@ export async function createCertificate( domain: string, trx: Transaction | typeof db ) { - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - return; - } - const [domainRecord] = await trx .select() .from(domains) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index c3a590193..9e434b3e0 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -41,8 +41,9 @@ async function query(domainId: string, domain: string) { } let existing: any[] = []; - if (domainRecord.type == "ns") { + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { // the manual "wildcard" domains can have wildcard certs const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; existing = await db .select({ @@ -64,7 +65,8 @@ async function query(domainId: string, domain: string) { eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown) + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) ) ) ); From a7c731940736d47392bab0bb897d219e87bcaac6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 12:09:22 -0700 Subject: [PATCH 7/7] Deprecated sites should be optional --- server/lib/blueprints/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 913cf31ed..e017a16d1 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -329,7 +329,7 @@ export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "http"]), - site: z.string(), // DEPRECATED IN FAVOR OF sites + site: z.string().optional(), // DEPRECATED IN FAVOR OF sites sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(),