From 2eef97509fd5192592cdc1cc2c3210ffd880ec2c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 18 Dec 2024 16:58:22 +0000 Subject: [PATCH] feat: Implement Apprise Alerter mbecker20/komodo#226 --- README.md | 8 +- notifiers/apprise/Dockerfile | 19 +++ notifiers/apprise/README.md | 13 ++ notifiers/apprise/main.ts | 3 + notifiers/apprise/program.ts | 234 +++++++++++++++++++++++++++++++ notifiers/common/atomic.ts | 5 + notifiers/common/errorUtils.ts | 0 notifiers/common/networkUtils.ts | 49 +++++++ notifiers/common/stringUtils.ts | 77 ++++++++++ notifiers/runners/apprise.ts | 26 ++++ 10 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 notifiers/apprise/Dockerfile create mode 100644 notifiers/apprise/README.md create mode 100644 notifiers/apprise/main.ts create mode 100644 notifiers/apprise/program.ts create mode 100644 notifiers/common/atomic.ts create mode 100644 notifiers/common/errorUtils.ts create mode 100644 notifiers/common/networkUtils.ts create mode 100644 notifiers/common/stringUtils.ts create mode 100644 notifiers/runners/apprise.ts diff --git a/README.md b/README.md index 400642b..a7b326b 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,10 @@ An [Alerter](https://komo.do/docs/resources#alerter) that pushes to [ntfy](https An [Alerter](https://komo.do/docs/resources#alerter) that pushes to a [Discord Webhook](https://discordjs.guide/popular-topics/webhooks.html#what-is-a-webhook) -[See README](/notifiers/discord/README.md) \ No newline at end of file +[See README](/notifiers/discord/README.md) + +## Apprise API Webhook Alerter + +An [Alerter](https://komo.do/docs/resources#alerter) that pushes to [Apprise](https://github.com/caronc/apprise) using [Apprise API](https://github.com/caronc/apprise-api) + +[See README](/notifiers/apprise/README.md) \ No newline at end of file diff --git a/notifiers/apprise/Dockerfile b/notifiers/apprise/Dockerfile new file mode 100644 index 0000000..d0901d7 --- /dev/null +++ b/notifiers/apprise/Dockerfile @@ -0,0 +1,19 @@ +FROM denoland/deno:2.0.4 + +# The port that your application listens to. +EXPOSE 7000 + +WORKDIR /app + +# Prefer not to run as root. +USER deno + +# These steps will be re-run upon each file change in your working directory: +COPY common/ common/ +COPY notifiers/common/ notifiers/common/ +COPY notifiers/apprise/ notifiers/apprise/ + +RUN deno install --entrypoint notifiers/apprise/main.ts +RUN deno cache notifiers/apprise/main.ts + +CMD ["run", "--allow-net", "--allow-env", "notifiers/apprise/main.ts"] \ No newline at end of file diff --git a/notifiers/apprise/README.md b/notifiers/apprise/README.md new file mode 100644 index 0000000..0bf410c --- /dev/null +++ b/notifiers/apprise/README.md @@ -0,0 +1,13 @@ +A [Komodo](https://komo.do/) [Alerter](https://komo.do/docs/resources#alerter) client for [Apprise](https://github.com/caronc/apprise) using [Apprise API](https://github.com/caronc/apprise-api) + +# Usage + +See [Komodohub deploy instructions](https://github.com/FoxxMD/deploy-apprise-alerter) + +# Building + +Run from the repository top-level folder: + +```shell +docker build -t komodo-apprise-alerter -f notifiers/apprise/Dockerfile . +``` \ No newline at end of file diff --git a/notifiers/apprise/main.ts b/notifiers/apprise/main.ts new file mode 100644 index 0000000..4b15338 --- /dev/null +++ b/notifiers/apprise/main.ts @@ -0,0 +1,3 @@ +import { program } from "./program.ts"; + +await program(); diff --git a/notifiers/apprise/program.ts b/notifiers/apprise/program.ts new file mode 100644 index 0000000..b971196 --- /dev/null +++ b/notifiers/apprise/program.ts @@ -0,0 +1,234 @@ +import { Types } from "npm:komodo_client"; +import { CommonAlert, parseAlert } from "../common/alertParser.ts"; +import { alertResolvedAllowed, parseOptions } from "../common/options.ts"; +import { URLData } from "../common/atomic.ts"; +import { nonEmptyStringOrDefault, normalizeWebAddress, parseArrayFromMaybeString, truncateStringToLength } from "../common/stringUtils.ts"; +import { isPortReachable, joinedUrl } from "../common/networkUtils.ts"; +import { titleAndSubtitle } from "../common/notifierBuilder.ts"; + +interface AppriseOptions { + endpoint: URLData + urls: string[] + keys: string[] + tag?: string +} + +const upstreamFailureHint = 'HINT: Status 424 means a dependency upstream of Apprise failed. This is usually a connection or authentication issue. Check Apprise logs to see more details.'; + +const shortKey = truncateStringToLength(10); + +const parseAppriseOptions = async (): Promise => { + + const host = nonEmptyStringOrDefault(Deno.env.get("APPRISE_HOST") as string); + + if(host === undefined) { + throw new Error(`'APPRISE_HOST' must be defined`); + } + + const urls: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_STATELESS_URLS"), '')); + const keys: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_PERSISTENT_KEYS"), '')); + + if(urls.length === 0 && keys.length === 0) { + console.warn(`No 'APPRISE_STATELESS_URLS' or 'APPRISE_PERSISTENT_KEYS' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`); + } + + const tag: string | undefined = nonEmptyStringOrDefault(Deno.env.get("APPRISE_TAG")); + + // check url is correct as a courtesy + const endpoint = normalizeWebAddress(host); + console.debug(`Apprise Host Config URL: '${host}' => Normalized: '${endpoint.normal}'`) + + try { + await isPortReachable(endpoint.port, { host: endpoint.url.hostname }); + } catch (e) { + console.warn(new Error('Unable to detect if server is reachable', { cause: e })); + return {endpoint, urls, keys, tag}; + } + + if (keys.length > 0) { + let anyOk = false; + for (const key of keys) { + try { + const resp = await fetch(joinedUrl(endpoint.url, `/json/urls/${key}`).toString()); + if (resp.status === 204) { + console.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`); + } else { + anyOk = true; + } + } catch (e) { + console.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e})); + } + } + if (!anyOk) { + console.warn('No Apprise Configs were valid!'); + } + } + + return {endpoint, urls, keys, tag}; +} + +const callApi = async (req: Request): Promise => { + let resp: Response | undefined; + try { + const resp = await fetch(req); + if(!resp.ok) { + let text: string; + try { + text = await resp.text(); + } catch (e) { + console.debug(new Error('Could not parse body from response', {cause: e})); + throw new Error(`Apprise response NOT OK! Status ${resp.status}`); + } + let json: object; + try { + json = JSON.parse(text); + } catch (e) { + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`); + } + + if('error' in json) { + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Error => ${json.error}`); + } else { + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`); + } + } + return resp; + } catch (e) { + if(resp !== undefined) { + console.debug(resp); + } + throw e; + } +} + +const program = async () => { + + let appriseOptions: AppriseOptions; + const commonOpts = parseOptions(); + + try { + appriseOptions = await parseAppriseOptions(); + } catch (e) { + throw new Error('Could not parse Apprise options', {cause: e}); + } + + const {keys, urls, endpoint, tag} = appriseOptions; + + let configSummary: string[] = [`Using Apprise @ ${endpoint.normal}`]; + if(urls.length === 0 && keys.length === 0) { + configSummary.push(`Pushing to stateless endpoint (to '/notify')`) + } else { + if(urls.length > 0) { + configSummary.push(`Pushing to stateless URLs '${urls.join(',')}'`); + } + if(keys.length > 0) { + configSummary.push(`Pushing to persistent keys '${keys.join(',')}'`); + } + } + if(tag !== undefined) { + configSummary.push(`With tag '${tag}'`); + } + console.debug(configSummary.join('\n')); + + const pushAlert = async ( + data: CommonAlert, + level: Types.SeverityLevel, + ): Promise => { + let notifyType: string; + switch (level) { + case Types.SeverityLevel.Ok: + notifyType ='info'; + break; + case Types.SeverityLevel.Warning: + notifyType = 'warning'; + break; + case Types.SeverityLevel.Critical: + notifyType = 'failure'; + break; + } + + const body: Record = { + title: titleAndSubtitle(data), + body: data.message, + type: notifyType + } + if(tag !== undefined) { + body.tag = tag; + } + + const requestOpts: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + if(keys.length > 0) { + for(const k of keys) { + try { + const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify/${k}`).toString(), { + ...requestOpts, + body: JSON.stringify(body) + })); + await resp.body?.cancel(); + // @ts-expect-error + } catch (e: Error) { + console.error(new Error(`Failed to send notification with key ${k}${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e})); + } + } + } + + if(urls.length > 0 || keys.length === 0) { + const urlBody = {...body}; + if(urls.length > 0) { + urlBody.urls = urls.join(','); + } + try { + const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify`).toString(), { + ...requestOpts, + body: JSON.stringify(urlBody) + })); + await resp.body?.cancel(); + // @ts-expect-error + } catch (e: Error) { + console.error(new Error(`Failed to send notification using URLs${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e})); + } + } + }; + + const server = Deno.serve({ port: 7000 }, async (req) => { + const alert: Types.Alert = await req.json(); + console.log(`Recieved data from ${req.headers.get("host")}...`); + + let data: CommonAlert; + + try { + data = parseAlert(alert, { ...commonOpts }); + } catch (e) { + console.debug("Komodo Alert Payload:", alert); + console.error(e); + return new Response(); + } + + if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { + console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); + return new Response(); + } + + try { + await pushAlert(data, alert.level); + } catch (e) { + console.debug("Komodo Alert Payload:", alert); + console.error( + new Error("Failed to push Alert to Apprise", { cause: e }), + ); + } + + return new Response(); + }); + + return server; +}; + +export { program }; diff --git a/notifiers/common/atomic.ts b/notifiers/common/atomic.ts new file mode 100644 index 0000000..ad2fdd2 --- /dev/null +++ b/notifiers/common/atomic.ts @@ -0,0 +1,5 @@ +export interface URLData { + url: URL + normal: string + port: number +} \ No newline at end of file diff --git a/notifiers/common/errorUtils.ts b/notifiers/common/errorUtils.ts new file mode 100644 index 0000000..e69de29 diff --git a/notifiers/common/networkUtils.ts b/notifiers/common/networkUtils.ts new file mode 100644 index 0000000..b106227 --- /dev/null +++ b/notifiers/common/networkUtils.ts @@ -0,0 +1,49 @@ +import net from 'node:net'; +import { join as joinPath } from "node:path"; + +export interface PortReachableOpts { + host: string, + timeout?: number +} +/** + * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting + * */ +export const isPortReachable = async (port: number, opts: PortReachableOpts) => { + const {host, timeout = 1000} = opts; + + const promise = new Promise(((resolve, reject) => { + const socket = new net.Socket(); + + const onError = (e: Error) => { + socket.destroy(); + reject(e); + }; + const onTimeout = () => { + socket.destroy(); + reject(new Error(`Connection timed out after ${timeout}ms`)); + } + + socket.setTimeout(timeout); + socket.once('error', onError); + socket.once('timeout', onTimeout); + + socket.connect(port, host, () => { + socket.end(); + resolve(true); + }); + })); + + try { + await promise; + return true; + } catch (e) { + throw e; + } +} + +export const joinedUrl = (url: URL, ...paths: string[]): URL => { + // https://github.com/jfromaniello/url-join#in-nodejs + const finalUrl = new URL(url); + finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); + return finalUrl; +} \ No newline at end of file diff --git a/notifiers/common/stringUtils.ts b/notifiers/common/stringUtils.ts new file mode 100644 index 0000000..22abc34 --- /dev/null +++ b/notifiers/common/stringUtils.ts @@ -0,0 +1,77 @@ +import { parseRegexSingle } from "npm:@foxxmd/regex-buddy-core@0.1.2"; +import normalizeUrl from "npm:normalize-url@8.0.1"; +import { URLData } from "./atomic.ts"; + +const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); + +export const normalizeWebAddress = (val: string): URLData => { + let cleanUserUrl = val.trim(); + const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); + if (results !== undefined && results.groups && results.groups.length > 0) { + cleanUserUrl = results.groups[0]; + } + + let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true}); + const u = new URL(normal); + let port: number; + + if (u.port === '') { + port = u.protocol === 'https:' ? 443 : 80; + } else { + port = parseInt(u.port); + // if user val does not include protocol and port is 443 then auto set to https + if(port === 443 && !val.includes('http')) { + if(u.protocol === 'http:') { + u.protocol = 'https:'; + } + normal = normal.replace('http:', 'https:'); + } + } + return { + url: u, + normal, + port + } +} + +export const truncateStringToLength = (length: any, truncStr = '...') => (val: any = '') => { + if (val === null) { + return ''; + } + const str = typeof val !== 'string' ? val.toString() : val; + return str.length > length ? `${str.slice(0, length)}${truncStr}` : str; +} + +/** + * Returns value if it is a non-empty string or returns default value + * */ +export const nonEmptyStringOrDefault = (str: any, + // @ts-expect-error this is fine + defaultVal: T = undefined): string | T => { + if (str === undefined || str === null || typeof str !== 'string' || str.trim() === '') { + return defaultVal; + } + return str; +} + +interface ArrParseOpts { + lower?: boolean + split?: string +} + +export const parseArrayFromMaybeString = (value: string | string[] = '', opts: ArrParseOpts = {}) => { + const {lower = false, split = ','} = opts; + let arr: string[] = []; + if (Array.isArray(value)) { + arr = value; + } else if (value.trim() === '') { + return []; + } else { + arr = value.split(split); + } + arr = arr.map(x => x.trim()); + if (lower) { + arr = arr.map(x => x.toLowerCase()); + } + return arr; +} \ No newline at end of file diff --git a/notifiers/runners/apprise.ts b/notifiers/runners/apprise.ts new file mode 100644 index 0000000..b942f04 --- /dev/null +++ b/notifiers/runners/apprise.ts @@ -0,0 +1,26 @@ +import { expect } from "jsr:@std/expect"; +import { ServerMem } from "../tests/fixtures.ts"; +import { program } from "../apprise/program.ts"; + +Deno.test({ + name: "Apprise - run memory alert", + async fn() { + let server: Deno.HttpServer | undefined; + try { + server = await program(); + const req = new Request("http://127.0.0.1:7000", { + method: "POST", + body: JSON.stringify(ServerMem), + }); + const resp = await fetch(req); + resp.body?.cancel(); + expect(resp.ok).toBeTruthy(); + } catch (e) { + throw e; + } finally { + if(server !== undefined) { + await server.shutdown(); + } + } + }, +}); \ No newline at end of file -- 2.51.2