feat: add Apprise as a fourth notification channel (#1644)

* feat: add Apprise as a fourth notification channel

Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing.

* fix: harden Apprise secrets at rest and preserve-on-write saves

Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful.

* fix: harden Apprise route type changes and mixed-version config UI

Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator.

* fix: tolerate stub fleet configuration payloads without agents

Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path.

* fix: correct TypeScript in configuration normalize tests

* fix: ignore stale Channels agent bodies after node switch

Compare the active node after response JSON parsing so a slow body
cannot overwrite the newly selected node's channel state.

* fix: isolate corrupt Apprise crypto and keep keyed Tags visible

Decrypt failures on one Apprise row no longer 500 agent/route lists or
suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed
so Tags remain editable after reload.
This commit is contained in:
Anso
2026-07-18 16:32:58 -04:00
committed by GitHub
parent 674220b9de
commit 83b3d932e5
42 changed files with 2916 additions and 136 deletions
@@ -0,0 +1,47 @@
/** Agent slot on dashboard/fleet configuration responses. */
export interface AgentStatus {
configured: boolean;
enabled: boolean;
}
export type ConfigurationAgents = {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise: AgentStatus;
};
/**
* Older remotes omit `apprise`. Default the slot so mixed-version hubs do not
* treat the response as malformed or crash UI consumers.
*/
export function normalizeConfigurationAgents(agents: {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
}): ConfigurationAgents {
return {
discord: agents.discord,
slack: agents.slack,
webhook: agents.webhook,
apprise: agents.apprise ?? { configured: false, enabled: false },
};
}
/**
* Normalize a remote `/dashboard/configuration` body when it carries an agents
* block. Stub or partial JSON without `notifications.agents` is returned as-is
* so a successful fetch still marks the node online.
*/
export function normalizeRemoteConfigurationStatus<T extends object>(raw: T): T {
const notifications = (raw as { notifications?: { agents?: Parameters<typeof normalizeConfigurationAgents>[0] } }).notifications;
if (!notifications?.agents) return raw;
return {
...raw,
notifications: {
...notifications,
agents: normalizeConfigurationAgents(notifications.agents),
},
};
}
+303 -3
View File
@@ -1,6 +1,54 @@
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
import type { Agent, NotificationRoute } from '../services/DatabaseService';
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
/** Write payload for Apprise agents/routes (never public DTO fields). */
export type AppriseWriteConfig = { tags?: string } | { urls: string };
/** Persisted JSON shape after normalize (keyed empty is `{}`). */
export type AppriseStoredConfig = { tags?: string } | { urls: string };
export type PublicAppriseConfig = {
mode: 'keyed' | 'stateless';
tags?: string;
has_urls: boolean;
providers?: string[];
url_count?: number;
};
export type PublicAgent = Omit<Agent, 'config' | 'type'> & {
type: NotificationChannelType;
config: PublicAppriseConfig | null;
/** True only when Apprise masking was applied; Discord/Slack/webhook still return raw URLs. */
secrets_redacted: boolean;
};
export type PublicNotificationRoute = Omit<NotificationRoute, 'config' | 'channel_type'> & {
channel_type: NotificationChannelType;
config: PublicAppriseConfig | null;
secrets_redacted: boolean;
};
/** Apprise notify key: 1128 alphanumeric, underscore, or dash (official API notes). */
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
export type ParsedAppriseConfig =
| { ok: true; mode: 'keyed'; tags?: string }
| { ok: true; mode: 'stateless'; urls: string[]; urlsJoined: string }
| { ok: false; reason: string };
const APPRISE_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
const KEYED_KEYS = new Set(['tags']);
const STATELESS_KEYS = new Set(['urls']);
const INVALID_STORED = 'Apprise configuration is missing or invalid';
/** Path segment after `/notify/` when the path is `/notify/{key}` (key not yet validated). */
function notifyKeyFromPath(path: string): string | null {
const match = path.match(/^\/notify\/([^/]+)$/);
return match ? match[1] : null;
}
export const cleanStackPatterns = (patterns: string[]): string[] =>
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
@@ -10,6 +58,260 @@ export function validateHttpsUrl(value: unknown): string | null {
return null;
}
export function classifyAppriseEndpoint(endpoint: string): 'keyed' | 'stateless' | null {
try {
const path = new URL(endpoint).pathname.replace(/\/$/, '');
const key = notifyKeyFromPath(path);
if (key !== null) return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
if (path === '/notify') return 'stateless';
return null;
} catch {
return null;
}
}
/** Thin classifier for tests; write validation uses validateNotificationChannel. */
export function classifyAppriseConfig(endpoint: string, config: unknown): { mode: 'keyed' | 'stateless'; urls?: string[] } | null {
const mode = classifyAppriseEndpoint(endpoint);
if (!mode) return null;
if (mode === 'keyed') {
if (config !== undefined && config !== null && (typeof config !== 'object' || Array.isArray(config))) return null;
return { mode: 'keyed' };
}
if (!config || typeof config !== 'object' || Array.isArray(config)) return null;
const urls = typeof (config as { urls?: unknown }).urls === 'string'
? splitServiceUrls((config as { urls: string }).urls)
: [];
return urls.length > 0 ? { mode: 'stateless', urls } : null;
}
export function isPublicAppriseConfigShape(config: unknown): boolean {
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
const record = config as Record<string, unknown>;
return 'has_urls' in record || 'providers' in record || 'url_count' in record || 'mode' in record || 'secrets_redacted' in record;
}
function unknownKeyError(record: Record<string, unknown>, allowed: Set<string>): string | null {
for (const key of Object.keys(record)) {
if (!allowed.has(key)) return `unknown Apprise config field: ${key}`;
}
return null;
}
function splitServiceUrls(urls: string): string[] {
return urls.split(/[\s,]+/).map(url => url.trim()).filter(Boolean);
}
function validateServiceUrlToken(token: string): string | null {
if (token.length > 2000) return 'Apprise service URLs must be 2000 characters or fewer';
if (!APPRISE_SCHEME.test(token)) return 'each Apprise service URL must include a URI scheme';
return null;
}
function asRecord(config: unknown): Record<string, unknown> | null {
if (config === undefined || config === null) return {};
if (typeof config !== 'object' || Array.isArray(config)) return null;
return config as Record<string, unknown>;
}
export function validateNotificationChannel(type: unknown, url: unknown, config?: unknown): string | null {
if (typeof type !== 'string' || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
return `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}`;
}
if (type !== 'apprise') return validateHttpsUrl(url);
if (typeof url !== 'string') return 'must be a valid Apprise URL';
let parsed: URL;
try { parsed = new URL(url); } catch { return 'is not a valid URL'; }
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.hash) {
return 'must be an HTTP or HTTPS Apprise URL without credentials or a fragment';
}
if (isPublicAppriseConfigShape(config)) {
return 'must use raw Apprise config ({ urls } or { tags }), not a public summary';
}
const path = parsed.pathname.replace(/\/$/, '');
const notifyKey = notifyKeyFromPath(path);
if (notifyKey !== null && !APPRISE_NOTIFY_KEY.test(notifyKey)) {
return 'Apprise notify key must be 1128 characters of letters, digits, underscore, or dash';
}
const mode = classifyAppriseEndpoint(url);
if (!mode) return 'must use /notify or /notify/{key} with valid configuration';
const record = asRecord(config);
if (!record) return 'must use /notify or /notify/{key} with valid configuration';
if (mode === 'keyed') {
const keyErr = unknownKeyError(record, KEYED_KEYS);
if (keyErr) return keyErr;
if (record.urls !== undefined) return 'keyed Apprise endpoints cannot include urls';
if (record.tags !== undefined && typeof record.tags !== 'string') return 'Apprise tags must be a string';
return null;
}
const keyErr = unknownKeyError(record, STATELESS_KEYS);
if (keyErr) return keyErr;
if (record.tags !== undefined) return 'stateless Apprise endpoints cannot include tags';
if (typeof record.urls !== 'string' || !record.urls.trim()) {
return 'stateless Apprise endpoints require a nonempty urls string';
}
const urls = splitServiceUrls(record.urls);
if (urls.length === 0) return 'stateless Apprise endpoints require a nonempty urls string';
for (const token of urls) {
const tokenErr = validateServiceUrlToken(token);
if (tokenErr) return tokenErr;
}
return null;
}
/** Persist shape: keyed empty → `{}`; omit empty tags; stateless → `{ urls }`. */
export function normalizeAppriseStoredJson(endpoint: string, config: unknown): string {
const mode = classifyAppriseEndpoint(endpoint);
if (mode === 'keyed') {
const record = asRecord(config) ?? {};
const tags = typeof record.tags === 'string' ? record.tags.trim() : '';
return tags ? JSON.stringify({ tags }) : '{}';
}
const record = asRecord(config) ?? {};
const urls = typeof record.urls === 'string' ? record.urls.trim() : '';
return JSON.stringify({ urls });
}
export function parseStoredAppriseConfig(endpoint: string, configJson: string | null | undefined): ParsedAppriseConfig {
const mode = classifyAppriseEndpoint(endpoint);
if (!mode) return { ok: false, reason: INVALID_STORED };
if (configJson == null || configJson === '') {
if (mode === 'keyed') return { ok: true, mode: 'keyed' };
return { ok: false, reason: INVALID_STORED };
}
let parsed: unknown;
try {
parsed = JSON.parse(configJson);
} catch {
return { ok: false, reason: INVALID_STORED };
}
const err = validateNotificationChannel('apprise', endpoint, parsed);
if (err) return { ok: false, reason: INVALID_STORED };
// validateNotificationChannel already confirmed parsed is a plain object
// whose only fields match the mode (tags for keyed, urls for stateless),
// so no further shape narrowing is needed here.
if (mode === 'keyed') {
const tags = typeof (parsed as { tags?: string }).tags === 'string' ? (parsed as { tags: string }).tags.trim() : '';
return tags ? { ok: true, mode: 'keyed', tags } : { ok: true, mode: 'keyed' };
}
const urlsJoined = (parsed as { urls: string }).urls.trim();
const urls = splitServiceUrls(urlsJoined);
return { ok: true, mode: 'stateless', urls, urlsJoined };
}
export function storedAppriseToWriteConfig(parsed: Extract<ParsedAppriseConfig, { ok: true }>): AppriseWriteConfig {
if (parsed.mode === 'keyed') {
return parsed.tags ? { tags: parsed.tags } : {};
}
return { urls: parsed.urlsJoined };
}
/** Reconstruct the write config to preserve on an agent/route write that omits `config`. */
export function resolvePreservedAppriseConfig(
endpoint: string,
existingConfigJson: string | null | undefined,
): { ok: true; config: AppriseWriteConfig } | { ok: false; error: string } {
const stored = parseStoredAppriseConfig(endpoint, existingConfigJson);
if (!stored.ok) {
return {
ok: false,
error: 'Stored Apprise configuration is invalid for this endpoint; provide a complete replacement',
};
}
return { ok: true, config: storedAppriseToWriteConfig(stored) };
}
/**
* Shared preserve-on-write guard for agent/route Apprise writes: blocks a
* write that echoes a redacted public URL, config, or DTO shape back as raw
* input. Identical across `agents.ts` POST and `notification-routes` PUT.
*/
export function redactedChannelWriteError(
type: string,
effectiveUrl: unknown,
effectiveConfig: unknown,
rawConfig: unknown,
): string | null {
const configText = effectiveConfig === undefined ? '' : JSON.stringify(effectiveConfig) ?? '';
if (
(typeof effectiveUrl === 'string' && effectiveUrl.includes('<redacted>'))
|| configText.includes('<redacted>')
|| (type === 'apprise' && rawConfig !== undefined && isPublicAppriseConfigShape(rawConfig))
) {
return 'Provide raw channel credentials to replace redacted values';
}
return null;
}
function providerSchemes(urls: string[]): string[] {
const schemes: string[] = [];
for (const value of urls) {
if (!APPRISE_SCHEME.test(value)) continue;
const scheme = value.split(':', 1)[0].toLowerCase();
if (scheme && !schemes.includes(scheme)) schemes.push(scheme);
}
return schemes;
}
function publicAppriseConfig(url: string, config: string | null): PublicAppriseConfig | null {
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
console.warn(`[apprise] stored config unreadable for public DTO (${parsed.reason})`);
return null;
}
if (parsed.mode === 'keyed') {
return { mode: 'keyed', tags: parsed.tags, has_urls: false };
}
const providers = providerSchemes(parsed.urls);
return {
mode: 'stateless',
has_urls: parsed.urls.length > 0,
...(providers.length > 0 ? { providers } : {}),
url_count: parsed.urls.length,
};
}
export function maskAppriseEndpoint(value: string): string {
try {
const parsed = new URL(value);
const path = parsed.pathname.replace(/\/$/, '');
const key = notifyKeyFromPath(path);
if (key !== null && APPRISE_NOTIFY_KEY.test(key)) return `${parsed.origin}/notify/<redacted>`;
return `${parsed.origin}/notify`;
} catch {
return '<invalid url>';
}
}
export function serializePublicAgent(agent: Agent): PublicAgent {
const isApprise = agent.type === 'apprise';
return {
...agent,
type: agent.type,
url: isApprise ? maskAppriseEndpoint(agent.url) : agent.url,
config: isApprise ? publicAppriseConfig(agent.url, agent.config ?? null) : null,
secrets_redacted: isApprise,
};
}
export function serializePublicNotificationRoute(route: NotificationRoute): PublicNotificationRoute {
const isApprise = route.channel_type === 'apprise';
return {
...route,
channel_type: route.channel_type,
channel_url: isApprise ? maskAppriseEndpoint(route.channel_url) : route.channel_url,
config: isApprise ? publicAppriseConfig(route.channel_url, route.config ?? null) : null,
secrets_redacted: isApprise,
};
}
/**
* Mask a channel webhook URL for logging. Discord/Slack/custom webhook URLs
* embed their auth token in the path (and sometimes the query), so only the
@@ -20,8 +322,6 @@ export function maskWebhookUrl(value: unknown): string {
if (typeof value !== 'string' || value.length === 0) return '<no url>';
try {
const { origin, pathname, search } = new URL(value);
// pathname is normalized to '/' for an origin-only URL, so anything else
// (or any query string) is a token-bearing segment we must not log.
const hasSecret = pathname !== '/' || search !== '';
return hasSecret ? `${origin}/<redacted>` : origin;
} catch {