From 3255314402e042935a6ce15fa60ebb0c657f4482 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 12:14:05 +0200 Subject: [PATCH] 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 --- .env.example | 5 +- README.md | 4 + package-lock.json | 72 +++ server/package.json | 4 + server/src/env.ts | 4 - server/src/index.ts | 4 + server/src/routes/dns.ts | 33 ++ server/src/routes/settings.ts | 133 +++++ server/src/services/notify.ts | 179 ++++++ server/src/services/secretExpiryScheduler.ts | 53 ++ server/src/services/settingsStore.ts | 125 ++++ web/src/App.tsx | 4 +- web/src/api/client.ts | 66 +++ web/src/pages/ComingSoon.tsx | 12 - web/src/pages/Dns.tsx | 19 +- web/src/pages/Settings.tsx | 578 +++++++++++++++++++ 16 files changed, 1272 insertions(+), 23 deletions(-) create mode 100644 server/src/routes/settings.ts create mode 100644 server/src/services/notify.ts create mode 100644 server/src/services/secretExpiryScheduler.ts create mode 100644 server/src/services/settingsStore.ts delete mode 100644 web/src/pages/ComingSoon.tsx create mode 100644 web/src/pages/Settings.tsx diff --git a/.env.example b/.env.example index 58b3293..3488b99 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,5 @@ AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/homelab-manager AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= -# --- Optional: Gotify notifications (secret expiry, integration offline, etc.) --- -GOTIFY_URL= -GOTIFY_TOKEN= +# Notification channels (Gotify, ntfy, SMTP, webhook) are configured in-app +# under Settings, not here. diff --git a/README.md b/README.md index 68ebf5f..0636c2c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ All modules from the original plan are built: Supports self-signed certificates (common in homelab Proxmox setups). - **Integrations → Synology** — volume and disk health (read-only by design). Supports self-signed certificates. +- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP, + generic webhook) with per-channel test buttons, per-event toggles (DNS + record added/updated/deleted, daily secret-expiry reminder with a + configurable time/timezone), and DNS provider badge-color customization. All six integrations follow the same config-in-UI + encrypted-credentials pattern, added through **Integrations → Manage integrations**. diff --git a/package-lock.json b/package-lock.json index 20bb9a2..0a2c87e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1979,6 +1979,26 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-schedule": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz", + "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/nodemailer": { + "version": "6.4.24", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3214,6 +3234,12 @@ "@libsql/win32-x64-msvc": "0.4.7" } }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3402,6 +3428,42 @@ "node": ">=18" } }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/node-schedule/node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "deprecated": "v4 is no longer maintained, upgrade to v5", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/oauth4webapi": { "version": "3.8.8", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", @@ -3968,6 +4030,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4825,6 +4893,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", @@ -4834,6 +4904,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", diff --git a/server/package.json b/server/package.json index c4cd1f2..045f59e 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/env.ts b/server/src/env.ts index 0e6a39d..4f7bdc4 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -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); }, diff --git a/server/src/index.ts b/server/src/index.ts index 6bddfa8..b357766 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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)); diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts index c3abafc..d0b5201 100644 --- a/server/src/routes/dns.ts +++ b/server/src/routes/dns.ts @@ -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 { + 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) { diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts new file mode 100644 index 0000000..002efc0 --- /dev/null +++ b/server/src/routes/settings.ts @@ -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) }); + } +})); diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts new file mode 100644 index 0000000..24279af --- /dev/null +++ b/server/src/services/notify.ts @@ -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 = { + "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 = { "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 { + 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 { + 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 { + const base = cfg.url.replace(/\/$/, ""); + const headers: Record = { "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 { + 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 { + const headers: Record = { "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 { + const { notifications } = await getSettings(); + return notifications[key] !== false; +} + +export async function notifyDnsRecordAdded( + providerLabel: string, + zoneName: string, + record: { type: string; name: string; content: string }, +): Promise { + 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 { + 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 { + 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 { + 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")}`, + ); +} diff --git a/server/src/services/secretExpiryScheduler.ts b/server/src/services/secretExpiryScheduler.ts new file mode 100644 index 0000000..4b18add --- /dev/null +++ b/server/src/services/secretExpiryScheduler.ts @@ -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 { + 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 { + 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 { + 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 { + await checkSecretExpiryOnce(); + await scheduleSecretExpiryCheck(); +} diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts new file mode 100644 index 0000000..c6008a3 --- /dev/null +++ b/server/src/services/settingsStore.ts @@ -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; + +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 { + 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 = {}; + if (raw) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = {}; + } + } + (result[key] as Record) = { ...(DEFAULTS[key] as object), ...parsed }; + } + return result; +} + +export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; + +export async function updateSettings(partial: AppSettingsPatch): Promise { + 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 { + 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 { + const fullKey = `_internal:${key}`; + await db + .insert(settings) + .values({ key: fullKey, value }) + .onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ff1a7c4..c710ef1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; import Integrations from "./pages/Integrations"; -import ComingSoon from "./pages/ComingSoon"; +import Settings from "./pages/Settings"; import AppShell from "./layout/AppShell"; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; @@ -84,7 +84,7 @@ export default function App() { path="/settings" element={ - + } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d0fb469..673d468 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -78,6 +78,58 @@ export interface IpamInput { export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium"; +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; + timezone: string; +} + +export interface AppSettings { + gotify: GotifySettings; + ntfy: NtfySettings; + smtp: SmtpSettings; + webhook: WebhookSettings; + notifications: NotificationEvents; + providerColors: Record; +} + +export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; + export interface DnsProviderField { key: string; label: string; @@ -534,4 +586,18 @@ export const api = { storage: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/storage`), }, }, + settings: { + get: () => request<{ settings: AppSettings }>("/api/settings"), + providerColors: () => request<{ providerColors: Record }>("/api/settings/provider-colors"), + update: (data: AppSettingsPatch) => + request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }), + testGotify: (data: { url: string; token: string; priority?: number }) => + request<{ ok: true }>("/api/settings/test-gotify", { method: "POST", body: JSON.stringify(data) }), + testNtfy: (data: { url: string; topic: string; token?: string; priority?: number }) => + request<{ ok: true }>("/api/settings/test-ntfy", { method: "POST", body: JSON.stringify(data) }), + testSmtp: (data: { host: string; port?: number; secure?: boolean; username?: string; password?: string; from: string; to: string }) => + request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }), + testWebhook: (data: { url: string; secret?: string }) => + request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }), + }, }; diff --git a/web/src/pages/ComingSoon.tsx b/web/src/pages/ComingSoon.tsx deleted file mode 100644 index 79ca1d2..0000000 --- a/web/src/pages/ComingSoon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export default function ComingSoon({ title }: { title: string }) { - return ( - <> -

{title}

-
-
- This module hasn't been built yet — it's on the roadmap for a follow-up pass. -
-
- - ); -} diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index 90186b7..22f1379 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; import { api, type CurrentUser, @@ -21,6 +21,12 @@ const PROVIDER_LABELS: Record = { const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined }; +function providerBadgeStyle(colors: Record, providerType: DnsProviderType): CSSProperties { + const color = colors[providerType]; + if (!color) return {}; + return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` }; +} + export default function Dns({ user }: { user: CurrentUser }) { const isAdmin = user.role === "admin"; const canEdit = user.role === "admin" || user.role === "operator"; @@ -29,6 +35,11 @@ export default function Dns({ user }: { user: CurrentUser }) { const [managingProviders, setManagingProviders] = useState(false); const [selectedProviderId, setSelectedProviderId] = useState(null); const [error, setError] = useState(null); + const [providerColors, setProviderColors] = useState>({}); + + useEffect(() => { + api.settings.providerColors().then((res) => setProviderColors(res.providerColors)).catch(() => {}); + }, []); const [zones, setZones] = useState(null); const [zonesError, setZonesError] = useState(null); @@ -212,7 +223,11 @@ export default function Dns({ user }: { user: CurrentUser }) { {providers?.map((p) => ( {p.name} - {PROVIDER_LABELS[p.providerType]} + + + {PROVIDER_LABELS[p.providerType]} + + + + + + {loadError &&
{loadError}
} + +
+
+
+
+

Gotify

+
+
+

Send push notifications to a self-hosted Gotify instance.

+ +
+ + setGotify((g) => ({ ...g, url: e.target.value }))} + /> +
+
+ + setGotify((g) => ({ ...g, token: e.target.value }))} + /> +
+
+ + setGotify((g) => ({ ...g, priority: Number(e.target.value) }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

ntfy

+
+
+

+ Send notifications via ntfy.sh or a self-hosted instance. +

+ +
+
+ + setNtfy((n) => ({ ...n, url: e.target.value }))} + /> +
+
+ + setNtfy((n) => ({ ...n, priority: Number(e.target.value) }))} + /> +
+
+
+ + setNtfy((n) => ({ ...n, topic: e.target.value }))} + /> +
+
+ + setNtfy((n) => ({ ...n, token: e.target.value }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

Email (SMTP)

+
+
+

Send notifications via email using any SMTP server.

+ +
+
+ + setSmtp((s) => ({ ...s, host: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, port: Number(e.target.value) }))} + /> +
+
+ +
+
+ + setSmtp((s) => ({ ...s, username: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, password: e.target.value }))} + /> +
+
+
+
+ + setSmtp((s) => ({ ...s, from: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, to: e.target.value }))} + /> +
+
+ +
+
+ +
+
+
+ +
+
+
+

Webhook

+
+
+

+ POST a JSON payload to any HTTP endpoint. Uses Discord/Slack-compatible {"{ \"content\": \"…\" }"} format. +

+ +
+ + setWebhook((w) => ({ ...w, url: e.target.value }))} + /> +
+
+ + setWebhook((w) => ({ ...w, secret: e.target.value }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

Notify on

+
+
+

+ Controls which events trigger a notification on every enabled channel above. +

+ {[ + { key: "dnsAdd" as const, label: "DNS record added" }, + { key: "dnsUpdate" as const, label: "DNS record updated" }, + { key: "dnsDelete" as const, label: "DNS record deleted" }, + { key: "secretCheck" as const, label: "Secret expiry reminder" }, + ].map(({ key, label }) => ( + + ))} +
+
+ + setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))} + /> +
+
+ + +
+
+
+
+
+ +
+
+
+

DNS provider badge colors

+
+
+

Customize the color of each DNS provider's badge throughout the app.

+
+ {Object.entries(PROVIDER_NAMES).map(([id, name]) => { + const color = providerColors[id] ?? "#6366f1"; + return ( +
+ + {name} + + setProviderColors((c) => ({ ...c, [id]: e.target.value }))} + /> + {color} +
+ ); + })} +
+
+
+
+
+ + ); +}