diff --git a/backend/diag-log.json b/backend/diag-log.json index 3ecee59..80392dc 100644 --- a/backend/diag-log.json +++ b/backend/diag-log.json @@ -1,4 +1,28 @@ [ + { + "id": "1780770465729-72o2", + "timestamp": "2026-06-06T18:27:45.730Z", + "provider": "cpanel", + "operation": "GET /execute/DomainInfo/list_domains", + "method": "GET", + "url": "https://cpsrv32.misshosting.com/execute/DomainInfo/list_domains", + "status": 200, + "latency": 18, + "ok": true, + "error": null + }, + { + "id": "1780770465709-j66b", + "timestamp": "2026-06-06T18:27:45.709Z", + "provider": "cpanel", + "operation": "GET /execute/DNS/list_zones", + "method": "GET", + "url": "https://cpsrv32.misshosting.com/execute/DNS/list_zones", + "status": 200, + "latency": 82, + "ok": true, + "error": null + }, { "id": "1780397326074-s7tw", "timestamp": "2026-06-02T10:48:46.074Z", diff --git a/backend/package-lock.json b/backend/package-lock.json index 9fff638..0ea886d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "express": "^4.19.2", "jsonwebtoken": "^9.0.2", "node-schedule": "^2.1.1", + "nodemailer": "^6.9.14", "xml2js": "^0.6.2", "xmlbuilder2": "^3.1.1" }, @@ -1008,6 +1009,15 @@ "node": ">=6" } }, + "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/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index bffec8e..5bdc1c3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "bcryptjs": "^2.4.3", + "nodemailer": "^6.9.14", "node-schedule": "^2.1.1", "jsonwebtoken": "^9.0.2", "xml2js": "^0.6.2", diff --git a/backend/src/notify.js b/backend/src/notify.js index d57b1ec..4e2ce1f 100644 --- a/backend/src/notify.js +++ b/backend/src/notify.js @@ -1,69 +1,162 @@ -const settings = require('./settings'); -const db = require('./db'); +const settings = require('./settings'); +const db = require('./db'); +const nodemailer = require('nodemailer'); -/** - * Send a Gotify notification if enabled and configured. - * Errors are logged but never thrown — notifications are best-effort. - */ -async function notify(title, message) { +// ─── Channel senders ────────────────────────────────────────────────────────── + +async function sendGotify(title, message) { const { gotify } = settings.get(); - 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, - }), + body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }), }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - console.error(`[notify] Gotify error ${res.status}: ${text}`); - } + if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => '')}`); } catch (err) { - console.error('[notify] Failed to send Gotify notification:', err.message); + console.error('[notify] Gotify failed:', err.message); } } -// ─── Convenience helpers ───────────────────────────────────────────────────── +async function sendNtfy(title, message) { + const { ntfy } = settings.get(); + if (!ntfy.enabled || !ntfy.url || !ntfy.topic) return; + const base = ntfy.url.replace(/\/$/, ''); + const headers = { + '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.message); + } +} + +async function sendSmtp(title, message) { + const { smtp } = settings.get(); + 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 === true || smtp.secure === 'true', + 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.message); + } +} + +async function sendWebhook(title, message) { + const { webhook } = settings.get(); + if (!webhook.enabled || !webhook.url) return; + const body = JSON.stringify({ content: `**${title}**\n${message}` }); + const headers = { 'Content-Type': 'application/json' }; + if (webhook.secret) headers['X-Webhook-Secret'] = webhook.secret; + try { + const res = await fetch(webhook.url, { method: 'POST', headers, body }); + if (!res.ok) console.error(`[notify] Webhook error ${res.status}: ${await res.text().catch(() => '')}`); + } catch (err) { + console.error('[notify] Webhook failed:', err.message); + } +} + +// ─── Broadcast to all enabled channels ─────────────────────────────────────── + +async function notify(title, message) { + await Promise.all([ + sendGotify(title, message), + sendNtfy(title, message), + sendSmtp(title, message), + sendWebhook(title, message), + ]); +} + +// ─── Exported senders for test routes ──────────────────────────────────────── + +async function testGotify(cfg) { + 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: '🦥 Sloth 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(() => '')}`); +} + +async function testNtfy(cfg) { + const base = cfg.url.replace(/\/$/, ''); + const headers = { 'Content-Type': 'text/plain', Title: '🦥 Sloth 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(() => '')}`); +} + +async function testSmtp(cfg) { + const transporter = nodemailer.createTransport({ + host: cfg.host, + port: Number(cfg.port) || 587, + secure: cfg.secure === true || cfg.secure === 'true', + auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined, + }); + await transporter.verify(); + await transporter.sendMail({ + from: cfg.from, + to: cfg.to, + subject: '🦥 Sloth Manager — Test', + text: 'SMTP notifications are working correctly.', + }); +} + +async function testWebhook(cfg) { + const headers = { '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: '**🦥 Sloth Manager — Test**\nWebhook notifications are working correctly.' }), + }); + if (!res.ok) throw new Error(`Webhook returned ${res.status}: ${await res.text().catch(() => '')}`); +} + +// ─── Event helpers ──────────────────────────────────────────────────────────── function eventEnabled(key) { - const { notifications } = settings.get(); - return notifications?.[key] !== false; + return settings.get().notifications?.[key] !== false; } function recordAdded(provider, zoneId, record) { if (!eventEnabled('dns_add')) return; const zoneName = db.getZoneName(provider, zoneId); - return notify( - `DNS Record Added`, - `[${provider}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`, - ); + return notify(`DNS Record Added`, `[${provider}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`); } function recordUpdated(provider, zoneId, oldRecord, newRecord) { if (!eventEnabled('dns_update')) return; const zoneName = db.getZoneName(provider, zoneId); - return notify( - `DNS Record Updated`, - `[${provider}] ${zoneName}\n✎ ${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content} → ${newRecord.content}`, - ); + return notify(`DNS Record Updated`, `[${provider}] ${zoneName}\n✎ ${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content} → ${newRecord.content}`); } function recordDeleted(provider, zoneId, record) { if (!eventEnabled('dns_delete')) return; const zoneName = db.getZoneName(provider, zoneId); - return notify( - `DNS Record Deleted`, - `[${provider}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`, - ); + return notify(`DNS Record Deleted`, `[${provider}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`); } -module.exports = { notify, recordAdded, recordUpdated, recordDeleted }; +module.exports = { notify, testGotify, testNtfy, testSmtp, testWebhook, recordAdded, recordUpdated, recordDeleted }; diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 8980d55..967425e 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const settings = require('../settings'); -const { notify } = require('../notify'); +const { testGotify, testNtfy, testSmtp, testWebhook } = require('../notify'); // GET /api/settings router.get('/', (req, res) => { @@ -12,8 +12,7 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { const updated = settings.update(req.body); - // Reschedule secret check if the notification time changed - if (req.body.notifications?.secret_check_time) { + if (req.body.notifications?.secret_check_time || req.body.notifications?.timezone) { try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ } } res.json(updated); @@ -22,36 +21,51 @@ router.put('/', (req, res) => { } }); -// POST /api/settings/test-notification -router.post('/test-notification', async (req, res) => { - // Use the payload from the request body so the user can test - // before saving (the frontend sends the current form values) +// POST /api/settings/test-gotify +router.post('/test-gotify', async (req, res) => { const { url, token, priority } = req.body; - - if (!url || !token) { - return res.status(400).json({ error: 'URL and token are required' }); - } - - const base = url.replace(/\/$/, ''); + if (!url || !token) return res.status(400).json({ error: 'URL and token are required' }); try { - const response = await fetch(`${base}/message?token=${encodeURIComponent(token)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: '🦥 Sloth Manager — Test', - message: 'Gotify notifications are working correctly.', - priority: priority ?? 5, - }), - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - return res.status(502).json({ error: `Gotify returned ${response.status}: ${text}` }); - } - + await testGotify({ url, token, priority }); res.json({ success: true }); } catch (err) { - res.status(502).json({ error: `Could not reach Gotify: ${err.message}` }); + res.status(502).json({ error: err.message }); + } +}); + +// POST /api/settings/test-ntfy +router.post('/test-ntfy', async (req, res) => { + const { url, topic, token, priority } = req.body; + if (!url || !topic) return res.status(400).json({ error: 'URL and topic are required' }); + try { + await testNtfy({ url, topic, token, priority }); + res.json({ success: true }); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// POST /api/settings/test-smtp +router.post('/test-smtp', async (req, res) => { + const { host, port, secure, username, password, from, to } = req.body; + if (!host || !from || !to) return res.status(400).json({ error: 'Host, from, and to are required' }); + try { + await testSmtp({ host, port, secure, username, password, from, to }); + res.json({ success: true }); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// POST /api/settings/test-webhook +router.post('/test-webhook', async (req, res) => { + const { url, secret } = req.body; + if (!url) return res.status(400).json({ error: 'Webhook URL is required' }); + try { + await testWebhook({ url, secret }); + res.json({ success: true }); + } catch (err) { + res.status(502).json({ error: err.message }); } }); diff --git a/backend/src/settings.js b/backend/src/settings.js index b6c45f9..1e11e79 100644 --- a/backend/src/settings.js +++ b/backend/src/settings.js @@ -18,6 +18,28 @@ const DEFAULTS = { secret_check_time: '08:00', timezone: 'UTC', }, + 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: '', + }, providerColors: { cloudflare: '#f6821f', loopia: '#2ecc71', @@ -36,6 +58,9 @@ function load() { ...raw, gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) }, notifications: { ...DEFAULTS.notifications, ...(raw.notifications ?? {}) }, + ntfy: { ...DEFAULTS.ntfy, ...(raw.ntfy ?? {}) }, + smtp: { ...DEFAULTS.smtp, ...(raw.smtp ?? {}) }, + webhook: { ...DEFAULTS.webhook, ...(raw.webhook ?? {}) }, providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) }, }; } catch { @@ -58,6 +83,9 @@ function update(partial) { ...partial, gotify: { ...current.gotify, ...(partial.gotify ?? {}) }, notifications: { ...current.notifications, ...(partial.notifications ?? {}) }, + ntfy: { ...current.ntfy, ...(partial.ntfy ?? {}) }, + smtp: { ...current.smtp, ...(partial.smtp ?? {}) }, + webhook: { ...current.webhook, ...(partial.webhook ?? {}) }, providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) }, }; save(merged); diff --git a/frontend/src/api/dns.js b/frontend/src/api/dns.js index 10403f0..64d4f8a 100644 --- a/frontend/src/api/dns.js +++ b/frontend/src/api/dns.js @@ -190,12 +190,33 @@ export async function clearCache() { } export async function testNotification(gotify) { - return handleResponse(await fetch(`${BASE}/settings/test-notification`, { + return handleResponse(await fetch(`${BASE}/settings/test-gotify`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(gotify), })); } +export async function testNtfyNotification(ntfy) { + return handleResponse(await fetch(`${BASE}/settings/test-ntfy`, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(ntfy), + })); +} + +export async function testSmtpNotification(smtp) { + return handleResponse(await fetch(`${BASE}/settings/test-smtp`, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(smtp), + })); +} + +export async function testWebhookNotification(webhook) { + return handleResponse(await fetch(`${BASE}/settings/test-webhook`, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(webhook), + })); +} + export async function getSyncStatus(provider) { return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() })); } diff --git a/frontend/src/components/SettingsPage.js b/frontend/src/components/SettingsPage.js index e01013d..8621f9d 100644 --- a/frontend/src/components/SettingsPage.js +++ b/frontend/src/components/SettingsPage.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { getSettings, saveSettings, testNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns'; +import { getSettings, saveSettings, testNotification, testNtfyNotification, testSmtpNotification, testWebhookNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns'; import ConfirmDialog from './ConfirmDialog'; import { exportCsv } from '../utils/exportCsv'; import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors'; @@ -16,18 +16,30 @@ const TABS = [ function NotificationsTab() { const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 }); + const [ntfy, setNtfy] = useState({ enabled: false, url: 'https://ntfy.sh', topic: '', token: '', priority: 3 }); + const [smtp, setSmtp] = useState({ enabled: false, host: '', port: 587, secure: false, username: '', password: '', from: '', to: '' }); + const [webhook, setWebhook] = useState({ enabled: false, url: '', secret: '' }); const [events, setEvents] = useState({ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [testingNtfy, setTestingNtfy] = useState(false); + const [testingSmtp, setTestingSmtp] = useState(false); + const [testingWebhook, setTestingWebhook] = useState(false); const [saved, setSaved] = useState(false); - const [testResult, setTestResult] = useState(null); + const [testResult, setTestResult] = useState(null); + const [testResultNtfy, setTestResultNtfy] = useState(null); + const [testResultSmtp, setTestResultSmtp] = useState(null); + const [testResultWebhook, setTestResultWebhook] = useState(null); const [error, setError] = useState(''); useEffect(() => { getSettings() .then(s => { - setForm({ ...s.gotify }); + setForm({ ...{ enabled: false, url: '', token: '', priority: 5 }, ...s.gotify }); + setNtfy({ ...{ enabled: false, url: 'https://ntfy.sh', topic: '', token: '', priority: 3 }, ...s.ntfy }); + setSmtp({ ...{ enabled: false, host: '', port: 587, secure: false, username: '', password: '', from: '', to: '' }, ...s.smtp }); + setWebhook({ ...{ enabled: false, url: '', secret: '' }, ...s.webhook }); setEvents({ ...{ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }, ...s.notifications }); }) .catch(e => setError(e.message)) @@ -51,7 +63,7 @@ function NotificationsTab() { e.preventDefault(); setSaving(true); setError(''); setSaved(false); try { - await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, notifications: events }); + await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, ntfy: { ...ntfy, priority: Number(ntfy.priority) }, smtp: { ...smtp, port: Number(smtp.port) }, webhook, notifications: events }); setSaved(true); setTimeout(() => setSaved(false), 3000); } catch (err) { setError(err.message); } @@ -68,6 +80,36 @@ function NotificationsTab() { } finally { setTesting(false); } } + async function handleTestNtfy() { + setTestingNtfy(true); setTestResultNtfy(null); + try { + await testNtfyNotification({ url: ntfy.url, topic: ntfy.topic, token: ntfy.token, priority: Number(ntfy.priority) }); + setTestResultNtfy({ ok: true, message: 'Notification sent successfully.' }); + } catch (err) { + setTestResultNtfy({ ok: false, message: err.message }); + } finally { setTestingNtfy(false); } + } + + async function handleTestSmtp() { + setTestingSmtp(true); setTestResultSmtp(null); + try { + await testSmtpNotification({ ...smtp, port: Number(smtp.port) }); + setTestResultSmtp({ ok: true, message: 'Test email sent successfully.' }); + } catch (err) { + setTestResultSmtp({ ok: false, message: err.message }); + } finally { setTestingSmtp(false); } + } + + async function handleTestWebhook() { + setTestingWebhook(true); setTestResultWebhook(null); + try { + await testWebhookNotification({ url: webhook.url, secret: webhook.secret }); + setTestResultWebhook({ ok: true, message: 'Webhook delivered successfully.' }); + } catch (err) { + setTestResultWebhook({ ok: false, message: err.message }); + } finally { setTestingWebhook(false); } + } + if (loading) return

