mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 13:18:08 +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:
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
Reference in New Issue
Block a user