mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -0,0 +1,224 @@
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
import type { AlertLink, ChannelType, Severity } from "../types.js";
|
||||
|
||||
export interface OutgoingAlert {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: Severity;
|
||||
category: string;
|
||||
source: string;
|
||||
link?: AlertLink | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
}
|
||||
|
||||
export interface SendMail {
|
||||
(
|
||||
smtp: SmtpSettings,
|
||||
message: {
|
||||
to: string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SenderDeps {
|
||||
fetch: PluginFetch;
|
||||
/** Hosts the admin lets channels reach on a private network. */
|
||||
privateAllowlist: () => Promise<string[]>;
|
||||
smtp: () => Promise<SmtpSettings | null>;
|
||||
sendMail: SendMail;
|
||||
}
|
||||
|
||||
const NTFY_PRIORITY: Record<Severity, number> = {
|
||||
info: 2,
|
||||
success: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
const NTFY_TAGS: Record<Severity, string> = {
|
||||
info: "information_source",
|
||||
success: "white_check_mark",
|
||||
warning: "warning",
|
||||
critical: "rotating_light",
|
||||
};
|
||||
|
||||
const DISCORD_COLORS: Record<Severity, number> = {
|
||||
info: 3447003,
|
||||
success: 3066993,
|
||||
warning: 16753920,
|
||||
critical: 15158332,
|
||||
};
|
||||
|
||||
export const DISCORD_WEBHOOK =
|
||||
/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i;
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export function parseRecipients(value: unknown): string[] {
|
||||
return str(value)
|
||||
.split(/[,;\s]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** What is wrong with a channel's config, or null when it can be saved. */
|
||||
export function validateChannelConfig(
|
||||
type: ChannelType,
|
||||
config: Record<string, unknown>,
|
||||
): string | null {
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return /^https?:\/\//i.test(str(config.url))
|
||||
? null
|
||||
: "webhook config requires an http(s) url";
|
||||
case "ntfy":
|
||||
if (!/^https?:\/\//i.test(str(config.url))) {
|
||||
return "ntfy config requires an http(s) url";
|
||||
}
|
||||
return str(config.topic) ? null : "ntfy config requires a topic";
|
||||
case "discord":
|
||||
return DISCORD_WEBHOOK.test(str(config.url))
|
||||
? null
|
||||
: "discord config requires a Discord webhook URL";
|
||||
case "email": {
|
||||
const to = parseRecipients(config.to);
|
||||
if (to.length === 0) return "email config requires at least one address";
|
||||
return to.every((address) => EMAIL.test(address))
|
||||
? null
|
||||
: "email config has an invalid address";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkUrl(alert: OutgoingAlert): string | undefined {
|
||||
const url = alert.link?.url;
|
||||
return url && /^https?:\/\//i.test(url) ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one alert to one channel and throws on failure, so the caller can say
|
||||
* which channel failed. Private and loopback targets need both the channel's
|
||||
* allowPrivateNetwork opt-in and an exact host in the admin allowlist.
|
||||
*/
|
||||
export async function sendToChannel(
|
||||
channel: { type: ChannelType; config: Record<string, unknown> },
|
||||
alert: OutgoingAlert,
|
||||
deps: SenderDeps,
|
||||
): Promise<void> {
|
||||
const { config } = channel;
|
||||
|
||||
if (channel.type === "email") {
|
||||
const smtp = await deps.smtp();
|
||||
if (!smtp) throw new Error("Email is not set up on this server");
|
||||
const to = parseRecipients(config.to);
|
||||
if (to.length === 0) throw new Error("Channel has no email address");
|
||||
const url = linkUrl(alert);
|
||||
await deps.sendMail(smtp, {
|
||||
to,
|
||||
subject: `[Termix] ${alert.title}`,
|
||||
text: [alert.body || alert.title, url].filter(Boolean).join("\n\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const allowPrivateHosts =
|
||||
config.allowPrivateNetwork === true ? await deps.privateAllowlist() : [];
|
||||
const post = async (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body: string },
|
||||
) => {
|
||||
const response = await deps.fetch(url, {
|
||||
...init,
|
||||
timeoutMs: 30_000,
|
||||
allowPrivateHosts,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const url = str(config.url);
|
||||
if (!url) throw new Error("Channel is missing a URL");
|
||||
const context = alert.context ?? {};
|
||||
|
||||
switch (channel.type) {
|
||||
case "webhook": {
|
||||
const headers =
|
||||
config.headers && typeof config.headers === "object"
|
||||
? (config.headers as Record<string, string>)
|
||||
: {};
|
||||
return post(url, {
|
||||
method: config.method === "PUT" ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify({
|
||||
title: alert.title,
|
||||
message: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
source: alert.source,
|
||||
link: linkUrl(alert),
|
||||
hostName: context.hostName,
|
||||
hostId: context.hostId,
|
||||
ruleName: context.sourceName ?? alert.title,
|
||||
ruleId: context.sourceId,
|
||||
triggerType: context.triggerType,
|
||||
value: context.value,
|
||||
threshold: context.threshold,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "ntfy": {
|
||||
const topic = str(config.topic);
|
||||
if (!topic) throw new Error("ntfy channel is missing a topic");
|
||||
const headers: Record<string, string> = {
|
||||
Title: alert.title || "Termix",
|
||||
Priority: String(NTFY_PRIORITY[alert.severity]),
|
||||
Tags: NTFY_TAGS[alert.severity],
|
||||
};
|
||||
const click = linkUrl(alert);
|
||||
if (click) headers.Click = click;
|
||||
const token = str(config.token);
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return post(`${url.replace(/\/$/, "")}/${encodeURIComponent(topic)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: alert.body || alert.title,
|
||||
});
|
||||
}
|
||||
case "discord": {
|
||||
const embed: Record<string, unknown> = {
|
||||
title: alert.title || "Termix",
|
||||
description: alert.body || undefined,
|
||||
color: DISCORD_COLORS[alert.severity],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const click = linkUrl(alert);
|
||||
if (click) embed.url = click;
|
||||
const payload: Record<string, unknown> = { embeds: [embed] };
|
||||
if (str(config.username)) payload.username = str(config.username);
|
||||
if (str(config.avatar_url)) payload.avatar_url = str(config.avatar_url);
|
||||
return post(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user