Build the Settings module: notification channels, event toggles, DNS badge colors

The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 12:14:05 +02:00
1 parent 3a53a86ce0
commit 3255314402
16 files changed
+1272 -23

No files matched your search

+4
View File
@@ -16,6 +16,8 @@
"drizzle-orm": "^0.45.2",
"express": "^4.21.2",
"express-session": "^1.18.1",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"openid-client": "^6.1.7",
"session-file-store": "^1.5.0",
"xml2js": "^0.6.2",
@@ -25,6 +27,8 @@
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"@types/node-schedule": "^2.1.7",
"@types/nodemailer": "^6.4.17",
"@types/session-file-store": "^1.2.5",
"@types/xml2js": "^0.4.14",
"drizzle-kit": "^0.31.10",
-4
View File
@@ -11,10 +11,6 @@ export const env = {
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
},
gotify: {
url: process.env.GOTIFY_URL ?? "",
token: process.env.GOTIFY_TOKEN ?? "",
},
get authEnabled() {
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
},
+4
View File
@@ -19,9 +19,12 @@ import { serversRouter } from "./routes/servers.js";
import { tasksRouter } from "./routes/tasks.js";
import { agentReportRouter } from "./routes/agentReport.js";
import { integrationsRouter } from "./routes/integrations.js";
import { settingsRouter } from "./routes/settings.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
warnIfAuthNotConfigured();
await runMigrations();
await initSecretExpiryScheduler();
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist");
@@ -68,6 +71,7 @@ app.use("/api/servers", serversRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/agent/report", agentReportRouter);
app.use("/api/integrations", integrationsRouter);
app.use("/api/settings", settingsRouter);
if (existsSync(webDist)) {
app.use(express.static(webDist));
+33
View File
@@ -14,6 +14,16 @@ import {
import { createDnsAdapter } from "../dns/registry.js";
import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { notifyDnsRecordAdded, notifyDnsRecordUpdated, notifyDnsRecordDeleted } from "../services/notify.js";
async function zoneNameFor(providerId: number, zoneId: string): Promise<string> {
const [zone] = await db
.select()
.from(dnsZonesCache)
.where(and(eq(dnsZonesCache.providerId, providerId), eq(dnsZonesCache.zoneId, zoneId)))
.limit(1);
return zone?.zoneName ?? zoneId;
}
export const dnsRouter = Router();
@@ -383,6 +393,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"),
targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type },
});
notifyDnsRecordAdded(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] add-record notification failed:", err),
);
res.status(201).json({ record: result });
} catch (err) {
@@ -433,6 +446,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope
targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type },
});
notifyDnsRecordUpdated(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] update-record notification failed:", err),
);
res.json({ record: result });
} catch (err) {
@@ -448,6 +464,18 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
if (!found) return res.status(404).json({ error: "not_found" });
try {
const [existingCached] = await db
.select()
.from(dnsRecordsCache)
.where(
and(
eq(dnsRecordsCache.providerId, providerId),
eq(dnsRecordsCache.zoneId, zoneId),
eq(dnsRecordsCache.recordId, recordId),
),
)
.limit(1);
await found.adapter.deleteRecord(zoneId, recordId);
await db
.delete(dnsRecordsCache)
@@ -467,6 +495,11 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
targetId: recordId,
detail: { providerId, zoneId },
});
if (existingCached) {
notifyDnsRecordDeleted(found.provider.name, await zoneNameFor(providerId, zoneId), existingCached).catch((err) =>
console.error("[dns] delete-record notification failed:", err),
);
}
res.status(204).end();
} catch (err) {
+133
View File
@@ -0,0 +1,133 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const settingsRouter = Router();
settingsRouter.use(requireAuth);
settingsRouter.get("/", requireRole("admin"), asyncHandler(async (_req, res) => {
res.json({ settings: await getSettings() });
}));
// Non-secret subset any signed-in user can read, so badge colors can be applied
// throughout the app without exposing Gotify/SMTP/webhook credentials.
settingsRouter.get("/provider-colors", asyncHandler(async (_req, res) => {
const { providerColors } = await getSettings();
res.json({ providerColors });
}));
const updateSchema = z.object({
gotify: z.object({ enabled: z.boolean(), url: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
ntfy: z.object({ enabled: z.boolean(), url: z.string(), topic: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
smtp: z
.object({
enabled: z.boolean(),
host: z.string(),
port: z.number(),
secure: z.boolean(),
username: z.string(),
password: z.string(),
from: z.string(),
to: z.string(),
})
.partial()
.optional(),
webhook: z.object({ enabled: z.boolean(), url: z.string(), secret: z.string() }).partial().optional(),
notifications: z
.object({
dnsAdd: z.boolean(),
dnsUpdate: z.boolean(),
dnsDelete: z.boolean(),
secretCheck: z.boolean(),
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
timezone: z.string(),
})
.partial()
.optional(),
providerColors: z.record(z.string()).optional(),
});
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const updated = await updateSettings(parsed.data);
if (parsed.data.notifications) {
await scheduleSecretExpiryCheck();
}
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "update",
targetType: "settings",
detail: { sections: Object.keys(parsed.data) },
});
res.json({ settings: updated });
}));
settingsRouter.post("/test-gotify", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), token: z.string().min(1), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testGotify(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-ntfy", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), topic: z.string().min(1), token: z.string().optional(), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testNtfy(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-smtp", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({
host: z.string().min(1),
port: z.number().optional(),
secure: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
from: z.string().min(1),
to: z.string().min(1),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testSmtp(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-webhook", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), secret: z.string().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testWebhook(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
+179
View File
@@ -0,0 +1,179 @@
import nodemailer from "nodemailer";
import { getSettings, type NotificationEvents } from "./settingsStore.js";
// ─── Channel senders (best-effort — failures are logged, never thrown) ──────
async function sendGotify(title: string, message: string) {
const { gotify } = await getSettings();
if (!gotify.enabled || !gotify.url || !gotify.token) return;
const base = gotify.url.replace(/\/$/, "");
try {
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }),
});
if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] Gotify failed:", err instanceof Error ? err.message : err);
}
}
async function sendNtfy(title: string, message: string) {
const { ntfy } = await getSettings();
if (!ntfy.enabled || !ntfy.url || !ntfy.topic) return;
const base = ntfy.url.replace(/\/$/, "");
const headers: Record<string, string> = {
"Content-Type": "text/plain",
Title: title,
Priority: String(ntfy.priority ?? 3),
};
if (ntfy.token) headers.Authorization = `Bearer ${ntfy.token}`;
try {
const res = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, { method: "POST", headers, body: message });
if (!res.ok) console.error(`[notify] ntfy error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] ntfy failed:", err instanceof Error ? err.message : err);
}
}
async function sendSmtp(title: string, message: string) {
const { smtp } = await getSettings();
if (!smtp.enabled || !smtp.host || !smtp.to || !smtp.from) return;
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: Number(smtp.port) || 587,
secure: !!smtp.secure,
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
});
await transporter.sendMail({ from: smtp.from, to: smtp.to, subject: title, text: message });
} catch (err) {
console.error("[notify] SMTP failed:", err instanceof Error ? err.message : err);
}
}
async function sendWebhook(title: string, message: string) {
const { webhook } = await getSettings();
if (!webhook.enabled || !webhook.url) return;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (webhook.secret) headers["X-Webhook-Secret"] = webhook.secret;
try {
const res = await fetch(webhook.url, {
method: "POST",
headers,
body: JSON.stringify({ content: `**${title}**\n${message}` }),
});
if (!res.ok) console.error(`[notify] Webhook error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] Webhook failed:", err instanceof Error ? err.message : err);
}
}
export async function notify(title: string, message: string): Promise<void> {
await Promise.all([sendGotify(title, message), sendNtfy(title, message), sendSmtp(title, message), sendWebhook(title, message)]);
}
// ─── Test senders — throw on failure so the route can report it ────────────
export async function testGotify(cfg: { url: string; token: string; priority?: number }): Promise<void> {
const base = cfg.url.replace(/\/$/, "");
const res = await fetch(`${base}/message?token=${encodeURIComponent(cfg.token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Homelab Manager — Test",
message: "Gotify notifications are working correctly.",
priority: cfg.priority ?? 5,
}),
});
if (!res.ok) throw new Error(`Gotify returned ${res.status}: ${await res.text().catch(() => "")}`);
}
export async function testNtfy(cfg: { url: string; topic: string; token?: string; priority?: number }): Promise<void> {
const base = cfg.url.replace(/\/$/, "");
const headers: Record<string, string> = { "Content-Type": "text/plain", Title: "Homelab Manager — Test", Priority: String(cfg.priority ?? 3) };
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
const res = await fetch(`${base}/${encodeURIComponent(cfg.topic)}`, {
method: "POST",
headers,
body: "ntfy notifications are working correctly.",
});
if (!res.ok) throw new Error(`ntfy returned ${res.status}: ${await res.text().catch(() => "")}`);
}
export async function testSmtp(cfg: {
host: string;
port?: number;
secure?: boolean;
username?: string;
password?: string;
from: string;
to: string;
}): Promise<void> {
const transporter = nodemailer.createTransport({
host: cfg.host,
port: Number(cfg.port) || 587,
secure: !!cfg.secure,
auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined,
});
await transporter.verify();
await transporter.sendMail({ from: cfg.from, to: cfg.to, subject: "Homelab Manager — Test", text: "SMTP notifications are working correctly." });
}
export async function testWebhook(cfg: { url: string; secret?: string }): Promise<void> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (cfg.secret) headers["X-Webhook-Secret"] = cfg.secret;
const res = await fetch(cfg.url, {
method: "POST",
headers,
body: JSON.stringify({ content: "**Homelab Manager — Test**\nWebhook notifications are working correctly." }),
});
if (!res.ok) throw new Error(`Webhook returned ${res.status}: ${await res.text().catch(() => "")}`);
}
// ─── Event helpers ───────────────────────────────────────────────────────────
async function eventEnabled(key: keyof NotificationEvents): Promise<boolean> {
const { notifications } = await getSettings();
return notifications[key] !== false;
}
export async function notifyDnsRecordAdded(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsAdd"))) return;
await notify("DNS Record Added", `[${providerLabel}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`);
}
export async function notifyDnsRecordUpdated(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsUpdate"))) return;
await notify("DNS Record Updated", `[${providerLabel}] ${zoneName}\n✎ ${record.type} ${record.name} → ${record.content}`);
}
export async function notifyDnsRecordDeleted(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsDelete"))) return;
await notify("DNS Record Deleted", `[${providerLabel}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`);
}
export async function notifySecretExpiry(
expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[],
): Promise<void> {
if (expiring.length === 0) return;
if (!(await eventEnabled("secretCheck"))) return;
const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`));
await notify(
"Homelab Manager — Secrets Alert",
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
);
}
@@ -0,0 +1,53 @@
import schedule from "node-schedule";
import { db } from "../db/client.js";
import { secrets } from "../db/schema.js";
import { computeSecretStatus } from "./secretStatus.js";
import { notifySecretExpiry } from "./notify.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "secretCheckLastRunDate";
async function checkSecretExpiry(): Promise<void> {
const rows = await db.select().from(secrets);
const expiring = rows
.map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) }))
.filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring");
await notifySecretExpiry(expiring);
}
async function checkSecretExpiryOnce(): Promise<void> {
const today = new Date().toDateString();
const lastRun = await getInternalFlag(LAST_RUN_FLAG);
if (lastRun === today) return;
await setInternalFlag(LAST_RUN_FLAG, today);
await checkSecretExpiry();
}
function cronFromTime(time: string): string {
const [h, m] = time.split(":").map(Number);
return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`;
}
let currentJob: schedule.Job | null = null;
/** (Re)schedules the daily secret-expiry check per the current notification settings. Call again after settings change. */
export async function scheduleSecretExpiryCheck(): Promise<void> {
if (currentJob) {
currentJob.cancel();
currentJob = null;
}
const { notifications } = await getSettings();
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
getSettings().then(({ notifications: n }) => {
if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
});
});
console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
}
/** Runs once at startup (skipped if already run today), then arms the daily schedule. */
export async function initSecretExpiryScheduler(): Promise<void> {
await checkSecretExpiryOnce();
await scheduleSecretExpiryCheck();
}
+125
View File
@@ -0,0 +1,125 @@
import { sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { settings } from "../db/schema.js";
export interface GotifySettings {
enabled: boolean;
url: string;
token: string;
priority: number;
}
export interface NtfySettings {
enabled: boolean;
url: string;
topic: string;
token: string;
priority: number;
}
export interface SmtpSettings {
enabled: boolean;
host: string;
port: number;
secure: boolean;
username: string;
password: string;
from: string;
to: string;
}
export interface WebhookSettings {
enabled: boolean;
url: string;
secret: string;
}
export interface NotificationEvents {
dnsAdd: boolean;
dnsUpdate: boolean;
dnsDelete: boolean;
secretCheck: boolean;
secretCheckTime: string; // "HH:MM"
timezone: string;
}
export type ProviderColors = Record<string, string>;
export interface AppSettings {
gotify: GotifySettings;
ntfy: NtfySettings;
smtp: SmtpSettings;
webhook: WebhookSettings;
notifications: NotificationEvents;
providerColors: ProviderColors;
}
const DEFAULTS: AppSettings = {
gotify: { enabled: false, url: "", token: "", priority: 5 },
ntfy: { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 },
smtp: { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" },
webhook: { enabled: false, url: "", secret: "" },
notifications: {
dnsAdd: true,
dnsUpdate: true,
dnsDelete: true,
secretCheck: true,
secretCheckTime: "08:00",
timezone: "UTC",
},
providerColors: {},
};
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
export async function getSettings(): Promise<AppSettings> {
const rows = await db.select().from(settings);
const byKey = new Map(rows.map((r) => [r.key, r.value]));
const result = {} as AppSettings;
for (const key of KEYS) {
const raw = byKey.get(key);
let parsed: Record<string, unknown> = {};
if (raw) {
try {
parsed = JSON.parse(raw);
} catch {
parsed = {};
}
}
(result[key] as Record<string, unknown>) = { ...(DEFAULTS[key] as object), ...parsed };
}
return result;
}
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
export async function updateSettings(partial: AppSettingsPatch): Promise<AppSettings> {
const current = await getSettings();
for (const key of Object.keys(partial) as (keyof AppSettings)[]) {
if (!KEYS.includes(key)) continue;
const merged = { ...(current[key] as object), ...(partial[key] as object) };
const value = JSON.stringify(merged);
await db
.insert(settings)
.values({ key, value })
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
}
return getSettings();
}
/** Small internal key/value slot for scheduler bookkeeping, outside the AppSettings shape. */
export async function getInternalFlag(key: string): Promise<string | null> {
const [row] = await db.select().from(settings).where(sql`${settings.key} = ${`_internal:${key}`}`).limit(1);
return row?.value ?? null;
}
export async function setInternalFlag(key: string, value: string): Promise<void> {
const fullKey = `_internal:${key}`;
await db
.insert(settings)
.values({ key: fullKey, value })
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
}