Merge branch 'dev' into feat/roles-and-user-multi-selectors

This commit is contained in:
Fred KISSIE
2026-04-24 00:40:17 +02:00
13 changed files with 228 additions and 31 deletions

View File

@@ -6,12 +6,13 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func installCrowdsec(config Config) error { func installCrowdsec(config Config, installDir string) error {
if err := stopContainers(config.InstallationContainerType); err != nil { if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err) return fmt.Errorf("failed to stop containers: %v", err)
@@ -40,6 +41,8 @@ func installCrowdsec(config Config) error {
os.Exit(1) os.Exit(1)
} }
setupTraefikLogRotate(installDir)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err) fmt.Printf("Error copying docker service: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -208,3 +211,69 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik") fmt.Println("Added dependency of crowdsec to traefik")
return nil 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(installDir string) {
const logrotateDir = "/etc/logrotate.d"
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
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)
}

View File

@@ -259,7 +259,7 @@ func main() {
} }
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
err := installCrowdsec(config) err := installCrowdsec(config, installDir)
if err != nil { if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err) fmt.Printf("Error installing CrowdSec: %v\n", err)
return return

View File

@@ -36,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: string; heading: string;
previewText: string; previewText: string;
summary: string; summary: string;
statusLabel: string; statusLabel: string | null;
statusColor: string; statusColor: string | null;
} { } {
switch (eventType) { switch (eventType) {
case "site_online": case "site_online":
@@ -63,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Status Changed", heading: "Site Status Changed",
previewText: "A site in your organization has changed status.", previewText: "A site in your organization has changed status.",
summary: "A site in your organization has changed status.", summary: "A site in your organization has changed status.",
statusLabel: "Status Changed", statusLabel: null,
statusColor: "#f59e0b" statusColor: null
}; };
case "health_check_healthy": case "health_check_healthy":
return { return {
@@ -93,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): {
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
summary: summary:
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
statusLabel: "Status Changed", statusLabel: null,
statusColor: "#f59e0b" statusColor: null
}; };
case "resource_healthy": case "resource_healthy":
return { return {
@@ -120,8 +120,8 @@ function getEventMeta(eventType: AlertEventType): {
previewText: previewText:
"A resource in your organization has changed status.", "A resource in your organization has changed status.",
summary: "A resource in your organization has changed status.", summary: "A resource in your organization has changed status.",
statusLabel: "Status Changed", statusLabel: null,
statusColor: "#f59e0b" statusColor: null
}; };
default: default:
return { 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( function formatDataItems(
data: Record<string, unknown> data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] { ): { label: string; value: React.ReactNode }[] {
return Object.entries(data) return Object.entries(data)
.filter(([key]) => key !== "orgId") .filter(([key]) => key !== "orgId" && key !== "status")
.map(([key, value]) => ({ .map(([key, value]) => ({
label: key label: key
.replace(/([A-Z])/g, " $1") .replace(/([A-Z])/g, " $1")
@@ -154,16 +169,36 @@ export const AlertNotification = (props: AlertNotificationProps) => {
const meta = getEventMeta(eventType); const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data); 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 }[] = [ const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId }, { label: "Organization", value: orgId },
{ ...(resolvedStatus != null
label: "Status", ? [
value: ( {
<span style={{ color: meta.statusColor, fontWeight: 600 }}> label: "Status",
{meta.statusLabel} value: (
</span> <span
) style={{
}, color: resolvedStatus.color,
fontWeight: 600
}}
>
{resolvedStatus.label}
</span>
)
}
]
: []),
{ label: "Time", value: new Date().toUTCString() }, { label: "Time", value: new Date().toUTCString() },
...dataItems ...dataItems
]; ];

View File

@@ -329,7 +329,7 @@ export const ClientResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), 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([]), sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),

View File

@@ -76,6 +76,7 @@ export async function fireHealthCheckHealthyAlert(
healthCheckId, healthCheckId,
data: { data: {
healthCheckId, healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}), ...(healthCheckName != null ? { healthCheckName } : {}),
...extra ...extra
} }
@@ -133,6 +134,7 @@ export async function fireHealthCheckUnhealthyAlert(
healthCheckId, healthCheckId,
data: { data: {
healthCheckId, healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}), ...(healthCheckName != null ? { healthCheckName } : {}),
...extra ...extra
} }

View File

@@ -61,6 +61,7 @@ export async function fireResourceHealthyAlert(
resourceId, resourceId,
data: { data: {
resourceId, resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }
@@ -115,6 +116,7 @@ export async function fireResourceUnhealthyAlert(
resourceId, resourceId,
data: { data: {
resourceId, resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }

View File

@@ -63,6 +63,7 @@ export async function fireSiteOnlineAlert(
siteId, siteId,
data: { data: {
siteId, siteId,
status: "online",
...(siteName != null ? { siteName } : {}), ...(siteName != null ? { siteName } : {}),
...extra ...extra
} }
@@ -143,6 +144,7 @@ export async function fireSiteOfflineAlert(
siteId, siteId,
data: { data: {
siteId, siteId,
status: "offline",
...(siteName != null ? { siteName } : {}), ...(siteName != null ? { siteName } : {}),
...extra ...extra
} }

View File

@@ -42,6 +42,7 @@ export async function sendAlertWebhook(
const payload = { const payload = {
event: context.eventType, event: context.eventType,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: deriveStatus(context.eventType, context.data),
data: { data: {
orgId: context.orgId, orgId: context.orgId,
...context.data ...context.data
@@ -117,6 +118,38 @@ export async function sendAlertWebhook(
throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`);
} }
// ---------------------------------------------------------------------------
// Status derivation
// ---------------------------------------------------------------------------
function deriveStatus(
eventType: AlertContext["eventType"],
data: Record<string, unknown>
): 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) // Header construction (mirrors HttpLogDestination.buildHeaders)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { Transaction } from "@server/db"; import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm"; import { eq, or, and, like } from "drizzle-orm";
import privateConfig from "#private/lib/config";
/** /**
* Checks if a certificate exists for the given domain. * Checks if a certificate exists for the given domain.
@@ -27,10 +26,6 @@ export async function createCertificate(
domain: string, domain: string,
trx: Transaction | typeof db trx: Transaction | typeof db
) { ) {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
return;
}
const [domainRecord] = await trx const [domainRecord] = await trx
.select() .select()
.from(domains) .from(domains)

View File

@@ -41,8 +41,9 @@ async function query(domainId: string, domain: string) {
} }
let existing: any[] = []; 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 domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await db existing = await db
.select({ .select({
@@ -64,7 +65,8 @@ async function query(domainId: string, domain: string) {
eq(certificates.wildcard, true), // only NS domains can have wildcard certs eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or( or(
eq(certificates.domain, domain), eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown) eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
) )
) )
); );

View File

@@ -31,6 +31,8 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -494,6 +496,10 @@ export async function createSiteResource(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
); );
if (ssl && mode === "http" && domainId && fullDomain && build != "oss") {
await createCertificate(domainId, fullDomain, db);
}
return response(res, { return response(res, {
data: newSiteResource, data: newSiteResource,
success: true, success: true,

View File

@@ -59,6 +59,7 @@ import type { Selectedsite } from "./site-selector";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -1114,6 +1115,54 @@ export function InternalResourceForm({
</FormItem> </FormItem>
)} )}
/> />
<div className="flex items-start justify-between gap-4">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(
enableSslLabelKey
)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
)}
/>
{variant === "edit" &&
resource?.domainId &&
httpConfigFullDomain &&
form.watch("ssl") && (
<div className="flex items-center gap-1 pt-1">
<span className="text-sm font-medium text-muted-foreground">
{t("certificateStatus")}:
</span>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={
httpConfigFullDomain
}
autoFetch={true}
showLabel={false}
polling={true}
/>
</div>
)}
</div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -30,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<AlertDescription> <AlertDescription>
{/* 4 cols because of the certs */} {/* 4 cols because of the certs */}
<InfoSections <InfoSections
cols={resource.http && env.flags.usePangolinDns ? 5 : 4} cols={resource.http ? 5 : 4}
> >
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle> <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
@@ -43,7 +43,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection> <InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle> <InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} /> <CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
@@ -133,8 +136,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* Certificate Status Column */} {/* Certificate Status Column */}
{resource.http && {resource.http &&
resource.domainId && resource.domainId &&
resource.fullDomain && resource.fullDomain && (
env.flags.usePangolinDns && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("certificateStatus", { {t("certificateStatus", {