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
+30 -6
View File
@@ -4,7 +4,14 @@ import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl } from '../helpers/notificationChannels';
import {
NOTIFICATION_CHANNEL_TYPES,
normalizeAppriseStoredJson,
redactedChannelWriteError,
resolvePreservedAppriseConfig,
serializePublicAgent,
validateNotificationChannel,
} from '../helpers/notificationChannels';
export const agentsRouter = Router();
@@ -12,7 +19,7 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
try {
const nodeId = req.nodeId ?? 0;
const agents = DatabaseService.getInstance().getAgents(nodeId);
res.json(agents);
res.json(agents.map(serializePublicAgent));
} catch (error) {
console.error('Failed to fetch agents:', error);
res.status(500).json({ error: 'Failed to fetch agents' });
@@ -22,19 +29,36 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { type, url, enabled } = req.body;
const { type, url, enabled, config } = req.body;
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
const urlErr = validateHttpsUrl(url);
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type);
const effectiveUrl = url === undefined ? existing?.url : url;
let effectiveConfig: unknown = config ?? null;
if (type === 'apprise' && config === undefined && existing) {
const resolved = resolvePreservedAppriseConfig(typeof effectiveUrl === 'string' ? effectiveUrl : existing.url, existing.config);
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
effectiveConfig = resolved.config;
}
const redactedErr = redactedChannelWriteError(type, effectiveUrl, effectiveConfig, config);
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
const channelErr = validateNotificationChannel(type, effectiveUrl, effectiveConfig);
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
DatabaseService.getInstance().upsertAgent(nodeId, {
type,
url: effectiveUrl.trim(),
enabled,
config: type === 'apprise' ? normalizeAppriseStoredJson(effectiveUrl.trim(), effectiveConfig) : null,
});
console.log('[Agents] Agent %s updated', sanitizeForLog(type));
if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled));
res.json({ success: true });
+3 -2
View File
@@ -16,7 +16,7 @@ interface AgentStatus {
export interface ConfigurationStatus {
tier: LicenseTier;
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
@@ -57,7 +57,7 @@ export function buildLocalConfigurationStatus(
const db = DatabaseService.getInstance();
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
const a = agents.find(ag => ag.type === type);
return { configured: !!a?.url, enabled: a?.enabled ?? false };
};
@@ -96,6 +96,7 @@ export function buildLocalConfigurationStatus(
discord: agentByType('discord'),
slack: agentByType('slack'),
webhook: agentByType('webhook'),
apprise: agentByType('apprise'),
},
alertRules,
// Notification routing is available on every tier.
+3 -1
View File
@@ -50,6 +50,7 @@ import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign';
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
@@ -687,7 +688,8 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
signal: AbortSignal.timeout(10000),
},
);
const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
const raw = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
const configuration = raw ? normalizeRemoteConfigurationStatus(raw) : null;
return {
id: node.id,
name: node.name,
+55 -16
View File
@@ -7,9 +7,15 @@ import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import {
NOTIFICATION_CHANNEL_TYPES,
validateHttpsUrl,
serializePublicNotificationRoute,
validateNotificationChannel,
cleanStackPatterns,
maskWebhookUrl,
normalizeAppriseStoredJson,
parseStoredAppriseConfig,
redactedChannelWriteError,
resolvePreservedAppriseConfig,
storedAppriseToWriteConfig,
} from '../helpers/notificationChannels';
import {
deleteSuppressionRuleFromFleet,
@@ -246,14 +252,14 @@ notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Respon
notificationsRouter.post('/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { type, url } = req.body;
const { type, url, config } = req.body;
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
const urlErr = validateHttpsUrl(url);
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
await NotificationService.getInstance().testDispatch(type, url);
const channelErr = validateNotificationChannel(type, url, config);
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
await NotificationService.getInstance().testDispatch(type, url, config);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
@@ -266,7 +272,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
if (!requireAdmin(req, res)) return;
try {
const routes = DatabaseService.getInstance().getNotificationRoutes();
res.json(routes);
res.json(routes.map(serializePublicNotificationRoute));
} catch (error) {
console.error('Failed to fetch notification routes:', error);
res.status(500).json({ error: 'Failed to fetch notification routes' });
@@ -276,7 +282,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Name is required' });
@@ -299,7 +305,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
const channelUrlErr = validateHttpsUrl(channel_url);
const channelUrlErr = validateNotificationChannel(channel_type, channel_url, config);
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
@@ -315,6 +321,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null,
channel_type,
channel_url: channel_url.trim(),
config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null,
priority: typeof priority === 'number' ? priority : 0,
enabled: enabled !== false,
created_at: now,
@@ -322,7 +329,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
});
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
res.status(201).json(route);
res.status(201).json(serializePublicNotificationRoute(route));
} catch (error) {
console.error('Failed to create notification route:', error);
res.status(500).json({ error: 'Failed to create notification route' });
@@ -338,7 +345,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
const existing = DatabaseService.getInstance().getNotificationRoute(id);
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name must be a non-empty string' });
@@ -368,10 +375,29 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
if (channel_url !== undefined) {
const urlErr = validateHttpsUrl(channel_url);
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
const typeChanged = channel_type !== undefined && channel_type !== existing.channel_type;
// Type changes replace credentials; never reuse a prior channel's URL/config (ciphertext or plaintext).
if (typeChanged && (typeof channel_url !== 'string' || !channel_url.trim())) {
res.status(400).json({ error: 'channel_url is required when changing channel_type' });
return;
}
const effectiveType = channel_type ?? existing.channel_type;
const effectiveUrl = channel_url !== undefined ? String(channel_url).trim() : existing.channel_url;
let effectiveConfig: unknown = config ?? null;
if (effectiveType === 'apprise' && config === undefined) {
if (typeChanged) {
// Fresh Apprise credentials: empty keyed (or stateless urls required via validate).
effectiveConfig = null;
} else {
const resolved = resolvePreservedAppriseConfig(effectiveUrl, existing.config);
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
effectiveConfig = resolved.config;
}
}
const redactedErr = redactedChannelWriteError(effectiveType, effectiveUrl, effectiveConfig, config);
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
const urlErr = validateNotificationChannel(effectiveType, effectiveUrl, effectiveConfig);
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
return;
@@ -388,7 +414,9 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
if (channel_type !== undefined) updates.channel_type = channel_type;
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl;
if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig);
else if (typeChanged || channel_type !== undefined) updates.config = null;
if (priority !== undefined) updates.priority = priority;
if (enabled !== undefined) updates.enabled = enabled;
@@ -397,7 +425,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
const updated = db.getNotificationRoute(id);
console.log(`[Routes] Route ${id} updated`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
res.json(updated);
res.json(serializePublicNotificationRoute(updated!));
} catch (error) {
console.error('Failed to update notification route:', error);
res.status(500).json({ error: 'Failed to update notification route' });
@@ -430,7 +458,18 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${maskWebhookUrl(route.channel_url)})`);
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
let testConfig: unknown;
if (route.channel_type === 'apprise') {
const stored = parseStoredAppriseConfig(route.channel_url, route.config);
if (!stored.ok) {
res.status(400).json({ error: stored.reason });
return;
}
testConfig = storedAppriseToWriteConfig(stored);
} else {
testConfig = route.config ? JSON.parse(route.config) : undefined;
}
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url, testConfig);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });