diff --git a/backend/src/__tests__/agents-routes.test.ts b/backend/src/__tests__/agents-routes.test.ts index da9c4546..51df399c 100644 --- a/backend/src/__tests__/agents-routes.test.ts +++ b/backend/src/__tests__/agents-routes.test.ts @@ -124,3 +124,122 @@ describe('POST /api/agents', () => { expect(Boolean(agents[0].enabled)).toBe(true); }); }); + +describe('Apprise agents - redaction and preserve-on-write', () => { + const keyedUrl = 'http://apprise.local/notify/key-secret-value'; + const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret'; + + it('GET redacts keyed endpoint and never returns destination secrets', async () => { + DatabaseService.getInstance().upsertAgent(1, { + type: 'apprise', + url: keyedUrl, + enabled: true, + config: JSON.stringify({ tags: 'ops' }), + }); + + const res = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body[0].url).toBe('http://apprise.local/notify/'); + expect(res.body[0].config).toMatchObject({ mode: 'keyed', tags: 'ops', has_urls: false }); + expect(res.body[0].secrets_redacted).toBe(true); + expect(JSON.stringify(res.body)).not.toContain('key-secret-value'); + }); + + it('GET for stateless mode exposes providers and url_count only', async () => { + DatabaseService.getInstance().upsertAgent(1, { + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: JSON.stringify({ urls: serviceUrl }), + }); + + const res = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body[0].url).toBe('http://apprise.local/notify'); + expect(res.body[0].config).toMatchObject({ + mode: 'stateless', + has_urls: true, + providers: ['discord'], + url_count: 1, + }); + expect(JSON.stringify(res.body)).not.toContain('webhook-token'); + expect(JSON.stringify(res.body)).not.toContain('query-secret'); + }); + + it('rejects posting a redacted endpoint URL', async () => { + DatabaseService.getInstance().upsertAgent(1, { + type: 'apprise', + url: keyedUrl, + enabled: true, + config: JSON.stringify({ tags: 'ops' }), + }); + + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'apprise', + url: 'http://apprise.local/notify/', + enabled: true, + config: { tags: 'ops' }, + }); + expect(res.status).toBe(400); + }); + + it('rejects public DTO config shapes on write', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: { mode: 'stateless', has_urls: true, providers: ['discord'], url_count: 1 }, + }); + expect(res.status).toBe(400); + }); + + it('preserves stored secrets when url and config are omitted', async () => { + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { + type: 'apprise', + url: keyedUrl, + enabled: true, + config: JSON.stringify({ tags: 'ops' }), + }); + + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'apprise', + enabled: false, + }); + expect(res.status).toBe(200); + + const agents = db.getAgents(1); + expect(agents[0].url).toBe(keyedUrl); + expect(agents[0].config).toBe(JSON.stringify({ tags: 'ops' })); + expect(Boolean(agents[0].enabled)).toBe(false); + }); + + it('creates a keyed agent with no config and persists {}', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'apprise', + url: 'http://apprise.local/notify/new-key', + enabled: true, + }); + expect(res.status).toBe(200); + const agent = DatabaseService.getInstance().getAgents(1).find(a => a.type === 'apprise'); + expect(agent?.url).toBe('http://apprise.local/notify/new-key'); + expect(agent?.config).toBe('{}'); + }); + + it('rejects preserve-on-write when stored Apprise config is malformed', async () => { + DatabaseService.getInstance().upsertAgent(1, { + type: 'apprise', + url: keyedUrl, + enabled: true, + config: '{not-json', + }); + + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'apprise', + enabled: false, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + expect(JSON.stringify(res.body)).not.toContain('key-secret-value'); + }); +}); diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index 6d1c3e80..53a3a600 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -242,7 +242,7 @@ describe('POST /api/notifications/test', () => { .set('Cookie', authCookie) .send({ type: 'telegram', url: 'https://example.com' }); expect(res.status).toBe(400); - expect(res.body.error).toContain('discord, slack, webhook'); + expect(res.body.error).toContain('discord, slack, webhook, apprise'); }); it('rejects missing type with 400', async () => { @@ -277,4 +277,77 @@ describe('POST /api/notifications/test', () => { .send({ type: 'discord', url: 'https://' }); expect(res.status).toBe(400); }); + + it('dispatches keyed Apprise test with exact payload', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ type: 'apprise', url: 'http://apprise.local/notify/test-key', config: { tags: 'ops' } }); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + 'http://apprise.local/notify/test-key', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + title: 'Sencho Alert [INFO]', + body: '🔌 Test Notification from Sencho!', + type: 'info', + tag: 'ops', + }), + }), + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('dispatches stateless Apprise test with urls and rejects scheme-less tokens', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const bad = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'no-scheme' } }); + expect(bad.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + + const ok = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'discord://token' } }); + expect(ok.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + 'http://apprise.local/notify', + expect.objectContaining({ + body: JSON.stringify({ + title: 'Sencho Alert [INFO]', + body: '🔌 Test Notification from Sencho!', + type: 'info', + urls: 'discord://token', + }), + }), + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('returns sanitized details on Apprise 204 without leaking secrets', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 })); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ type: 'apprise', url: 'http://apprise.local/notify/secret-key', config: {} }); + expect(res.status).toBe(500); + expect(JSON.stringify(res.body)).toContain('HTTP 204'); + expect(JSON.stringify(res.body)).not.toContain('secret-key'); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/backend/src/__tests__/apprise-secret-at-rest.test.ts b/backend/src/__tests__/apprise-secret-at-rest.test.ts new file mode 100644 index 00000000..a93437ba --- /dev/null +++ b/backend/src/__tests__/apprise-secret-at-rest.test.ts @@ -0,0 +1,207 @@ +/** + * Downgrade-safety: Apprise url/config must be encrypted at rest so a + * pre-Apprise binary's SELECT * + unsanitized GET cannot return raw secrets. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { serializePublicAgent, serializePublicNotificationRoute } from '../helpers/notificationChannels'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM agents').run(); + db.prepare('DELETE FROM notification_routes').run(); +}); + +describe('Apprise secrets at rest (downgrade-safe)', () => { + it('stores keyed agent secrets as ciphertext; SELECT * does not leak the notify key', () => { + const db = DatabaseService.getInstance(); + const keySecret = 'SuperSecretKey99'; + + db.upsertAgent(1, { + type: 'apprise', + url: `http://apprise.local/notify/${keySecret}`, + enabled: true, + config: '{}', + }); + + const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as { + url: string; + config: string | null; + }; + expect(raw.url).toMatch(/^enc:/); + expect(raw.url).not.toContain(keySecret); + expect(raw.url).not.toContain('apprise.local'); + expect(raw.config).toMatch(/^enc:/); + + const apprise = db.getAgents(1).find(a => a.type === 'apprise')!; + expect(apprise.url).toBe(`http://apprise.local/notify/${keySecret}`); + const pub = serializePublicAgent(apprise); + expect(pub.secrets_redacted).toBe(true); + expect(pub.url).toContain(''); + expect(JSON.stringify(pub)).not.toContain(keySecret); + }); + + it('stores stateless destination URLs as ciphertext; SELECT * does not leak credentials', () => { + const db = DatabaseService.getInstance(); + const destSecret = 'mailto://user:smtp-password@example.com'; + + db.upsertAgent(1, { + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: JSON.stringify({ urls: destSecret }), + }); + + const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as { + url: string; + config: string | null; + }; + expect(raw.url).toMatch(/^enc:/); + expect(raw.config).toMatch(/^enc:/); + expect(raw.config).not.toContain(destSecret); + expect(raw.config).not.toContain('smtp-password'); + + const apprise = db.getAgents(1).find(a => a.type === 'apprise')!; + expect(apprise.url).toBe('http://apprise.local/notify'); + expect(apprise.config).toBe(JSON.stringify({ urls: destSecret })); + const pub = serializePublicAgent(apprise); + expect(pub.secrets_redacted).toBe(true); + expect(JSON.stringify(pub)).not.toContain(destSecret); + }); + + it('encrypts Apprise route channel_url and config the same way', () => { + const db = DatabaseService.getInstance(); + const keySecret = 'RouteKey_01'; + const now = Date.now(); + const route = db.createNotificationRoute({ + name: 'apprise-route', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + channel_type: 'apprise', + channel_url: `http://apprise.local/notify/${keySecret}`, + config: '{}', + priority: 0, + enabled: true, + created_at: now, + updated_at: now, + }); + + const raw = db.getDb().prepare('SELECT * FROM notification_routes WHERE id = ?').get(route.id) as { + channel_url: string; + config: string | null; + }; + expect(raw.channel_url).toMatch(/^enc:/); + expect(raw.channel_url).not.toContain(keySecret); + expect(raw.config).toMatch(/^enc:/); + + const loaded = db.getNotificationRoute(route.id)!; + expect(loaded.channel_url).toBe(`http://apprise.local/notify/${keySecret}`); + const pub = serializePublicNotificationRoute(loaded); + expect(pub.secrets_redacted).toBe(true); + expect(pub.channel_url).toContain(''); + expect(JSON.stringify(pub)).not.toContain(keySecret); + }); + + it('does not encrypt Discord agent URLs (unchanged channel behavior)', () => { + const db = DatabaseService.getInstance(); + const url = 'https://discord.com/api/webhooks/123/plaintext-token'; + db.upsertAgent(1, { type: 'discord', url, enabled: true }); + const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('discord') as { url: string }; + expect(raw.url).toBe(url); + expect(raw.url.startsWith('enc:')).toBe(false); + const pub = serializePublicAgent(db.getAgents(1).find(a => a.type === 'discord')!); + expect(pub.secrets_redacted).toBe(false); + expect(pub.url).toBe(url); + }); + + it('isolates corrupt Apprise ciphertext so sibling channels still load and Apprise can be repaired', () => { + const db = DatabaseService.getInstance(); + const discordUrl = 'https://discord.com/api/webhooks/123/plaintext-token'; + db.upsertAgent(1, { type: 'discord', url: discordUrl, enabled: true }); + db.upsertAgent(1, { + type: 'apprise', + url: 'http://apprise.local/notify/good-key', + enabled: true, + config: '{}', + }); + + // Simulate backup-restore / key-rotation damage: enc: prefix with invalid payload. + db.getDb().prepare('UPDATE agents SET url = ?, config = ? WHERE type = ?').run( + 'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb', + 'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb', + 'apprise', + ); + + const agents = db.getAgents(1); + expect(agents.find(a => a.type === 'discord')!.url).toBe(discordUrl); + const broken = agents.find(a => a.type === 'apprise')!; + expect(broken.url).toBe(''); + expect(broken.config).toBeNull(); + + const enabled = db.getEnabledAgents(1); + expect(enabled.some(a => a.type === 'discord')).toBe(true); + + db.upsertAgent(1, { + type: 'apprise', + url: 'http://apprise.local/notify/repaired-key', + enabled: true, + config: '{}', + }); + expect(db.getAgents(1).find(a => a.type === 'apprise')!.url).toBe( + 'http://apprise.local/notify/repaired-key', + ); + }); + + it('isolates corrupt Apprise route ciphertext from the routes list', () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + db.createNotificationRoute({ + name: 'discord-ok', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/token', + config: null, + priority: 0, + enabled: true, + created_at: now, + updated_at: now, + }); + const apprise = db.createNotificationRoute({ + name: 'apprise-broken', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + channel_type: 'apprise', + channel_url: 'http://apprise.local/notify/route-key', + config: '{}', + priority: 1, + enabled: true, + created_at: now, + updated_at: now, + }); + db.getDb().prepare('UPDATE notification_routes SET channel_url = ? WHERE id = ?').run( + 'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb', + apprise.id, + ); + + const routes = db.getNotificationRoutes(); + expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com'); + expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe(''); + }); +}); diff --git a/backend/src/__tests__/configuration-status-normalize.test.ts b/backend/src/__tests__/configuration-status-normalize.test.ts new file mode 100644 index 00000000..96e95af0 --- /dev/null +++ b/backend/src/__tests__/configuration-status-normalize.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus'; + +describe('normalizeConfigurationAgents', () => { + it('defaults missing apprise to disabled/unconfigured', () => { + const normalized = normalizeConfigurationAgents({ + discord: { configured: true, enabled: true }, + slack: { configured: false, enabled: false }, + webhook: { configured: true, enabled: false }, + }); + expect(normalized.apprise).toEqual({ configured: false, enabled: false }); + expect(normalized.discord.enabled).toBe(true); + }); + + it('preserves an explicit apprise slot', () => { + const normalized = normalizeConfigurationAgents({ + discord: { configured: false, enabled: false }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + apprise: { configured: true, enabled: true }, + }); + expect(normalized.apprise).toEqual({ configured: true, enabled: true }); + }); +}); + +describe('normalizeRemoteConfigurationStatus', () => { + it('passes through stub payloads without notifications.agents', () => { + const stub = { ssoConfigured: false, alertsConfigured: true }; + expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub); + }); + + it('fills missing apprise when an agents block is present', () => { + const raw = { + notifications: { + agents: { + discord: { configured: true, enabled: true }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + }, + }, + }; + const normalized = normalizeRemoteConfigurationStatus(raw); + expect(normalized.notifications.agents).toEqual({ + discord: { configured: true, enabled: true }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + apprise: { configured: false, enabled: false }, + }); + }); +}); diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts index c14891f4..ba4d0758 100644 --- a/backend/src/__tests__/dashboard-routes.test.ts +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -54,7 +54,12 @@ describe('GET /api/dashboard/configuration', () => { expect(res.body).toMatchObject({ tier: expect.any(String), notifications: { - agents: { discord: { configured: expect.any(Boolean) }, slack: { configured: expect.any(Boolean) }, webhook: { configured: expect.any(Boolean) } }, + agents: { + discord: { configured: expect.any(Boolean) }, + slack: { configured: expect.any(Boolean) }, + webhook: { configured: expect.any(Boolean) }, + apprise: { configured: expect.any(Boolean) }, + }, alertRules: expect.any(Number), routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) }, }, diff --git a/backend/src/__tests__/json-parser-bypass.test.ts b/backend/src/__tests__/json-parser-bypass.test.ts index bfaebb7e..65e7f59e 100644 --- a/backend/src/__tests__/json-parser-bypass.test.ts +++ b/backend/src/__tests__/json-parser-bypass.test.ts @@ -108,4 +108,29 @@ describe('conditionalJsonParser remote-proxy bypass', () => { expect(lastUpstreamAuth).toBeNull(); expect([200, 404]).toContain(res.status); }); + + it('forwards Apprise agent config bodies intact to the remote', async () => { + lastUpstreamBody = null; + lastUpstreamAuth = null; + + const payload = { + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: { urls: 'discord://webhook-id/webhook-token?token=query-secret' }, + }; + + const res = await request(app) + .post('/api/agents') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)) + .set('Content-Type', 'application/json') + .send(payload); + + expect(res.status).toBe(200); + expect(lastUpstreamBody).not.toBeNull(); + expect(JSON.parse(lastUpstreamBody!.toString('utf-8'))).toEqual(payload); + expect(lastUpstreamAuth).toBe('Bearer bypass-test-token'); + expect(JSON.stringify(res.body)).not.toContain('query-secret'); + }); }); diff --git a/backend/src/__tests__/notification-apprise.test.ts b/backend/src/__tests__/notification-apprise.test.ts new file mode 100644 index 00000000..5669e2cb --- /dev/null +++ b/backend/src/__tests__/notification-apprise.test.ts @@ -0,0 +1,197 @@ +/** + * Apprise delivery through NotificationService: payloads, status classes, + * fail-closed malformed config, and dispatch_error recording. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { + mockGetEnabledNotificationRoutes, + mockGetEnabledNotificationSuppressionRules, + mockGetEnabledAgents, + mockGetStackLabelIds, + mockAddNotificationHistory, + mockUpdateNotificationDispatchError, +} = vi.hoisted(() => ({ + mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), + mockGetEnabledAgents: vi.fn().mockReturnValue([]), + mockGetStackLabelIds: vi.fn().mockReturnValue([]), + mockAddNotificationHistory: vi.fn().mockReturnValue({ + id: 99, + level: 'info', + message: 'test', + timestamp: Date.now(), + is_read: 0, + }), + mockUpdateNotificationDispatchError: vi.fn(), +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, + getEnabledAgents: mockGetEnabledAgents, + getStackLabelIds: mockGetStackLabelIds, + addNotificationHistory: mockAddNotificationHistory, + updateNotificationDispatchError: mockUpdateNotificationDispatchError, + }), + }, +})); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDefaultNodeId: () => 1, + getComposeDir: () => '/app/compose', + }), + }, +})); + +vi.mock('../services/StackActivityMetricsService', () => ({ + StackActivityMetricsService: { + getInstance: () => ({ + record: vi.fn(), + }), + }, +})); + +import { NotificationService, NotificationDeliveryError } from '../services/NotificationService'; + +const KEYED = 'http://apprise.local/notify/key-secret'; +const STATELESS = 'http://apprise.local/notify'; + +function makeAppriseRoute(overrides: Record = {}) { + return { + id: 1, + name: 'Apprise route', + node_id: null, + stack_patterns: ['my-app'], + label_ids: null, + categories: null, + channel_type: 'apprise' as const, + channel_url: KEYED, + config: '{}', + priority: 0, + enabled: true, + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +describe('NotificationService - Apprise delivery', () => { + let svc: NotificationService; + let mockFetch: ReturnType; + + beforeEach(() => { + (NotificationService as unknown as { instance?: NotificationService }).instance = undefined; + svc = NotificationService.getInstance(); + mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + mockGetEnabledNotificationRoutes.mockReturnValue([]); + mockGetEnabledNotificationSuppressionRules.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([]); + mockUpdateNotificationDispatchError.mockClear(); + mockAddNotificationHistory.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('sends keyed payload without tag when tags are empty', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute({ config: '{}' })]); + await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' }); + expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + title: 'Sencho Alert [WARNING]', + body: 'boom', + type: 'warning', + }), + })); + }); + + it('sends keyed tag and maps error to failure', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeAppriseRoute({ config: JSON.stringify({ tags: 'ops' }) }), + ]); + await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' }); + expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({ + body: JSON.stringify({ + title: 'Sencho Alert [ERROR]', + body: 'boom', + type: 'failure', + tag: 'ops', + }), + })); + }); + + it('sends stateless urls from stored config via agent fallback', async () => { + mockGetEnabledAgents.mockReturnValue([{ + type: 'apprise', + url: STATELESS, + enabled: true, + config: JSON.stringify({ urls: 'discord://token mailto://a@b.com' }), + }]); + await svc.dispatchAlert('info', 'deploy_success', 'ok'); + expect(mockFetch).toHaveBeenCalledWith(STATELESS, expect.objectContaining({ + body: JSON.stringify({ + title: 'Sencho Alert [INFO]', + body: 'ok', + type: 'info', + urls: 'discord://token mailto://a@b.com', + }), + })); + }); + + it('treats HTTP 204 as non-retryable failure and records dispatch_error', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute()]); + await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' }); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 99, + expect.stringContaining('HTTP 204'), + ); + const errText = String(mockUpdateNotificationDispatchError.mock.calls[0][1]); + expect(errText).not.toContain('key-secret'); + }); + + it('classifies 4xx as non-retryable and 5xx as retryable via testDispatch', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 400 }); + await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({ + message: expect.stringContaining('HTTP 400'), + retryable: false, + } satisfies Partial); + + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({ + message: expect.stringContaining('HTTP 503'), + retryable: true, + }); + }); + + it('wraps network failures as retryable sanitized errors', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({ + message: 'Apprise request failed', + retryable: true, + status: null, + }); + }); + + it('does not fetch when stored config is malformed and records a sanitized error', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeAppriseRoute({ config: '{broken', channel_url: KEYED }), + ]); + await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 99, + expect.stringContaining('Apprise configuration is missing or invalid'), + ); + expect(String(mockUpdateNotificationDispatchError.mock.calls[0][1])).not.toContain('key-secret'); + }); +}); diff --git a/backend/src/__tests__/notification-channel-config-migration.test.ts b/backend/src/__tests__/notification-channel-config-migration.test.ts new file mode 100644 index 00000000..73c7004e --- /dev/null +++ b/backend/src/__tests__/notification-channel-config-migration.test.ts @@ -0,0 +1,108 @@ +/** + * Additive `config` columns on agents and notification_routes. + * Exercises production DatabaseService startup against a pre-config schema. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { DatabaseService } from '../services/DatabaseService'; + +function resetDatabaseSingleton(): void { + const holder = DatabaseService as unknown as { instance?: DatabaseService }; + const existing = holder.instance; + if (existing) { + try { + existing.getDb().close(); + } catch { + // already closed + } + holder.instance = undefined; + } +} + +describe('notification channel config column migration', () => { + let scratchDir: string | null = null; + + afterEach(() => { + resetDatabaseSingleton(); + if (scratchDir) { + try { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } catch { + // best-effort + } + scratchDir = null; + } + }); + + it('adds config columns via DatabaseService startup and preserves legacy row values', () => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-apprise-mig-')); + const dbPath = path.join(scratchDir, 'sencho.db'); + const seed = new Database(dbPath); + try { + seed.exec(` + CREATE TABLE agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL, + url TEXT NOT NULL, + enabled INTEGER DEFAULT 0 + ); + INSERT INTO agents (node_id, type, url, enabled) + VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1); + + CREATE TABLE notification_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + channel_type TEXT NOT NULL, + channel_url TEXT NOT NULL, + stack_patterns TEXT NOT NULL DEFAULT '[]', + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO notification_routes + (name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at) + VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1); + `); + } finally { + seed.close(); + } + + process.env.DATA_DIR = scratchDir; + resetDatabaseSingleton(); + const db = DatabaseService.getInstance(); + + const agentCols = db.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>; + const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>; + expect(agentCols.filter(c => c.name === 'config')).toHaveLength(1); + expect(routeCols.filter(c => c.name === 'config')).toHaveLength(1); + + const agent = db.getDb().prepare('SELECT type, url, config FROM agents WHERE type = ?').get('discord') as { + type: string; url: string; config: string | null; + }; + expect(agent.url).toBe('https://discord.example/webhook/legacy'); + expect(agent.config).toBeNull(); + + const route = db.getDb().prepare('SELECT name, channel_url, config FROM notification_routes WHERE name = ?').get('Legacy') as { + name: string; channel_url: string; config: string | null; + }; + expect(route.channel_url).toBe('https://hooks.slack.com/services/legacy'); + expect(route.config).toBeNull(); + + // Idempotent reopen: columns stay singular and values stay intact. + resetDatabaseSingleton(); + process.env.DATA_DIR = scratchDir; + const db2 = DatabaseService.getInstance(); + const agentCols2 = db2.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>; + expect(agentCols2.filter(c => c.name === 'config')).toHaveLength(1); + const agent2 = db2.getDb().prepare('SELECT url, config FROM agents WHERE type = ?').get('discord') as { + url: string; config: string | null; + }; + expect(agent2.url).toBe('https://discord.example/webhook/legacy'); + expect(agent2.config).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/notification-channels.test.ts b/backend/src/__tests__/notification-channels.test.ts index 52854ba4..773d0487 100644 --- a/backend/src/__tests__/notification-channels.test.ts +++ b/backend/src/__tests__/notification-channels.test.ts @@ -1,9 +1,17 @@ /** - * Unit tests for notification channel helpers. Focused on maskWebhookUrl, - * which must never let a channel's embedded auth token reach a log line. + * Unit tests for notification channel helpers: masking, Apprise validation, + * fail-closed stored parsing, and public DTO redaction. */ import { describe, it, expect } from 'vitest'; -import { maskWebhookUrl } from '../helpers/notificationChannels'; +import { + classifyAppriseConfig, + maskWebhookUrl, + normalizeAppriseStoredJson, + parseStoredAppriseConfig, + serializePublicAgent, + serializePublicNotificationRoute, + validateNotificationChannel, +} from '../helpers/notificationChannels'; describe('maskWebhookUrl', () => { it('redacts the token-bearing path of a Discord webhook URL', () => { @@ -47,3 +55,172 @@ describe('maskWebhookUrl', () => { expect(maskWebhookUrl('not a url')).toBe(''); }); }); + +describe('Apprise channel helpers', () => { + it('accepts empty keyed configuration (missing, null, or {})', () => { + const endpoint = 'http://apprise.local/notify/key'; + expect(validateNotificationChannel('apprise', endpoint)).toBeNull(); + expect(validateNotificationChannel('apprise', endpoint, null)).toBeNull(); + expect(validateNotificationChannel('apprise', endpoint, {})).toBeNull(); + expect(classifyAppriseConfig(endpoint, null)).toEqual({ mode: 'keyed' }); + expect(normalizeAppriseStoredJson(endpoint, null)).toBe('{}'); + expect(normalizeAppriseStoredJson(endpoint, { tags: '' })).toBe('{}'); + expect(normalizeAppriseStoredJson(endpoint, { tags: 'ops' })).toBe(JSON.stringify({ tags: 'ops' })); + }); + + it('validates and classifies keyed and stateless configurations', () => { + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops' })).toBeNull(); + expect(classifyAppriseConfig('http://apprise.local/notify', { urls: 'discord://token slack://token' })).toEqual({ + mode: 'stateless', + urls: ['discord://token', 'slack://token'], + }); + expect(validateNotificationChannel('apprise', 'https://apprise.local/notify/key', { urls: 'discord://token' })) + .toMatch(/urls|unknown/); + }); + + it('rejects unknown config keys and scheme-less destination tokens', () => { + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops', extra: 1 })) + .toMatch(/unknown Apprise config field/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', mode: 'stateless' })) + .toMatch(/public summary|unknown/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'not-a-scheme-token' })) + .toMatch(/URI scheme/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', tags: 'x' })) + .toMatch(/tags/); + }); + + it('redacts Apprise endpoint keys and service URLs from public agents', () => { + const secret = 'discord://webhook-id/webhook-token?secret=query'; + const agent = serializePublicAgent({ + id: 1, + type: 'apprise', + url: 'https://apprise.local/notify/key-secret', + enabled: true, + config: JSON.stringify({ tags: 'ops' }), + }); + + expect(agent).toMatchObject({ + type: 'apprise', + url: 'https://apprise.local/notify/', + config: { mode: 'keyed', tags: 'ops', has_urls: false }, + secrets_redacted: true, + }); + expect(JSON.stringify(agent)).not.toContain(secret); + }); + + it('never leaks Apprise secrets from userinfo, host, path, or query in public DTOs', () => { + const secrets = [ + 'userinfo-secret', + 'host-token.example', + 'path-secret', + 'query-secret', + ]; + const urls = [ + 'discord://userinfo-secret@host/path-secret?token=query-secret', + 'feishu://host-token.example/path-secret', + 'jira://APIKey:userinfo-secret@host/path-secret?token=query-secret', + ].join(' '); + + const agent = serializePublicAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: JSON.stringify({ urls }), + }); + const route = serializePublicNotificationRoute({ + id: 1, + name: 'r', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + channel_type: 'apprise', + channel_url: 'http://apprise.local/notify/path-secret', + config: JSON.stringify({ tags: 'ops' }), + priority: 0, + enabled: true, + created_at: 1, + updated_at: 1, + }); + + const blob = JSON.stringify({ agent, route }); + for (const secret of secrets) { + expect(blob).not.toContain(secret); + } + expect(agent.config).toMatchObject({ + mode: 'stateless', + has_urls: true, + providers: expect.arrayContaining(['discord', 'feishu', 'jira']), + url_count: 3, + }); + expect(route.channel_url).toBe('http://apprise.local/notify/'); + }); + + it('rejects public DTO config shapes on validate', () => { + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { + mode: 'stateless', + has_urls: true, + providers: ['discord'], + url_count: 1, + })).toMatch(/raw Apprise config/); + }); + + it('fail-closes malformed stored JSON and never exposes raw tokens as providers', () => { + const keyed = parseStoredAppriseConfig('http://apprise.local/notify/key', '{not-json'); + expect(keyed).toEqual({ ok: false, reason: 'Apprise configuration is missing or invalid' }); + + const badToken = parseStoredAppriseConfig( + 'http://apprise.local/notify', + JSON.stringify({ urls: 'super-secret-token' }), + ); + expect(badToken.ok).toBe(false); + + const agent = serializePublicAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: JSON.stringify({ urls: 'super-secret-token' }), + }); + expect(agent.config).toBeNull(); + expect(JSON.stringify(agent)).not.toContain('super-secret-token'); + }); + + it('rejects Apprise notify keys outside the official alphanumeric/underscore/dash charset', () => { + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad.key')).toMatch(/notify key/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/has%20space')).toMatch(/notify key|valid configuration/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad!key')).toMatch(/notify key/); + expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'a'.repeat(129)}`)).toMatch(/notify key/); + expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/a')).toBeNull(); + expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'A1_-'}${'b'.repeat(124)}`)).toBeNull(); + }); + + it('sets secrets_redacted only for Apprise public DTOs', () => { + expect(serializePublicAgent({ + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + }).secrets_redacted).toBe(false); + expect(serializePublicAgent({ + type: 'slack', + url: 'https://hooks.slack.com/services/a/b/c', + enabled: true, + }).secrets_redacted).toBe(false); + expect(serializePublicAgent({ + type: 'webhook', + url: 'https://example.com/hook', + enabled: true, + }).secrets_redacted).toBe(false); + expect(serializePublicAgent({ + type: 'apprise', + url: 'http://apprise.local/notify/k', + enabled: true, + config: '{}', + }).secrets_redacted).toBe(true); + }); + + it('treats null/empty stored config as valid empty keyed', () => { + expect(parseStoredAppriseConfig('http://apprise.local/notify/key', null)).toEqual({ ok: true, mode: 'keyed' }); + expect(parseStoredAppriseConfig('http://apprise.local/notify/key', '{}')).toEqual({ ok: true, mode: 'keyed' }); + expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false); + }); +}); diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts index c478c937..87c8be1b 100644 --- a/backend/src/__tests__/notification-routes-api.test.ts +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -170,7 +170,7 @@ describe('POST /api/agents - validation', () => { .set('Cookie', authCookie) .send({ type: 'telegram', url: 'https://example.com/hook', enabled: true }); expect(res.status).toBe(400); - expect(res.body.error).toContain('discord, slack, webhook'); + expect(res.body.error).toContain('discord, slack, webhook, apprise'); }); it('rejects non-HTTPS url', async () => { @@ -346,7 +346,7 @@ describe('POST /api/notification-routes - validation', () => { .set('Cookie', authCookie) .send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' }); expect(res.status).toBe(400); - expect(res.body.error).toContain('discord, slack, webhook'); + expect(res.body.error).toContain('discord, slack, webhook, apprise'); }); it('rejects non-HTTPS channel_url', async () => { @@ -464,6 +464,282 @@ describe('POST /api/notification-routes/:id/test', () => { }); }); +describe('Apprise notification routes - redaction and preserve-on-write', () => { + const keyedUrl = 'http://apprise.local/notify/route-key-secret'; + const serviceUrl = 'mailto://user:pass@smtp.example.com?to=ops@example.com'; + let routeId: number; + + beforeAll(async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Apprise route', + stack_patterns: ['app'], + channel_type: 'apprise', + channel_url: keyedUrl, + config: { tags: 'ops' }, + enabled: true, + }); + expect(res.status).toBe(201); + routeId = res.body.id; + }); + + it('POST 201 and GET redact the keyed endpoint path', async () => { + const created = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Apprise create redact', + stack_patterns: [], + channel_type: 'apprise', + channel_url: keyedUrl, + config: { tags: 'night' }, + }); + expect(created.status).toBe(201); + expect(created.body.channel_url).toBe('http://apprise.local/notify/'); + expect(created.body.config).toMatchObject({ mode: 'keyed', tags: 'night', has_urls: false }); + expect(JSON.stringify(created.body)).not.toContain('route-key-secret'); + + const listed = await request(app).get('/api/notification-routes').set('Cookie', authCookie); + const row = listed.body.find((r: { id: number }) => r.id === created.body.id); + expect(row.channel_url).toBe('http://apprise.local/notify/'); + expect(JSON.stringify(row)).not.toContain('route-key-secret'); + + await request(app).delete(`/api/notification-routes/${created.body.id}`).set('Cookie', authCookie); + }); + + it('POST 201 for stateless mode returns providers and url_count only', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Apprise stateless', + stack_patterns: [], + channel_type: 'apprise', + channel_url: 'http://apprise.local/notify', + config: { urls: serviceUrl }, + }); + expect(res.status).toBe(201); + expect(res.body.channel_url).toBe('http://apprise.local/notify'); + expect(res.body.config).toMatchObject({ + mode: 'stateless', + has_urls: true, + providers: ['mailto'], + url_count: 1, + }); + expect(JSON.stringify(res.body)).not.toContain('pass@'); + expect(JSON.stringify(res.body)).not.toContain('ops@example.com'); + + const stored = DatabaseService.getInstance().getNotificationRoute(res.body.id); + expect(stored?.channel_url).toBe('http://apprise.local/notify'); + expect(stored?.config).toContain(serviceUrl); + + await request(app).delete(`/api/notification-routes/${res.body.id}`).set('Cookie', authCookie); + }); + + it('PUT preserves secrets when channel_url and config are omitted', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ name: 'Apprise route renamed', enabled: false }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Apprise route renamed'); + expect(res.body.channel_url).toBe('http://apprise.local/notify/'); + expect(JSON.stringify(res.body)).not.toContain('route-key-secret'); + + const stored = DatabaseService.getInstance().getNotificationRoute(routeId); + expect(stored?.channel_url).toBe(keyedUrl); + expect(stored?.config).toBe(JSON.stringify({ tags: 'ops' })); + }); + + it('PUT rejects channel_type change without a new channel_url and leaves the row unchanged', async () => { + const before = DatabaseService.getInstance().getDb() + .prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?') + .get(routeId) as { channel_type: string; channel_url: string; config: string | null }; + expect(before.channel_type).toBe('apprise'); + expect(before.channel_url.startsWith('enc:')).toBe(true); + + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ channel_type: 'discord' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/channel_url is required when changing channel_type/i); + + const after = DatabaseService.getInstance().getDb() + .prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?') + .get(routeId) as { channel_type: string; channel_url: string; config: string | null }; + expect(after).toEqual(before); + }); + + it('PUT Apprise-to-Discord with a raw URL stores plaintext Discord credentials', async () => { + const created = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Apprise leave', + stack_patterns: [], + channel_type: 'apprise', + channel_url: 'http://apprise.local/notify/leave-key', + config: { tags: 'ops' }, + }); + expect(created.status).toBe(201); + const id = created.body.id as number; + + const discordUrl = 'https://discord.com/api/webhooks/1/token-secret'; + const res = await request(app) + .put(`/api/notification-routes/${id}`) + .set('Cookie', authCookie) + .send({ channel_type: 'discord', channel_url: discordUrl }); + expect(res.status).toBe(200); + expect(res.body.channel_type).toBe('discord'); + + const raw = DatabaseService.getInstance().getDb() + .prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?') + .get(id) as { channel_type: string; channel_url: string; config: string | null }; + expect(raw.channel_type).toBe('discord'); + expect(raw.channel_url).toBe(discordUrl); + expect(raw.channel_url.startsWith('enc:')).toBe(false); + expect(raw.config).toBeNull(); + + await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie); + }); + + it('PUT webhook-to-Apprise seals both channel_url and config as enc: ciphertext', async () => { + const notifyUrl = 'https://apprise.example/notify/promote-key'; + const created = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Webhook promote', + stack_patterns: [], + channel_type: 'webhook', + channel_url: notifyUrl, + }); + expect(created.status).toBe(201); + const id = created.body.id as number; + + const before = DatabaseService.getInstance().getDb() + .prepare('SELECT channel_url FROM notification_routes WHERE id = ?') + .get(id) as { channel_url: string }; + expect(before.channel_url).toBe(notifyUrl); + + const res = await request(app) + .put(`/api/notification-routes/${id}`) + .set('Cookie', authCookie) + .send({ + channel_type: 'apprise', + channel_url: notifyUrl, + config: { tags: 'fleet' }, + }); + expect(res.status).toBe(200); + expect(res.body.channel_type).toBe('apprise'); + expect(res.body.channel_url).toBe('https://apprise.example/notify/'); + + const raw = DatabaseService.getInstance().getDb() + .prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?') + .get(id) as { channel_type: string; channel_url: string; config: string | null }; + expect(raw.channel_type).toBe('apprise'); + expect(raw.channel_url.startsWith('enc:')).toBe(true); + expect(raw.channel_url).not.toContain('promote-key'); + expect(raw.config?.startsWith('enc:')).toBe(true); + expect(raw.config).not.toContain('fleet'); + + const stored = DatabaseService.getInstance().getNotificationRoute(id); + expect(stored?.channel_url).toBe(notifyUrl); + expect(stored?.config).toBe(JSON.stringify({ tags: 'fleet' })); + + await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie); + }); + + it('PUT rejects a redacted channel_url', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ channel_url: 'http://apprise.local/notify/' }); + expect(res.status).toBe(400); + }); + + it('PUT rejects public DTO config shapes', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ + channel_url: keyedUrl, + config: { mode: 'keyed', has_urls: false, providers: [] }, + }); + expect(res.status).toBe(400); + }); + + it('stored-route test uses raw config and returns sanitized 204 errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const ok = await request(app) + .post(`/api/notification-routes/${routeId}/test`) + .set('Cookie', authCookie); + expect(ok.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + keyedUrl, + expect.objectContaining({ + body: JSON.stringify({ + title: 'Sencho Alert [INFO]', + body: '🔌 Test Notification from Sencho!', + type: 'info', + tag: 'ops', + }), + }), + ); + } finally { + vi.unstubAllGlobals(); + } + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 })); + try { + const fail = await request(app) + .post(`/api/notification-routes/${routeId}/test`) + .set('Cookie', authCookie); + expect(fail.status).toBe(500); + expect(JSON.stringify(fail.body)).toContain('HTTP 204'); + expect(JSON.stringify(fail.body)).not.toContain('route-key-secret'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('stored-route test fails closed on malformed config without fetch', async () => { + const broken = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Broken Apprise', + stack_patterns: [], + channel_type: 'apprise', + channel_url: 'http://apprise.local/notify/broken-key', + config: { tags: 'x' }, + }); + expect(broken.status).toBe(201); + const id = broken.body.id as number; + DatabaseService.getInstance().updateNotificationRoute(id, { config: '{not-json' }); + + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post(`/api/notification-routes/${id}/test`) + .set('Cookie', authCookie); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(res.body)).not.toContain('broken-key'); + } finally { + vi.unstubAllGlobals(); + await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie); + } + }); +}); + // --- DELETE /api/notifications/:id NaN guard --- describe('DELETE /api/notifications/:id - validation', () => { diff --git a/backend/src/helpers/configurationStatus.ts b/backend/src/helpers/configurationStatus.ts new file mode 100644 index 00000000..afb11abb --- /dev/null +++ b/backend/src/helpers/configurationStatus.ts @@ -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(raw: T): T { + const notifications = (raw as { notifications?: { agents?: Parameters[0] } }).notifications; + if (!notifications?.agents) return raw; + return { + ...raw, + notifications: { + ...notifications, + agents: normalizeConfigurationAgents(notifications.agents), + }, + }; +} diff --git a/backend/src/helpers/notificationChannels.ts b/backend/src/helpers/notificationChannels.ts index a0be58a3..80afa2ac 100644 --- a/backend/src/helpers/notificationChannels.ts +++ b/backend/src/helpers/notificationChannels.ts @@ -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 & { + 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 & { + channel_type: NotificationChannelType; + config: PublicAppriseConfig | null; + secrets_redacted: boolean; +}; + +/** Apprise notify key: 1–128 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; + return 'has_urls' in record || 'providers' in record || 'url_count' in record || 'mode' in record || 'secrets_redacted' in record; +} + +function unknownKeyError(record: Record, allowed: Set): 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 | null { + if (config === undefined || config === null) return {}; + if (typeof config !== 'object' || Array.isArray(config)) return null; + return config as Record; +} + +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 1–128 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): 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('')) + || configText.includes('') + || (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/`; + return `${parsed.origin}/notify`; + } catch { + return ''; + } +} + +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 ''; 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}/` : origin; } catch { diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index 873024e1..527ca960 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -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 => { 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 }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 12c192af..b99797df 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -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. diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index abc739ff..794c3cd4 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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, diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 398b59e1..e83fda05 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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 => { 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 => { 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)) }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4f2568a0..997b81f0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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 { + 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>): 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); } diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index de9555f3..d1ea2542 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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(['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 { + private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise { 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, + ): Promise { + const payload: Record = { + 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) { diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index d7ad204e..782763ca 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -1,23 +1,23 @@ --- title: Alerts & Notifications sidebarTitle: Alerts and notifications -description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and channel routing. +description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, Apprise, or any webhook, with per-stack rules and channel routing. --- -Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention. +Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and an external channel you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention. - Settings · Notifications · Channels panel showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions. + Settings · Notifications · Channels panel with Discord, Slack, Webhook, and Apprise tabs, CHANNELS 1/4 in the masthead, the Apprise tab active with Enabled on, a redacted Apprise endpoint, an empty Tags field, and Test disabled beside Save. ## Notification channels -Open **Settings · Notifications · Channels** to configure the three channel types. Each channel is per-node, so switching the active node via the node picker reloads the panel against that node's stored settings. The masthead carries a `CHANNELS` stat showing how many of the three slots are enabled. +Open **Settings · Notifications · Channels** to configure Discord, Slack, custom webhook, and Apprise channels. Each channel is per-node, so switching the active node via the node picker reloads the panel against that node's stored settings. The masthead carries a `CHANNELS` stat showing how many of the four slots are enabled. -Each tab carries the same controls: an **Enabled** toggle (helper: `Send Sencho events to this channel.`), a **Webhook URL** input (placeholder `https://...`, helper: `Sencho posts JSON payloads here. Use a private channel.`), and the **Test** and **Save** buttons. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up. +Each Discord, Slack, and Webhook tab carries an **Enabled** toggle, a **Webhook URL** input (HTTPS only), and **Test** / **Save**. The Apprise tab uses an **Apprise endpoint** instead: keyed `/notify/{key}` shows optional **Tags**; stateless `/notify` shows **Destination URLs**. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up. - Webhook URLs must use HTTPS. Sencho rejects `http://` and any string that does not parse as a URL. The same rule applies to test sends and to routing-rule URLs. + Discord, Slack, and webhook URLs must use HTTPS. Apprise endpoints may use HTTP or HTTPS. Every endpoint must parse as a URL. ### Discord @@ -43,6 +43,12 @@ Use the Generic Webhook tab when you have your own receiver: a Mattermost or Tea `level` is one of `info`, `warning`, `error`. `source` is always the literal string `sencho`. `timestamp` is ISO-8601 with millisecond precision. +### Apprise + +Use a keyed Apprise endpoint ending in `/notify/` with optional tags, or a stateless endpoint ending in `/notify` with one or more destination URLs. Separate stateless destination URLs with commas or whitespace. Apprise endpoints may use HTTP for a local gateway. + +Sencho sends the alert title, body, severity, and either tags or destination URLs to Apprise. An HTTP 204 response means Apprise accepted no delivery and is reported as a failed test or dispatch. Configuration reads expose only destination provider names and counts, never destination URLs or endpoint keys. + ### Test sends and delivery semantics The **Test** button on each tab dispatches the literal message `🔌 Test Notification from Sencho!` at level `info` through the same path a real alert would take. Test sends require the admin role; the server returns 403 if an operator or viewer submits one. @@ -74,7 +80,7 @@ A route with all four matchers left empty matches every alert and intercepts glo Open **Settings · Notifications · Notification Routing** and click **+ Add Route**. - The 'New routing rule' modal with the 'ROUTING · NEW RULE' kicker, a Name input filled with 'Production alerts', a Node scope select reading 'Any node', empty Stacks, Labels, and Categories combobox pickers, the helper line 'Leave blank to match all categories. All non-empty filters must match (AND).', a Channel tab strip with Discord selected and a webhook URL filled in, and Priority and Enabled fields with Cancel and CREATE actions in the footer. + The New routing rule modal with empty Name, Node scope set to Any node, empty Stacks, Labels, and Categories fields, Channel tabs with Apprise selected, and Cancel and CREATE actions. Priority and Enabled may be scrolled out of the cropped frame. | Field | Purpose | @@ -84,7 +90,7 @@ Open **Settings · Notifications · Notification Routing** and click **+ Add Rou | **Stacks** *(optional)* | A combobox of stacks on the active node. Selected stacks appear as removable pills. Empty matches any stack. | | **Labels** *(optional)* | A combobox of stack labels on the active node. Empty matches any label. | | **Categories** *(optional)* | A combobox of notification categories. The helper line reads `Leave blank to match all categories. All non-empty filters must match (AND).` | -| **Channel** | Tabs for Discord, Slack, and Webhook with a URL input below. URL must use HTTPS. | +| **Channel** | Tabs for Discord, Slack, Webhook, and Apprise. Discord, Slack, and Webhook require HTTPS. Apprise accepts HTTP or HTTPS. | | **Priority** | A number used to sort the rule list. Lower numbers appear higher up. Priority does not gate dispatch: when multiple rules match the same alert, every matching rule fires concurrently. | | **Enabled** | Toggle the rule on or off without deleting it. | @@ -164,7 +170,7 @@ Each stack carries its own set of threshold rules that fire when a metric stays Open the rules editor by right-clicking a stack in the sidebar and choosing **Alerts**, or by pressing `A` while focused on a stack. - The Stack › PLEX › MONITOR sheet with the Alerts tab active, a green 'Notifications active via Discord, Slack, Webhook' banner under the NOTIFICATION CHANNELS heading, an ACTIVE RULES section listing 'CPU Usage (%) > 80' with the secondary line 'Trigger after 5m • Cooldown 60m', and an ADD NEW RULE form with Metric, Operator, Threshold, Duration, Cooldown fields and an Add Rule submit button. + The Stack › PLEX › MONITOR sheet with the Alerts tab active, a green notification-channel banner under the NOTIFICATION CHANNELS heading, an ACTIVE RULES section listing 'CPU Usage (%) > 80' with the secondary line 'Trigger after 5m • Cooldown 60m', and an ADD NEW RULE form with Metric, Operator, Threshold, Duration, Cooldown fields and an Add Rule submit button. ### Alert fields @@ -196,7 +202,7 @@ The **NOTIFICATION CHANNELS** banner above the rules list reflects what dispatch - **Loading** is a spinner with `Checking notification channels...` while Sencho asks the target node for its agent state. - **Remote node** is a blue banner reading `Remote node: `, with the body `Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.` A sub-line reports whether the remote has any channels configured. -- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in Settings → Notifications → Channels.` +- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, or a webhook in Settings → Notifications → Channels.` - **Active** is a green banner reading `Notifications active via Discord, Slack, …` with the configured channels listed. @@ -287,7 +293,7 @@ The bell aggregates across the entire fleet by hitting `/api/notifications` agai Per-stack alert rules and channel configuration are **stored on the node where the stack runs**. To configure a rule on a remote, switch the active node via the picker, open the stack's **Monitor** sheet, and the form's POST is forwarded to the remote. -Crash detection runs only on local Docker; remote nodes run their own copy of `DockerEventService` and emit through the proxy. On a multi-node fleet the bell attributes remotes with a node-name badge rather than embedding the satellite-local name in the message. External channel payloads (Slack, Discord, custom webhook) receive the same sanitized body with no structured source-node id. +Crash detection runs only on local Docker; remote nodes run their own copy of `DockerEventService` and emit through the proxy. On a multi-node fleet the bell attributes remotes with a node-name badge rather than embedding the satellite-local name in the message. External channel payloads (Slack, Discord, Apprise, custom webhook) receive the same sanitized body with no structured source-node id. Switching the active node only affects per-stack rule editing and the Settings panels. The bell aggregates every node regardless of which one is active. @@ -433,8 +439,8 @@ Switching the active node tears down per-stack rule editors and reloads channel ## Troubleshooting - - Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, the URL must use HTTPS; the form rejects plain `http://` outright. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. + + Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, Discord, Slack, and webhook URLs must use HTTPS (the form rejects plain `http://` for those channels); Apprise endpoints may use HTTP or HTTPS. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. Three causes account for almost every case. First, the rule's **Duration** has not elapsed yet: the breach must persist for the full duration before the rule fires. Second, the rule is still in cooldown after a previous fire. Third, the panel's banner is not green: a remote-node banner means the rule was saved on a remote whose channels you may not have configured, and an amber `No notification channels configured` banner means the rule evaluates fine but Sencho has nowhere to send the alert. The evaluator runs on a 30-second tick, so expect up to 30 seconds of latency between the breach starting and the timer engaging. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 2ed0aa2c..db8c418c 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -87,7 +87,7 @@ The card is divided into four sections. | Row | What it shows | |-----|---------------| -| **Notification agents** | The list of enabled delivery agents from `Discord`, `Slack`, `Webhook`, joined by commas; reads `None` when no agent is enabled | +| **Notification agents** | The list of enabled delivery agents from `Discord`, `Slack`, `Webhook`, and `Apprise`, joined by commas; reads `None` when no agent is enabled | | **Alert rules** | Total per-stack alert rules in effect, formatted ` rules` | | **Notification routing** | Number of enabled routing rules that direct categories to specific agents, formatted ` routes` | diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index b1d07d8f..7b543c95 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -36,12 +36,12 @@ See [the pricing page](https://sencho.io/pricing) for current pricing. - Atomic deployments with automatic rollback, and one-click rollback to the previous deployment - Auto-update policies for stack images and auto-heal policies for failed containers - Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune) -- Webhooks (incoming, to trigger deploys from CI/CD) and notification routing (per-stack and per-category rules to Discord, Slack, or any webhook) +- Webhooks (incoming, to trigger deploys from CI/CD) and notification routing (per-stack and per-category rules to Discord, Slack, Apprise, or any webhook) - Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket) - Vulnerability scanning: install, update, uninstall, and auto-update the managed Trivy binary, on-demand scans for vulnerabilities, secrets, and misconfigurations, scan comparison, CVE suppressions, and single-scan SBOM export (SPDX, CycloneDX) - Private registry credentials for Docker Hub, GitHub Container Registry (GHCR), and custom or self-hosted registries (admin role required) - A 14-day recent-activity audit log with Stream and Table views and filtering -- Alert rules with Discord, Slack, and webhook targets +- Alert rules with Discord, Slack, Apprise, and webhook targets - API tokens for CI/CD pipelines and scripts (admin role required) - Unlimited accounts with the Admin and Viewer roles - Two-factor authentication (TOTP plus backup codes) diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index e1850a83..6eaeb43e 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -189,7 +189,7 @@ When you select a remote node in the switcher, the Settings hub filters to the p |-------|:-----:|-------| | Appearance | Per browser | Theme and density preferences are stored in your browser, not on the node. | | Host Alerts | Per node | Host CPU, RAM, and disk thresholds for the selected node. | -| Channels | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. | +| Channels | Per node | Discord, Slack, Webhook, and Apprise channels fire from the node that detects the event. | | Labels | Per node | Stack and container label palettes. | | Vulnerability Scanning | Per node | Trivy install state and scanner readiness for the selected node. | | Developer | Per node | Retention windows for metrics and logs, plus Developer Mode. | diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index db207aac..3c4ff465 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -147,7 +147,7 @@ The **Logs** view aggregates output from every container across every stack into ### Alerts and notifications -Configure threshold-based alerts per stack and route notifications to Discord, Slack, or any generic webhook. Per-stack [routing rules](/features/alerts-notifications#notification-routing) send each stack's alerts to the right channel. [Learn more →](/features/alerts-notifications) +Configure threshold-based alerts per stack and route notifications to Discord, Slack, Apprise, or any generic webhook. Per-stack [routing rules](/features/alerts-notifications#notification-routing) send each stack's alerts to the right channel. [Learn more →](/features/alerts-notifications) ### Audit log diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 2ebb2de2..f520f166 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -216,7 +216,7 @@ Use the **Enabled** toggle in the row to pause a schedule without losing its con ## Failure notifications -When a scheduled task fails, Sencho dispatches an `error`-level notification in the `system` category through your configured channels (Discord, Slack, custom webhooks, and the in-app bell). The notification carries the task name, the action, and the error message so you can diagnose without opening the run history. +When a scheduled task fails, Sencho dispatches an `error`-level notification in the `system` category through your configured channels (Discord, Slack, Apprise, custom webhooks, and the in-app bell). The notification carries the task name, the action, and the error message so you can diagnose without opening the run history. When a task that previously failed succeeds again, Sencho dispatches an `info`-level recovery notification in the same category to confirm the issue is resolved. Tasks that succeed on every run do not produce per-run notifications: the recovery-only approach keeps the channel quiet during steady-state operation. diff --git a/docs/images/alerts-notifications/notifications-settings.png b/docs/images/alerts-notifications/notifications-settings.png index 1b8e64cd..85004f3a 100644 Binary files a/docs/images/alerts-notifications/notifications-settings.png and b/docs/images/alerts-notifications/notifications-settings.png differ diff --git a/docs/images/alerts-notifications/routing-modal.png b/docs/images/alerts-notifications/routing-modal.png index 3a981332..ea879ce7 100644 Binary files a/docs/images/alerts-notifications/routing-modal.png and b/docs/images/alerts-notifications/routing-modal.png differ diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index bf197c6f..0bd7028b 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -501,7 +501,7 @@ docker compose pull && docker compose up -d ## Notification routing rule matches but alerts still go to global channels -**Symptom:** You created a routing rule for a stack, but alerts from that stack still arrive on the global Discord/Slack/Webhook channels. +**Symptom:** You created a routing rule for a stack, but alerts from that stack still arrive on the global channels. **Checks:** diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 2ced5066..a31fffda 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -378,7 +378,7 @@ Quick reference: **Scope:** Per-node (each node has its own notification agents; remote nodes dispatch alerts through their own channels) -Configure external destinations for alert notifications. Three agent types are available on separate tabs: **Discord**, **Slack**, and **Webhook**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/3`). +Configure external destinations for alert notifications. Four agent types are available on separate tabs: **Discord**, **Slack**, **Webhook**, and **Apprise**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/4`). For each agent: @@ -386,6 +386,7 @@ For each agent: |-------|-------------| | **Enabled** toggle | Activates or deactivates this agent. Disabled agents receive no messages even if a URL is saved. | | **Webhook URL** | The endpoint Sencho will POST to when an alert fires. | +| **Apprise** | Use a keyed `/notify/` endpoint with optional tags, or a stateless `/notify` endpoint with destination URLs. Apprise accepts HTTP or HTTPS. | Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery. @@ -410,9 +411,9 @@ Create routing rules that direct specific alert types to specific notification c | **Stack patterns** | Glob patterns to match stack names (multi-select). | | **Labels** | Apply this rule to stacks that carry any of the selected labels. | | **Categories** | Event categories that trigger the rule (crash, deploy, vulnerability, etc.). | -| **Channel type** | `discord`, `slack`, or `webhook`. | +| **Channel type** | `discord`, `slack`, `webhook`, or `apprise`. | | **Channel URL** | The destination endpoint for this rule. | -| **Priority** | Order in which rules are evaluated; the first match wins. | +| **Priority** | Sort order among matching rules. Every matching route fires; priority does not stop later matches. | | **Enabled** toggle | Mute a rule without deleting it. | Each rule keeps an execution history (collapsible per row) showing which alerts triggered it, when, and whether the delivery succeeded. diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 05950dcc..ee665527 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -102,6 +102,7 @@ const agentTypeLabels: Record = { discord: 'Discord', slack: 'Slack', webhook: 'Webhook', + apprise: 'Apprise', }; const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent) => { @@ -314,7 +315,7 @@ function AlertsTab({ stackName }: { stackName: string }) {

No notification channels configured

- Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '} + Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, or a webhook in{' '} Settings → Notifications.

diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 10d66a1e..8fdaf1fd 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react'; import { formatCount } from '@/lib/utils'; +import { normalizeConfigurationAgents } from '@/lib/configurationStatus'; import { useConfigurationStatus } from './useConfigurationStatus'; import type { SectionId } from '@/components/settings/types'; import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager'; @@ -115,11 +116,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps const { notifications, automation, security, thresholds, backup } = status; const agentSummary = (() => { - const { discord, slack, webhook } = notifications.agents; + const { discord, slack, webhook, apprise } = normalizeConfigurationAgents(notifications.agents); const active = [ discord.enabled ? 'Discord' : null, slack.enabled ? 'Slack' : null, webhook.enabled ? 'Webhook' : null, + apprise.enabled ? 'Apprise' : null, ].filter(Boolean); return active.length === 0 ? 'None' : active.join(', '); })(); diff --git a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx index c6470513..377bd152 100644 --- a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx @@ -17,6 +17,7 @@ function makePayload(overrides: Partial = {}): Confi discord: { configured: false, enabled: false }, slack: { configured: false, enabled: false }, webhook: { configured: false, enabled: false }, + apprise: { configured: false, enabled: false }, }, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true }, @@ -88,6 +89,7 @@ describe('ConfigurationStatus row visibility', () => { discord: { configured: false, enabled: false }, slack: { configured: false, enabled: false }, webhook: { configured: false, enabled: false }, + apprise: { configured: false, enabled: false }, }, alertRules: 2, routingRules: { count: 1, enabledCount: 1, locked: false }, @@ -167,3 +169,33 @@ describe('ConfigurationStatus click targets', () => { expect(onOpenSection).toHaveBeenCalledWith('container-alerts'); }); }); + +describe('ConfigurationStatus legacy remote agents', () => { + it('renders a three-channel payload that omits apprise without throwing', () => { + const legacy = makePayload(); + // Simulate older remote contract: no apprise key on the wire. + delete (legacy.notifications.agents as { apprise?: unknown }).apprise; + useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false }); + expect(() => render()).not.toThrow(); + const channelsRow = screen.getByText('Channels').closest('button'); + expect(channelsRow?.textContent).toContain('None'); + }); + + it('summarizes enabled legacy channels without requiring apprise', () => { + const legacy = makePayload({ + notifications: { + agents: { + discord: { configured: true, enabled: true }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + } as ConfigurationStatusPayload['notifications']['agents'], + alertRules: 0, + routingRules: { count: 0, enabledCount: 0, locked: true }, + suppressionRules: { total: 0, enabledCount: 0 }, + }, + }); + useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false }); + render(); + expect(screen.getByText('Discord')).toBeDefined(); + }); +}); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index 960ed3cc..e4c0776f 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -2,20 +2,16 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { visibilityInterval } from '@/lib/utils'; +import { normalizeConfigurationAgents, type AgentStatus } from '@/lib/configurationStatus'; // Trailing-edge debounce window for filtered settings-event refetches, // matching the precedent in useNextAutoUpdateRun. const INVALIDATE_DEBOUNCE_MS = 250; -interface AgentStatus { - configured: boolean; - enabled: boolean; -} - export interface ConfigurationStatus { tier: 'community' | 'paid'; 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 }; @@ -48,6 +44,28 @@ export interface ConfigurationStatus { }; } +/** Wire payload may omit `apprise` from older remotes. */ +type WireConfigurationStatus = Omit & { + notifications: Omit & { + agents: { + discord: AgentStatus; + slack: AgentStatus; + webhook: AgentStatus; + apprise?: AgentStatus; + }; + }; +}; + +function normalizeConfigurationStatus(raw: WireConfigurationStatus): ConfigurationStatus { + return { + ...raw, + notifications: { + ...raw.notifications, + agents: normalizeConfigurationAgents(raw.notifications.agents), + }, + }; +} + export function useConfigurationStatus() { const { activeNode } = useNodes(); const nodeId = activeNode?.id; @@ -61,8 +79,8 @@ export function useConfigurationStatus() { try { const res = await apiFetch('/dashboard/configuration'); if (!res.ok) return; - const data = await res.json() as ConfigurationStatus; - setStatus(data); + const data = await res.json() as WireConfigurationStatus; + setStatus(normalizeConfigurationStatus(data)); } catch { // Silent; stale data stays visible } finally { diff --git a/frontend/src/components/fleet/FleetConfiguration.tsx b/frontend/src/components/fleet/FleetConfiguration.tsx index 7d7bcc81..e475f62f 100644 --- a/frontend/src/components/fleet/FleetConfiguration.tsx +++ b/frontend/src/components/fleet/FleetConfiguration.tsx @@ -10,6 +10,7 @@ import { import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; import { STICKY_CONTROL_IDENTITY_MISMATCH, type FleetSyncStatus } from '@/lib/fleetSyncApi'; import type { ConfigurationStatusPayload } from '@/components/dashboard'; +import { normalizeConfigurationAgents } from '@/lib/configurationStatus'; interface FleetNodeConfiguration { id: number; @@ -115,11 +116,13 @@ function NodeCard({ node, policySyncState }: { } const { notifications, automation, security, backup, thresholds } = node.configuration; + const agents = normalizeConfigurationAgents(notifications.agents); const agentCount = [ - notifications.agents.discord.enabled, - notifications.agents.slack.enabled, - notifications.agents.webhook.enabled, + agents.discord.enabled, + agents.slack.enabled, + agents.webhook.enabled, + agents.apprise.enabled, ].filter(Boolean).length; return ( diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 80c42cd5..81af1180 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -23,6 +23,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; +import { classifyAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint'; interface NotificationRoute { id: number; @@ -31,8 +32,9 @@ interface NotificationRoute { stack_patterns: string[]; label_ids: number[] | null; categories: NotificationCategory[] | null; - channel_type: 'discord' | 'slack' | 'webhook'; + channel_type: 'discord' | 'slack' | 'webhook' | 'apprise'; channel_url: string; + config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null; priority: number; enabled: boolean; created_at: number; @@ -43,12 +45,14 @@ const CHANNEL_LABELS: Record = { discord: 'Discord', slack: 'Slack', webhook: 'Webhook', + apprise: 'Apprise', }; const CHANNEL_PLACEHOLDERS: Record = { discord: 'https://discord.com/api/webhooks/...', slack: 'https://hooks.slack.com/services/...', webhook: 'https://example.com/webhook', + apprise: 'http://apprise.local/notify', }; export function NotificationRoutingSection() { @@ -70,8 +74,15 @@ export function NotificationRoutingSection() { const [formStacks, setFormStacks] = useState([]); const [formLabelIds, setFormLabelIds] = useState([]); const [formCategories, setFormCategories] = useState([]); - const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord'); + const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord'); const [formChannelUrl, setFormChannelUrl] = useState(''); + const [formAppriseUrls, setFormAppriseUrls] = useState(''); + const [formAppriseTags, setFormAppriseTags] = useState(''); + const [appriseEndpointDirty, setAppriseEndpointDirty] = useState(false); + const [appriseConfigDirty, setAppriseConfigDirty] = useState(false); + /** Original Apprise mode when editing; drives preserve hints and forces a config write on mode switch. */ + const [editAppriseOriginalMode, setEditAppriseOriginalMode] = useState<'keyed' | 'stateless' | null>(null); + const [editOriginalChannelType, setEditOriginalChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | null>(null); const [formPriority, setFormPriority] = useState(0); const [formEnabled, setFormEnabled] = useState(true); @@ -123,6 +134,12 @@ export function NotificationRoutingSection() { setFormCategories([]); setFormChannelType('discord'); setFormChannelUrl(''); + setFormAppriseUrls(''); + setFormAppriseTags(''); + setAppriseEndpointDirty(false); + setAppriseConfigDirty(false); + setEditAppriseOriginalMode(null); + setEditOriginalChannelType(null); setFormPriority(0); setFormEnabled(true); setEditingId(null); @@ -138,6 +155,14 @@ export function NotificationRoutingSection() { setFormCategories(route.categories ? [...route.categories] : []); setFormChannelType(route.channel_type); setFormChannelUrl(route.channel_url); + setFormAppriseTags(route.config?.tags ?? ''); + setFormAppriseUrls(''); + setAppriseEndpointDirty(false); + setAppriseConfigDirty(false); + setEditAppriseOriginalMode( + route.channel_type === 'apprise' ? (route.config?.mode ?? null) : null, + ); + setEditOriginalChannelType(route.channel_type); setFormPriority(route.priority); setFormEnabled(route.enabled); setShowForm(true); @@ -145,8 +170,32 @@ export function NotificationRoutingSection() { const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } - if (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) { - toast.error('Channel URL must be a valid HTTPS URL.'); + if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && !formChannelUrl.startsWith('https://'))) { + toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : 'Channel URL must be a valid HTTPS URL.'); + return; + } + + const channelTypeChanged = Boolean( + editingId + && editOriginalChannelType + && formChannelType !== editOriginalChannelType, + ); + const appriseMode = formChannelType === 'apprise' ? classifyAppriseEndpoint(formChannelUrl) : null; + const appriseModeChanged = Boolean( + editingId + && editAppriseOriginalMode + && appriseMode + && appriseMode !== editAppriseOriginalMode, + ); + // Mode switch, type switch, or dirty tags/URLs need config; endpoint-only edits omit it so blank fields preserve destinations. + const needsAppriseConfig = formChannelType === 'apprise' + && (!editingId || appriseConfigDirty || appriseModeChanged || channelTypeChanged); + if ( + appriseMode === 'stateless' + && !formAppriseUrls.trim() + && (!editingId || appriseModeChanged || appriseConfigDirty || channelTypeChanged) + ) { + toast.error('Destination URLs are required for a stateless Apprise endpoint.'); return; } @@ -159,7 +208,16 @@ export function NotificationRoutingSection() { label_ids: formLabelIds.length > 0 ? formLabelIds : null, categories: formCategories.length > 0 ? formCategories : null, channel_type: formChannelType, - channel_url: formChannelUrl.trim(), + ...(formChannelType !== 'apprise' || !editingId || appriseEndpointDirty || channelTypeChanged + ? { channel_url: formChannelUrl.trim() } + : {}), + ...(needsAppriseConfig + ? { + config: appriseMode === 'stateless' + ? { urls: formAppriseUrls } + : { tags: formAppriseTags }, + } + : {}), priority: formPriority, enabled: formEnabled, }; @@ -287,6 +345,7 @@ export function NotificationRoutingSection() { ], ); + const isAppriseStateless = isStatelessAppriseEndpoint(formChannelUrl); const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value)); const availableLabelOptions = useMemo( () => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })), @@ -431,8 +490,22 @@ export function NotificationRoutingSection() {
- setFormChannelType(v as 'discord' | 'slack' | 'webhook')}> - + { + const next = v as 'discord' | 'slack' | 'webhook' | 'apprise'; + if (next !== formChannelType) { + // Type change replaces credentials; never carry a redacted prior URL across types. + setFormChannelUrl(''); + setFormAppriseTags(''); + setFormAppriseUrls(''); + setAppriseEndpointDirty(true); + setAppriseConfigDirty(true); + } + setFormChannelType(next); + }} + > + Discord @@ -443,14 +516,36 @@ export function NotificationRoutingSection() { Webhook + + Apprise + setFormChannelUrl(e.target.value)} + onChange={e => { + setFormChannelUrl(e.target.value); + if (formChannelType === 'apprise') setAppriseEndpointDirty(true); + }} /> + {formChannelType === 'apprise' && ( + <> + { + if (isAppriseStateless) setFormAppriseUrls(e.target.value); + else setFormAppriseTags(e.target.value); + setAppriseConfigDirty(true); + }} + /> + {editingId !== null && isAppriseStateless && editAppriseOriginalMode === 'stateless' && !formAppriseUrls && ( +

Leave destination URLs blank to preserve the configured destinations.

+ )} + + )}
@@ -594,7 +689,7 @@ export function NotificationRoutingSection() { Matches all alerts )} | - + {route.channel_url} {route.priority !== 0 && ( diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index de1ba118..652ddbd2 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; import { Button } from '@/components/ui/button'; @@ -13,42 +13,78 @@ import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; +import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint'; -export function NotificationsSection() { - const { activeNode } = useNodes(); +type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise'; - const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord'); - const [agents, setAgents] = useState>({ +function emptyAgents(): Record { + return { discord: { type: 'discord', url: '', enabled: false }, slack: { type: 'slack', url: '', enabled: false }, webhook: { type: 'webhook', url: '', enabled: false }, - }); + apprise: { type: 'apprise', url: '', enabled: false, config: null }, + }; +} + +function appriseWriteConfig(agent: Agent): { urls: string } | { tags: string } { + if (isStatelessAppriseEndpoint(agent.url)) { + return { urls: agent.config?.urls ?? '' }; + } + return { tags: agent.config?.tags ?? '' }; +} + +function hasStoredAppriseAgent(agent: Agent): boolean { + // Stateless public URLs are not redacted; treat a public mode summary (or keyed redaction) as stored. + return Boolean(agent.config?.mode) || agent.url.includes(''); +} + +export function NotificationsSection() { + const { activeNode } = useNodes(); + const activeNodeIdRef = useRef(activeNode?.id); + useEffect(() => { activeNodeIdRef.current = activeNode?.id; }, [activeNode?.id]); + + const [notifTab, setNotifTab] = useState('discord'); + const [agents, setAgents] = useState>(emptyAgents); const [isSavingAgent, setIsSavingAgent] = useState>({}); const [isTestingAgent, setIsTestingAgent] = useState>({}); + const [appriseUrlDirty, setAppriseUrlDirty] = useState(false); + const [appriseConfigDirty, setAppriseConfigDirty] = useState(false); const fetchAgents = async () => { + const requestNodeId = activeNode?.id; try { - const res = await apiFetch('/agents'); - if (res.ok) { - const data: Agent[] = await res.json(); - setAgents(prev => { - const next = { ...prev }; - data.forEach(a => { next[a.type] = a; }); - return next; - }); - } + const res = await apiFetch('/agents', { + // Bind the request to the node captured when this fetch started. + nodeId: typeof requestNodeId === 'number' ? requestNodeId : undefined, + }); + if (!res.ok) return; + const data: Agent[] = await res.json(); + // Compare after body parse so a slow json() cannot commit after a node switch. + if (activeNodeIdRef.current !== requestNodeId) return; + const next = emptyAgents(); + data.forEach(a => { + if (a.type in next) next[a.type as ChannelType] = a; + }); + setAgents(next); + setAppriseUrlDirty(false); + setAppriseConfigDirty(false); } catch (e) { console.error('Failed to fetch agents', e); } }; - useEffect(() => { fetchAgents(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + setAgents(emptyAgents()); + setAppriseUrlDirty(false); + setAppriseConfigDirty(false); + void fetchAgents(); + }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const enabledCount = Object.values(agents).filter(a => a.enabled).length; useMastheadStats([ { label: 'CHANNELS', - value: `${enabledCount}/3`, + value: `${enabledCount}/4`, tone: enabledCount > 0 ? 'value' : 'subtitle', }, ]); @@ -60,15 +96,47 @@ export function NotificationsSection() { })); }; + const handleAppriseConfigPatch = (patch: { urls?: string; tags?: string }) => { + setAppriseConfigDirty(true); + setAgents(prev => ({ + ...prev, + apprise: { ...prev.apprise, config: { ...prev.apprise.config, ...patch } }, + })); + }; + const saveAgent = async (type: string) => { setIsSavingAgent(prev => ({ ...prev, [type]: true })); try { + const agent = agents[type]; + const body: Record = { + type: agent.type, + enabled: agent.enabled, + }; + + if (type !== 'apprise') { + body.url = agent.url; + } else { + const stored = hasStoredAppriseAgent(agent); + // Omit url/config on clean saves (including enable toggles) so preserve-on-write keeps destinations. + // URL-only edits omit config so blank destination fields do not wipe stored URLs. + if (appriseUrlDirty || !stored) { + body.url = agent.url.trim(); + } + const storedMode = agent.config?.mode ?? null; + const nextMode = classifyAppriseEndpoint(agent.url); + const modeChanged = Boolean(stored && storedMode && nextMode && storedMode !== nextMode); + if (appriseConfigDirty || !stored || modeChanged) { + body.config = appriseWriteConfig(agent); + } + } + const res = await apiFetch('/agents', { method: 'POST', - body: JSON.stringify(agents[type]), + body: JSON.stringify(body), }); if (res.ok) { toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`); + await fetchAgents(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Something went wrong.'); @@ -80,16 +148,33 @@ export function NotificationsSection() { } }; + const appriseCanTest = (() => { + const agent = agents.apprise; + if (!agent.url.trim() || agent.url.includes('')) return false; + if (isStatelessAppriseEndpoint(agent.url)) { + return Boolean(agent.config?.urls?.trim()); + } + return true; + })(); + const testAgent = async (type: string) => { + if (type === 'apprise' && !appriseCanTest) { + toast.error('Enter a raw Apprise endpoint (and destination URLs for /notify) before testing.'); + return; + } if (!agents[type].url) { toast.error('Please enter a webhook URL first.'); return; } setIsTestingAgent(prev => ({ ...prev, [type]: true })); try { + const body: Record = { type, url: agents[type].url }; + if (type === 'apprise') { + body.config = appriseWriteConfig(agents.apprise); + } const res = await apiFetch('/notifications/test', { method: 'POST', - body: JSON.stringify({ type, url: agents[type].url }), + body: JSON.stringify(body), }); if (res.ok) { toast.success('Test notification sent!'); @@ -104,7 +189,7 @@ export function NotificationsSection() { } }; - const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => ( + const renderAgentTab = (type: ChannelType, title: string) => ( handleAgentChange(type, 'url', e.target.value)} + onChange={(e) => { + if (type === 'apprise') setAppriseUrlDirty(true); + handleAgentChange(type, 'url', e.target.value); + }} /> + {type === 'apprise' && isStatelessAppriseEndpoint(agents.apprise.url) && ( + + handleAppriseConfigPatch({ urls: e.target.value })} + /> + + )} + {type === 'apprise' && isKeyedAppriseEndpoint(agents.apprise.url) && ( + + handleAppriseConfigPatch({ tags: e.target.value })} + /> + + )} + {type === 'apprise' && agents.apprise.url.trim() && !isKeyedAppriseEndpoint(agents.apprise.url) && !isStatelessAppriseEndpoint(agents.apprise.url) && ( +

+ Enter an endpoint ending in /notify or /notify/{key} to configure Apprise. +

+ )} -