[READ-ONLY] Mirror of https://github.com/FoxxMD/komodo-utilities. Small utilities to enhance Komodo
gotify komodo
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement Apprise Alerter

mbecker20/komodo#226

FoxxMD (Dec 18, 2024, 4:58 PM UTC) 2eef9750 b9417185

+433 -1
+7 -1
README.md
··· 16 16 17 17 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) 18 18 19 - [See README](/notifiers/discord/README.md) 19 + [See README](/notifiers/discord/README.md) 20 + 21 + ## Apprise API Webhook Alerter 22 + 23 + 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) 24 + 25 + [See README](/notifiers/apprise/README.md)
+19
notifiers/apprise/Dockerfile
··· 1 + FROM denoland/deno:2.0.4 2 + 3 + # The port that your application listens to. 4 + EXPOSE 7000 5 + 6 + WORKDIR /app 7 + 8 + # Prefer not to run as root. 9 + USER deno 10 + 11 + # These steps will be re-run upon each file change in your working directory: 12 + COPY common/ common/ 13 + COPY notifiers/common/ notifiers/common/ 14 + COPY notifiers/apprise/ notifiers/apprise/ 15 + 16 + RUN deno install --entrypoint notifiers/apprise/main.ts 17 + RUN deno cache notifiers/apprise/main.ts 18 + 19 + CMD ["run", "--allow-net", "--allow-env", "notifiers/apprise/main.ts"]
+13
notifiers/apprise/README.md
··· 1 + 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) 2 + 3 + # Usage 4 + 5 + See [Komodohub deploy instructions](https://github.com/FoxxMD/deploy-apprise-alerter) 6 + 7 + # Building 8 + 9 + Run from the repository top-level folder: 10 + 11 + ```shell 12 + docker build -t komodo-apprise-alerter -f notifiers/apprise/Dockerfile . 13 + ```
+3
notifiers/apprise/main.ts
··· 1 + import { program } from "./program.ts"; 2 + 3 + await program();
+234
notifiers/apprise/program.ts
··· 1 + import { Types } from "npm:komodo_client"; 2 + import { CommonAlert, parseAlert } from "../common/alertParser.ts"; 3 + import { alertResolvedAllowed, parseOptions } from "../common/options.ts"; 4 + import { URLData } from "../common/atomic.ts"; 5 + import { nonEmptyStringOrDefault, normalizeWebAddress, parseArrayFromMaybeString, truncateStringToLength } from "../common/stringUtils.ts"; 6 + import { isPortReachable, joinedUrl } from "../common/networkUtils.ts"; 7 + import { titleAndSubtitle } from "../common/notifierBuilder.ts"; 8 + 9 + interface AppriseOptions { 10 + endpoint: URLData 11 + urls: string[] 12 + keys: string[] 13 + tag?: string 14 + } 15 + 16 + 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.'; 17 + 18 + const shortKey = truncateStringToLength(10); 19 + 20 + const parseAppriseOptions = async (): Promise<AppriseOptions> => { 21 + 22 + const host = nonEmptyStringOrDefault(Deno.env.get("APPRISE_HOST") as string); 23 + 24 + if(host === undefined) { 25 + throw new Error(`'APPRISE_HOST' must be defined`); 26 + } 27 + 28 + const urls: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_STATELESS_URLS"), '')); 29 + const keys: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_PERSISTENT_KEYS"), '')); 30 + 31 + if(urls.length === 0 && keys.length === 0) { 32 + 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`); 33 + } 34 + 35 + const tag: string | undefined = nonEmptyStringOrDefault(Deno.env.get("APPRISE_TAG")); 36 + 37 + // check url is correct as a courtesy 38 + const endpoint = normalizeWebAddress(host); 39 + console.debug(`Apprise Host Config URL: '${host}' => Normalized: '${endpoint.normal}'`) 40 + 41 + try { 42 + await isPortReachable(endpoint.port, { host: endpoint.url.hostname }); 43 + } catch (e) { 44 + console.warn(new Error('Unable to detect if server is reachable', { cause: e })); 45 + return {endpoint, urls, keys, tag}; 46 + } 47 + 48 + if (keys.length > 0) { 49 + let anyOk = false; 50 + for (const key of keys) { 51 + try { 52 + const resp = await fetch(joinedUrl(endpoint.url, `/json/urls/${key}`).toString()); 53 + if (resp.status === 204) { 54 + 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.`); 55 + } else { 56 + anyOk = true; 57 + } 58 + } catch (e) { 59 + console.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e})); 60 + } 61 + } 62 + if (!anyOk) { 63 + console.warn('No Apprise Configs were valid!'); 64 + } 65 + } 66 + 67 + return {endpoint, urls, keys, tag}; 68 + } 69 + 70 + const callApi = async (req: Request): Promise<Response> => { 71 + let resp: Response | undefined; 72 + try { 73 + const resp = await fetch(req); 74 + if(!resp.ok) { 75 + let text: string; 76 + try { 77 + text = await resp.text(); 78 + } catch (e) { 79 + console.debug(new Error('Could not parse body from response', {cause: e})); 80 + throw new Error(`Apprise response NOT OK! Status ${resp.status}`); 81 + } 82 + let json: object; 83 + try { 84 + json = JSON.parse(text); 85 + } catch (e) { 86 + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`); 87 + } 88 + 89 + if('error' in json) { 90 + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Error => ${json.error}`); 91 + } else { 92 + throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`); 93 + } 94 + } 95 + return resp; 96 + } catch (e) { 97 + if(resp !== undefined) { 98 + console.debug(resp); 99 + } 100 + throw e; 101 + } 102 + } 103 + 104 + const program = async () => { 105 + 106 + let appriseOptions: AppriseOptions; 107 + const commonOpts = parseOptions(); 108 + 109 + try { 110 + appriseOptions = await parseAppriseOptions(); 111 + } catch (e) { 112 + throw new Error('Could not parse Apprise options', {cause: e}); 113 + } 114 + 115 + const {keys, urls, endpoint, tag} = appriseOptions; 116 + 117 + let configSummary: string[] = [`Using Apprise @ ${endpoint.normal}`]; 118 + if(urls.length === 0 && keys.length === 0) { 119 + configSummary.push(`Pushing to stateless endpoint (to '/notify')`) 120 + } else { 121 + if(urls.length > 0) { 122 + configSummary.push(`Pushing to stateless URLs '${urls.join(',')}'`); 123 + } 124 + if(keys.length > 0) { 125 + configSummary.push(`Pushing to persistent keys '${keys.join(',')}'`); 126 + } 127 + } 128 + if(tag !== undefined) { 129 + configSummary.push(`With tag '${tag}'`); 130 + } 131 + console.debug(configSummary.join('\n')); 132 + 133 + const pushAlert = async ( 134 + data: CommonAlert, 135 + level: Types.SeverityLevel, 136 + ): Promise<any> => { 137 + let notifyType: string; 138 + switch (level) { 139 + case Types.SeverityLevel.Ok: 140 + notifyType ='info'; 141 + break; 142 + case Types.SeverityLevel.Warning: 143 + notifyType = 'warning'; 144 + break; 145 + case Types.SeverityLevel.Critical: 146 + notifyType = 'failure'; 147 + break; 148 + } 149 + 150 + const body: Record<string, any> = { 151 + title: titleAndSubtitle(data), 152 + body: data.message, 153 + type: notifyType 154 + } 155 + if(tag !== undefined) { 156 + body.tag = tag; 157 + } 158 + 159 + const requestOpts: RequestInit = { 160 + method: 'POST', 161 + headers: { 162 + 'Content-Type': 'application/json', 163 + 'Accept': 'application/json' 164 + } 165 + } 166 + 167 + if(keys.length > 0) { 168 + for(const k of keys) { 169 + try { 170 + const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify/${k}`).toString(), { 171 + ...requestOpts, 172 + body: JSON.stringify(body) 173 + })); 174 + await resp.body?.cancel(); 175 + // @ts-expect-error 176 + } catch (e: Error) { 177 + console.error(new Error(`Failed to send notification with key ${k}${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e})); 178 + } 179 + } 180 + } 181 + 182 + if(urls.length > 0 || keys.length === 0) { 183 + const urlBody = {...body}; 184 + if(urls.length > 0) { 185 + urlBody.urls = urls.join(','); 186 + } 187 + try { 188 + const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify`).toString(), { 189 + ...requestOpts, 190 + body: JSON.stringify(urlBody) 191 + })); 192 + await resp.body?.cancel(); 193 + // @ts-expect-error 194 + } catch (e: Error) { 195 + console.error(new Error(`Failed to send notification using URLs${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e})); 196 + } 197 + } 198 + }; 199 + 200 + const server = Deno.serve({ port: 7000 }, async (req) => { 201 + const alert: Types.Alert = await req.json(); 202 + console.log(`Recieved data from ${req.headers.get("host")}...`); 203 + 204 + let data: CommonAlert; 205 + 206 + try { 207 + data = parseAlert(alert, { ...commonOpts }); 208 + } catch (e) { 209 + console.debug("Komodo Alert Payload:", alert); 210 + console.error(e); 211 + return new Response(); 212 + } 213 + 214 + if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { 215 + console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); 216 + return new Response(); 217 + } 218 + 219 + try { 220 + await pushAlert(data, alert.level); 221 + } catch (e) { 222 + console.debug("Komodo Alert Payload:", alert); 223 + console.error( 224 + new Error("Failed to push Alert to Apprise", { cause: e }), 225 + ); 226 + } 227 + 228 + return new Response(); 229 + }); 230 + 231 + return server; 232 + }; 233 + 234 + export { program };
+5
notifiers/common/atomic.ts
··· 1 + export interface URLData { 2 + url: URL 3 + normal: string 4 + port: number 5 + }
notifiers/common/errorUtils.ts

