mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
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:
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user