Loading…

; return ( @@ -167,14 +209,98 @@ function NotificationsTab() {
-

About Gotify

+

ntfy

+

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

+
+ +
+ + +
+
+ + +
+ {testResultNtfy &&

{testResultNtfy.ok ? '✓' : '✕'} {testResultNtfy.message}

} +
+ +
+
+
+ +
+

Email (SMTP)

+

Send notifications via email using any SMTP server.

+
+ +
+ + +
+ +
+ + +
+
+ + +
+ {testResultSmtp &&

{testResultSmtp.ok ? '✓' : '✕'} {testResultSmtp.message}

} +
+ +
+
+
+ +
+

Webhook

+

+ POST a JSON payload to any HTTP endpoint. Compatible with Discord, Slack, and custom receivers. For Discord use a Webhook URL from Server Settings → Integrations → Webhooks. +

+
+ + + + {testResultWebhook &&

{testResultWebhook.ok ? '✓' : '✕'} {testResultWebhook.message}

} +
+ +
+
+
+ +
+

About Notifications

- Gotify is a self-hosted push notification server.

- To get started:
- 1. Log in to your Gotify instance
- 2. Go to Apps → Create application
- 3. Copy the generated token and paste it into App Token

- Notifications fire on every record add, update, or delete — not on sync. + Sloth Manager can deliver notifications through four independent channels — enable as many as you need. All enabled channels receive the same events simultaneously.

+ + Gotify — self-hosted push notification server. Create an application in your Gotify instance and paste the app token here. gotify.net

+ + ntfy — open-source pub/sub push service. Use the public ntfy.sh server or a self-hosted instance. Set a topic name and optionally an access token for protected topics.

+ + Email (SMTP) — send notifications via any SMTP server. TLS/SSL (port 465) and STARTTLS (port 587) are both supported. Username and password are optional for open relays.

+ + Webhook — POST a JSON payload to any HTTP endpoint. The body uses Discord's {`{"content":"…"}`} format, which is also accepted by many other services. An optional shared secret is sent as the X-Webhook-Secret header for request verification.

+ + Notify on controls which events trigger a notification. DNS record events fire immediately. The secret expiry reminder runs once daily at the configured time and timezone, sending one message listing all expiring or expired secrets.

+ + Sync operations never trigger notifications regardless of settings.