mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 13:17:57 +00:00
500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
import nodemailer from 'nodemailer';
|
|
import {
|
|
getEnabledNotificationSettings,
|
|
getEnabledEnvironmentNotifications,
|
|
getEnvironment,
|
|
type NotificationSettingData,
|
|
type SmtpConfig,
|
|
type AppriseConfig,
|
|
type NotificationEventType
|
|
} from './db';
|
|
|
|
export interface NotificationPayload {
|
|
title: string;
|
|
message: string;
|
|
type?: 'info' | 'success' | 'warning' | 'error';
|
|
environmentId?: number;
|
|
environmentName?: string;
|
|
}
|
|
|
|
// Send notification via SMTP
|
|
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<boolean> {
|
|
try {
|
|
const transporter = nodemailer.createTransport({
|
|
host: config.host,
|
|
port: config.port,
|
|
secure: config.secure,
|
|
auth: config.username ? {
|
|
user: config.username,
|
|
pass: config.password
|
|
} : undefined
|
|
});
|
|
|
|
const envBadge = payload.environmentName
|
|
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
|
: '';
|
|
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
|
|
|
const html = `
|
|
<div style="font-family: sans-serif; padding: 20px;">
|
|
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
|
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
|
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
|
</div>
|
|
`;
|
|
|
|
await transporter.sendMail({
|
|
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
|
|
to: config.to_emails.join(', '),
|
|
subject: `[Dockhand]${envText} ${payload.title}`,
|
|
text: `${payload.title}${envText}\n\n${payload.message}`,
|
|
html
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[Notifications] SMTP send failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Parse Apprise URL and send notification
|
|
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<boolean> {
|
|
let success = true;
|
|
|
|
for (const url of config.urls) {
|
|
try {
|
|
const sent = await sendToAppriseUrl(url, payload);
|
|
if (!sent) success = false;
|
|
} catch (error) {
|
|
console.error(`[Notifications] Failed to send to ${url}:`, error);
|
|
success = false;
|
|
}
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
// Send to a single Apprise URL
|
|
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<boolean> {
|
|
try {
|
|
// Extract protocol from Apprise URL format (protocol://...)
|
|
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
|
|
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
|
if (!protocolMatch) {
|
|
console.error('[Notifications] Invalid Apprise URL format - missing protocol:', url);
|
|
return false;
|
|
}
|
|
const protocol = protocolMatch[1].toLowerCase();
|
|
|
|
// Handle different notification services
|
|
switch (protocol) {
|
|
case 'discord':
|
|
case 'discords':
|
|
return await sendDiscord(url, payload);
|
|
case 'slack':
|
|
case 'slacks':
|
|
return await sendSlack(url, payload);
|
|
case 'tgram':
|
|
return await sendTelegram(url, payload);
|
|
case 'gotify':
|
|
case 'gotifys':
|
|
return await sendGotify(url, payload);
|
|
case 'ntfy':
|
|
case 'ntfys':
|
|
return await sendNtfy(url, payload);
|
|
case 'pushover':
|
|
return await sendPushover(url, payload);
|
|
case 'json':
|
|
case 'jsons':
|
|
return await sendGenericWebhook(url, payload);
|
|
default:
|
|
console.warn(`[Notifications] Unsupported Apprise protocol: ${protocol}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('[Notifications] Failed to parse Apprise URL:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Discord webhook
|
|
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// discord://webhook_id/webhook_token or discords://...
|
|
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
|
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
embeds: [{
|
|
title: titleWithEnv,
|
|
description: payload.message,
|
|
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
|
...(payload.environmentName && {
|
|
footer: { text: `Environment: ${payload.environmentName}` }
|
|
})
|
|
}]
|
|
})
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// Slack webhook
|
|
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// slack://token_a/token_b/token_c or webhook URL
|
|
let url: string;
|
|
if (appriseUrl.includes('hooks.slack.com')) {
|
|
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
|
|
} else {
|
|
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
|
|
url = `https://hooks.slack.com/services/${parts.join('/')}`;
|
|
}
|
|
|
|
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: `*${payload.title}*${envTag}\n${payload.message}`
|
|
})
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// Telegram
|
|
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// tgram://bot_token/chat_id
|
|
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
|
|
if (!match) {
|
|
console.error('[Notifications] Invalid Telegram URL format. Expected: tgram://bot_token/chat_id');
|
|
return false;
|
|
}
|
|
|
|
const [, botToken, chatId] = match;
|
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
|
|
const envTag = payload.environmentName ? ` \\[${payload.environmentName}\\]` : '';
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: chatId,
|
|
text: `*${payload.title}*${envTag}\n${payload.message}`,
|
|
parse_mode: 'Markdown'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
console.error('[Notifications] Telegram API error:', response.status, errorData);
|
|
}
|
|
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error('[Notifications] Telegram send failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Gotify
|
|
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// gotify://hostname/token or gotifys://hostname/token
|
|
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
|
if (!match) return false;
|
|
|
|
const [, hostname, token] = match;
|
|
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
|
const url = `${protocol}://${hostname}/message?token=${token}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: payload.title,
|
|
message: payload.message,
|
|
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
|
|
})
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// ntfy
|
|
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// ntfy://topic or ntfys://hostname/topic
|
|
let url: string;
|
|
const isSecure = appriseUrl.startsWith('ntfys');
|
|
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
|
|
|
if (path.includes('/')) {
|
|
// Custom server
|
|
url = `${isSecure ? 'https' : 'http'}://${path}`;
|
|
} else {
|
|
// Default ntfy.sh
|
|
url = `https://ntfy.sh/${path}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Title': payload.title,
|
|
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
|
|
'Tags': payload.type || 'info'
|
|
},
|
|
body: payload.message
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// Pushover
|
|
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// pushover://user_key/api_token
|
|
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
|
if (!match) return false;
|
|
|
|
const [, userKey, apiToken] = match;
|
|
const url = 'https://api.pushover.net/1/messages.json';
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
token: apiToken,
|
|
user: userKey,
|
|
title: payload.title,
|
|
message: payload.message,
|
|
priority: payload.type === 'error' ? 1 : 0
|
|
})
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// Generic JSON webhook
|
|
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
|
// json://hostname/path or jsons://hostname/path
|
|
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: payload.title,
|
|
message: payload.message,
|
|
type: payload.type || 'info',
|
|
timestamp: new Date().toISOString()
|
|
})
|
|
});
|
|
|
|
return response.ok;
|
|
}
|
|
|
|
// Send notification to all enabled channels
|
|
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
|
|
const settings = await getEnabledNotificationSettings();
|
|
const results: { name: string; success: boolean }[] = [];
|
|
|
|
for (const setting of settings) {
|
|
let success = false;
|
|
|
|
if (setting.type === 'smtp') {
|
|
success = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
|
} else if (setting.type === 'apprise') {
|
|
success = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
|
}
|
|
|
|
results.push({ name: setting.name, success });
|
|
}
|
|
|
|
return {
|
|
success: results.every(r => r.success),
|
|
results
|
|
};
|
|
}
|
|
|
|
// Test a specific notification setting
|
|
export async function testNotification(setting: NotificationSettingData): Promise<boolean> {
|
|
const payload: NotificationPayload = {
|
|
title: 'Dockhand Test Notification',
|
|
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
|
type: 'info'
|
|
};
|
|
|
|
if (setting.type === 'smtp') {
|
|
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
|
} else if (setting.type === 'apprise') {
|
|
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Map Docker action to notification event type
|
|
function mapActionToEventType(action: string): NotificationEventType | null {
|
|
const mapping: Record<string, NotificationEventType> = {
|
|
'start': 'container_started',
|
|
'stop': 'container_stopped',
|
|
'restart': 'container_restarted',
|
|
'die': 'container_exited',
|
|
'kill': 'container_exited',
|
|
'oom': 'container_oom',
|
|
'health_status: unhealthy': 'container_unhealthy',
|
|
'pull': 'image_pulled'
|
|
};
|
|
return mapping[action] || null;
|
|
}
|
|
|
|
// Scanner image patterns to exclude from notifications
|
|
const SCANNER_IMAGE_PATTERNS = [
|
|
'anchore/grype',
|
|
'aquasec/trivy',
|
|
'ghcr.io/anchore/grype',
|
|
'ghcr.io/aquasecurity/trivy'
|
|
];
|
|
|
|
function isScannerContainer(image: string | null | undefined): boolean {
|
|
if (!image) return false;
|
|
const lowerImage = image.toLowerCase();
|
|
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
|
}
|
|
|
|
// Send notification for an environment-specific event
|
|
export async function sendEnvironmentNotification(
|
|
environmentId: number,
|
|
action: string,
|
|
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
|
|
image?: string | null
|
|
): Promise<{ success: boolean; sent: number }> {
|
|
const eventType = mapActionToEventType(action);
|
|
if (!eventType) {
|
|
// Not a notifiable event type
|
|
return { success: true, sent: 0 };
|
|
}
|
|
|
|
// Get environment name
|
|
const env = await getEnvironment(environmentId);
|
|
if (!env) {
|
|
return { success: false, sent: 0 };
|
|
}
|
|
|
|
// Get enabled notification channels for this environment and event type
|
|
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
|
if (envNotifications.length === 0) {
|
|
return { success: true, sent: 0 };
|
|
}
|
|
|
|
const enrichedPayload: NotificationPayload = {
|
|
...payload,
|
|
environmentId,
|
|
environmentName: env.name
|
|
};
|
|
|
|
// Check if this is a scanner container
|
|
const isScanner = isScannerContainer(image);
|
|
|
|
let sent = 0;
|
|
let allSuccess = true;
|
|
|
|
// Skip all notifications for scanner containers (Trivy, Grype)
|
|
if (isScanner) {
|
|
return { success: true, sent: 0 };
|
|
}
|
|
|
|
for (const notif of envNotifications) {
|
|
try {
|
|
let success = false;
|
|
if (notif.channelType === 'smtp') {
|
|
success = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
|
} else if (notif.channelType === 'apprise') {
|
|
success = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
|
}
|
|
if (success) sent++;
|
|
else allSuccess = false;
|
|
} catch (error) {
|
|
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, error);
|
|
allSuccess = false;
|
|
}
|
|
}
|
|
|
|
return { success: allSuccess, sent };
|
|
}
|
|
|
|
// Send notification for a specific event type (not mapped from Docker action)
|
|
// Used for auto-update, git sync, vulnerability, and system events
|
|
export async function sendEventNotification(
|
|
eventType: NotificationEventType,
|
|
payload: NotificationPayload,
|
|
environmentId?: number
|
|
): Promise<{ success: boolean; sent: number }> {
|
|
// Get environment name if provided
|
|
let enrichedPayload = { ...payload };
|
|
if (environmentId) {
|
|
const env = await getEnvironment(environmentId);
|
|
if (env) {
|
|
enrichedPayload.environmentId = environmentId;
|
|
enrichedPayload.environmentName = env.name;
|
|
}
|
|
}
|
|
|
|
// Get enabled notification channels for this event type
|
|
let channels: Array<{
|
|
channel_type: 'smtp' | 'apprise';
|
|
channel_name: string;
|
|
config: SmtpConfig | AppriseConfig;
|
|
}> = [];
|
|
|
|
if (environmentId) {
|
|
// Environment-specific: get channels subscribed to this env and event type
|
|
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
|
channels = envNotifications
|
|
.filter(n => n.channelType && n.channelName)
|
|
.map(n => ({
|
|
channel_type: n.channelType!,
|
|
channel_name: n.channelName!,
|
|
config: n.config
|
|
}));
|
|
} else {
|
|
// System-wide: get all globally enabled channels that subscribe to this event type
|
|
const globalSettings = await getEnabledNotificationSettings();
|
|
channels = globalSettings
|
|
.filter(s => s.eventTypes?.includes(eventType))
|
|
.map(s => ({
|
|
channel_type: s.type,
|
|
channel_name: s.name,
|
|
config: s.config
|
|
}));
|
|
}
|
|
|
|
if (channels.length === 0) {
|
|
return { success: true, sent: 0 };
|
|
}
|
|
|
|
let sent = 0;
|
|
let allSuccess = true;
|
|
|
|
for (const channel of channels) {
|
|
try {
|
|
let success = false;
|
|
if (channel.channel_type === 'smtp') {
|
|
success = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
|
} else if (channel.channel_type === 'apprise') {
|
|
success = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
|
}
|
|
if (success) sent++;
|
|
else allSuccess = false;
|
|
} catch (error) {
|
|
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, error);
|
|
allSuccess = false;
|
|
}
|
|
}
|
|
|
|
return { success: allSuccess, sent };
|
|
}
|