This is a binary file and will not be displayed.

+49
notifiers/common/networkUtils.ts
··· 1 + import net from 'node:net'; 2 + import { join as joinPath } from "node:path"; 3 + 4 + export interface PortReachableOpts { 5 + host: string, 6 + timeout?: number 7 + } 8 + /** 9 + * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting 10 + * */ 11 + export const isPortReachable = async (port: number, opts: PortReachableOpts) => { 12 + const {host, timeout = 1000} = opts; 13 + 14 + const promise = new Promise(((resolve, reject) => { 15 + const socket = new net.Socket(); 16 + 17 + const onError = (e: Error) => { 18 + socket.destroy(); 19 + reject(e); 20 + }; 21 + const onTimeout = () => { 22 + socket.destroy(); 23 + reject(new Error(`Connection timed out after ${timeout}ms`)); 24 + } 25 + 26 + socket.setTimeout(timeout); 27 + socket.once('error', onError); 28 + socket.once('timeout', onTimeout); 29 + 30 + socket.connect(port, host, () => { 31 + socket.end(); 32 + resolve(true); 33 + }); 34 + })); 35 + 36 + try { 37 + await promise; 38 + return true; 39 + } catch (e) { 40 + throw e; 41 + } 42 + } 43 + 44 + export const joinedUrl = (url: URL, ...paths: string[]): URL => { 45 + // https://github.com/jfromaniello/url-join#in-nodejs 46 + const finalUrl = new URL(url); 47 + finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); 48 + return finalUrl; 49 + }
+77
notifiers/common/stringUtils.ts
··· 1 + import { parseRegexSingle } from "npm:@foxxmd/regex-buddy-core@0.1.2"; 2 + import normalizeUrl from "npm:normalize-url@8.0.1"; 3 + import { URLData } from "./atomic.ts"; 4 + 5 + const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); 6 + 7 + export const normalizeWebAddress = (val: string): URLData => { 8 + let cleanUserUrl = val.trim(); 9 + const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); 10 + if (results !== undefined && results.groups && results.groups.length > 0) { 11 + cleanUserUrl = results.groups[0]; 12 + } 13 + 14 + let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true}); 15 + const u = new URL(normal); 16 + let port: number; 17 + 18 + if (u.port === '') { 19 + port = u.protocol === 'https:' ? 443 : 80; 20 + } else { 21 + port = parseInt(u.port); 22 + // if user val does not include protocol and port is 443 then auto set to https 23 + if(port === 443 && !val.includes('http')) { 24 + if(u.protocol === 'http:') { 25 + u.protocol = 'https:'; 26 + } 27 + normal = normal.replace('http:', 'https:'); 28 + } 29 + } 30 + return { 31 + url: u, 32 + normal, 33 + port 34 + } 35 + } 36 + 37 + export const truncateStringToLength = (length: any, truncStr = '...') => (val: any = '') => { 38 + if (val === null) { 39 + return ''; 40 + } 41 + const str = typeof val !== 'string' ? val.toString() : val; 42 + return str.length > length ? `${str.slice(0, length)}${truncStr}` : str; 43 + } 44 + 45 + /** 46 + * Returns value if it is a non-empty string or returns default value 47 + * */ 48 + export const nonEmptyStringOrDefault = <T = undefined>(str: any, 49 + // @ts-expect-error this is fine 50 + defaultVal: T = undefined): string | T => { 51 + if (str === undefined || str === null || typeof str !== 'string' || str.trim() === '') { 52 + return defaultVal; 53 + } 54 + return str; 55 + } 56 + 57 + interface ArrParseOpts { 58 + lower?: boolean 59 + split?: string 60 + } 61 + 62 + export const parseArrayFromMaybeString = (value: string | string[] = '', opts: ArrParseOpts = {}) => { 63 + const {lower = false, split = ','} = opts; 64 + let arr: string[] = []; 65 + if (Array.isArray(value)) { 66 + arr = value; 67 + } else if (value.trim() === '') { 68 + return []; 69 + } else { 70 + arr = value.split(split); 71 + } 72 + arr = arr.map(x => x.trim()); 73 + if (lower) { 74 + arr = arr.map(x => x.toLowerCase()); 75 + } 76 + return arr; 77 + }
+26
notifiers/runners/apprise.ts
··· 1 + import { expect } from "jsr:@std/expect"; 2 + import { ServerMem } from "../tests/fixtures.ts"; 3 + import { program } from "../apprise/program.ts"; 4 + 5 + Deno.test({ 6 + name: "Apprise - run memory alert", 7 + async fn() { 8 + let server: Deno.HttpServer | undefined; 9 + try { 10 + server = await program(); 11 + const req = new Request("http://127.0.0.1:7000", { 12 + method: "POST", 13 + body: JSON.stringify(ServerMem), 14 + }); 15 + const resp = await fetch(req); 16 + resp.body?.cancel(); 17 + expect(resp.ok).toBeTruthy(); 18 + } catch (e) { 19 + throw e; 20 + } finally { 21 + if(server !== undefined) { 22 + await server.shutdown(); 23 + } 24 + } 25 + }, 26 + });