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
+105 -19
View File
@@ -16,9 +16,10 @@ function isPilotMode(): boolean {
export interface Agent {
id?: number;
type: 'discord' | 'slack' | 'webhook';
type: 'discord' | 'slack' | 'webhook' | 'apprise';
url: string;
enabled: boolean;
config?: string | null;
}
export interface GlobalSetting {
@@ -613,8 +614,9 @@ export interface NotificationRoute {
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
channel_type: 'discord' | 'slack' | 'webhook';
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_url: string;
config?: string | null;
priority: number;
enabled: boolean;
created_at: number;
@@ -909,6 +911,7 @@ export class DatabaseService {
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationChannelConfig();
this.migrateNotificationSuppressionRules();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
@@ -1907,6 +1910,11 @@ export class DatabaseService {
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationChannelConfig(): void {
this.tryAddColumn('agents', 'config', 'TEXT NULL');
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
}
private migrateNotificationSuppressionRules(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
@@ -2295,36 +2303,101 @@ export class DatabaseService {
// --- Agents ---
/** Encrypt Apprise secrets at rest so a downgraded binary's SELECT * cannot return raw keys/URLs. */
private sealAppriseSecret(value: string | null | undefined): string | null {
if (value == null) return null;
if (value === '') return value;
const crypto = CryptoService.getInstance();
return crypto.isEncrypted(value) ? value : crypto.encrypt(value);
}
private openAppriseSecret(value: string | null | undefined): string | null {
if (value == null) return null;
return CryptoService.getInstance().decrypt(value);
}
/** Seal or pass through url/config depending on whether the channel is Apprise. */
private storeAppriseFields(
isApprise: boolean,
url: string,
config: string | null | undefined,
): { url: string; config: string | null } {
if (!isApprise) return { url, config: config ?? null };
return {
url: this.sealAppriseSecret(url) ?? '',
config: this.sealAppriseSecret(config ?? null),
};
}
/**
* Decrypt Apprise fields for one row. Corrupt ciphertext or a key mismatch
* must not throw out of list/dispatch paths: one bad Apprise row would
* otherwise 500 GET /agents and silently drop every notification channel.
* Empty url/config forces the operator to re-enter credentials to repair.
*/
private loadAppriseFields(
isApprise: boolean,
url: string,
config: string | null | undefined,
): { url: string; config: string | null } {
if (!isApprise) return { url, config: config ?? null };
try {
return {
url: this.openAppriseSecret(url) ?? '',
config: this.openAppriseSecret(config ?? null),
};
} catch (e) {
console.error(
'[DatabaseService] Failed to decrypt Apprise credentials; isolating row:',
(e as Error).message,
);
return { url: '', config: null };
}
}
private mapAgentRow(row: any): Agent {
const type = row.type as Agent['type'];
const fields = this.loadAppriseFields(type === 'apprise', row.url as string, row.config as string | null);
return {
...row,
type,
enabled: row.enabled === 1,
url: fields.url,
config: fields.config,
};
}
public getAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
}
public getEnabledAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
}
public upsertAgent(nodeId: number, agent: Agent): void {
const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config);
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
if (existing) {
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ? WHERE node_id = ? AND type = ?');
stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, nodeId, agent.type);
} else {
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config) VALUES (?, ?, ?, ?, ?)');
stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config);
}
}
// --- Notification Routes ---
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
const fields = this.loadAppriseFields(
channel_type === 'apprise',
row.channel_url as string,
row.config as string | null,
);
return {
id: row.id as number,
name: row.name as string,
@@ -2332,8 +2405,9 @@ export class DatabaseService {
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
channel_url: row.channel_url as string,
channel_type,
channel_url: fields.url,
config: fields.config,
priority: row.priority as number,
enabled: row.enabled === 1,
created_at: row.created_at as number,
@@ -2366,8 +2440,9 @@ export class DatabaseService {
}
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
const result = this.db.prepare(
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
route.name,
route.node_id ?? null,
@@ -2375,7 +2450,8 @@ export class DatabaseService {
route.label_ids ? JSON.stringify(route.label_ids) : null,
route.categories ? JSON.stringify(route.categories) : null,
route.channel_type,
route.channel_url,
stored.url,
stored.config,
route.priority,
route.enabled ? 1 : 0,
route.created_at,
@@ -2387,6 +2463,9 @@ export class DatabaseService {
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
const existing = this.getNotificationRoute(id);
const effectiveType = updates.channel_type ?? existing?.channel_type;
const sealApprise = effectiveType === 'apprise';
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
@@ -2394,7 +2473,14 @@ export class DatabaseService {
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
if (updates.channel_url !== undefined) {
fields.push('channel_url = ?');
values.push(sealApprise ? (this.sealAppriseSecret(updates.channel_url) ?? '') : updates.channel_url);
}
if ('config' in updates) {
fields.push('config = ?');
values.push(sealApprise ? this.sealAppriseSecret(updates.config ?? null) : (updates.config ?? null));
}
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
+64 -7
View File
@@ -12,6 +12,13 @@ import {
matchesNotificationFilters,
ruleNeedsStackLabels,
} from '../helpers/notificationMatchers';
import {
type NotificationChannelType,
type ParsedAppriseConfig,
normalizeAppriseStoredJson,
parseStoredAppriseConfig,
validateNotificationChannel,
} from '../helpers/notificationChannels';
export type NotificationCategory =
| 'deploy_success'
@@ -65,7 +72,13 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
const WEBHOOK_TIMEOUT_MS = 10_000;
/** Valid notification channel types for defense-in-depth validation. */
const ALLOWED_CHANNEL_TYPES = new Set(['discord', 'slack', 'webhook']);
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
export class NotificationDeliveryError extends Error {
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
super(message);
}
}
export class NotificationService {
private static instance: NotificationService;
@@ -251,7 +264,7 @@ export class NotificationService {
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
await Promise.allSettled(
matched.map(route =>
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized)
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized, route.config)
.then(() => {
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via route "${sanitizeForLog(route.name)}" (${route.channel_type})`);
})
@@ -275,7 +288,7 @@ export class NotificationService {
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
await Promise.allSettled(
agents.map(agent =>
this.sendToChannel(agent.type, agent.url, level, sanitized)
this.sendToChannel(agent.type, agent.url, level, sanitized, agent.config)
.then(() => {
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`);
})
@@ -302,22 +315,66 @@ export class NotificationService {
}
}
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string): Promise<void> {
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise<void> {
if (type === 'discord') {
await this.sendDiscordWebhook(url, level, message);
} else if (type === 'slack') {
await this.sendSlackWebhook(url, level, message);
} else if (type === 'webhook') {
await this.sendCustomWebhook(url, level, message);
} else if (type === 'apprise') {
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
throw new NotificationDeliveryError(parsed.reason, null, false);
}
await this.sendAppriseNotify(url, level, message, parsed);
} else {
throw new Error(`Unsupported channel type: ${type}`);
}
}
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) {
if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`);
if (!url || !url.startsWith('https://')) throw new Error('URL must use HTTPS');
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!');
const validation = validateNotificationChannel(type, url, config);
if (validation) throw new Error(`URL ${validation}`);
const stored = type === 'apprise' ? normalizeAppriseStoredJson(url, config) : (config == null ? null : JSON.stringify(config));
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!', stored);
}
private async sendAppriseNotify(
url: string,
level: 'info' | 'warning' | 'error',
message: string,
config: Extract<ParsedAppriseConfig, { ok: true }>,
): Promise<void> {
const payload: Record<string, string> = {
title: `Sencho Alert [${level.toUpperCase()}]`,
body: message,
type: level === 'error' ? 'failure' : level,
};
if (config.mode === 'stateless') payload.urls = config.urlsJoined;
else if (config.tags) payload.tag = config.tags;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (response.status === 204) throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false);
if (response.status >= 400 && response.status < 500) {
throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, false);
}
if (!response.ok) throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, true);
} catch (error) {
if (error instanceof NotificationDeliveryError) throw error;
const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
throw new NotificationDeliveryError(
aborted ? 'Apprise request timed out' : 'Apprise request failed',
null,
true,
);
}
}
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {