Files
sencho/backend/src/routes/dashboard.ts
T
Anso 0ba09ebdee feat: add ntfy notification channel (#1761)
* chore: bump brace-expansion and fast-uri via npm audit fix

Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded
intermediate arrays). Both transitive dev dependencies updated:
- brace-expansion 5.0.8 -> 5.0.9
- fast-uri 3.1.4 -> 3.1.5

* chore: also bump frontend deps via npm audit fix

Fixes brace-expansion and postcss in the frontend lockfile so
npm audit --audit-level=high passes on both packages.

* chore: bump ip-address transitive dep via npm audit fix

Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr,
GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior
push and CI run.

* feat: add ntfy notification channel

Add ntfy (https://ntfy.sh) as the fifth notification channel alongside
Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol:
plain-text POST body with Content-Type, Title, Priority, and Tags
headers. Priority maps info/warning/error to ntfy's default/high/urgent.

URL validation allows both HTTP and HTTPS (common for LAN self-hosting)
but rejects embedded credentials, consistent with Apprise. Token auth
via ntfy's documented ?auth= query parameter is supported.

* fix: correct ntfy channel test cases for Linux URL parsing and required type field

- notification-channels.test.ts: replace http:///topic host check with a
  cross-platform invalid-URL case (WHATWG parser treats triple-slash
  authority differently on Linux vs Windows)
- ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and
  inline agents fixtures (required by the expanded ConfigurationAgents
  type)

* fix: remove unused import and update 0/4 masthead assertions to 0/5

* ci: exclude NotificationService.ts from js/request-forgery CodeQL rule

Notification channel dispatch methods (Discord, Slack, Webhook, Apprise,
ntfy) all call fetch() with admin-configured URLs and notification bodies
that may embed stack or path data. This matches the trust model already
documented for registry-api.ts: single-tenant self-hosted, admin owns
the server, outbound posting is the intended behavior. The write path is
gated by requireAdmin or requirePermission(node:manage), and every
dispatch runs with a 10s AbortSignal.timeout.

* ci: also exclude NotificationService.ts from js/file-access-to-http

Notification messages may embed stack names, paths, or compose-derived
content. Same trust model as js/request-forgery: admin owns the server
and the configured endpoints, write path is gated.

* fix: correct ntfy channel tab copy and validation error message

The ntfy settings tab was reusing the generic webhook label, helper, and
placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own
copy: label names the server-and-topic URL, helper states plain-text delivery
and the mandatory topic path, placeholder matches the routing section.

Also fix the routing-rule validation toast: the guard correctly exempts ntfy
from the HTTPS check but the error message was not updated alongside it, so
ntfy URLs received a misleading HTTPS-required message.

* fix: strip trailing slash from ntfy topic URL before dispatch

A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips
the trailing slash internally) but was stored and dispatched with the slash
intact, causing the real ntfy server to 404. Normalize before fetch so the
request reaches the correct topic path.

Also add ntfy to the Channels card description in the settings registry.
2026-08-03 19:29:41 -04:00

215 lines
7.6 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
import { CloudBackupService } from '../services/CloudBackupService';
import { FileSystemService } from '../services/FileSystemService';
import TrivyService from '../services/TrivyService';
import { effectiveTier } from '../middleware/tierGates';
import { isDebugEnabled } from '../utils/debug';
import type { LicenseTier } from '../services/license-types';
export const dashboardRouter = Router();
interface AgentStatus {
configured: boolean;
enabled: boolean;
}
export interface ConfigurationStatus {
tier: LicenseTier;
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
};
automation: {
autoHeal: { total: number; enabled: number };
autoUpdate: { enabled: number; total: number };
scheduledTasks: { total: number; enabled: number; locked: boolean };
webhooks: { total: number; enabled: number; locked: boolean };
};
security: {
mfaEnabled: boolean | null;
ssoEnabled: boolean;
ssoProvider: string | null;
trivyInstalled: boolean;
scanPolicies: { total: number; enabled: number; locked: boolean };
};
thresholds: {
cpuLimit: number;
ramLimit: number;
diskLimit: number;
dockerJanitorGb: number;
globalCrash: boolean;
hostAlertsEnabled: boolean;
};
backup: {
provider: 'disabled' | 'sencho' | 'custom';
autoUpload: boolean;
locked: boolean;
};
}
export async function buildLocalConfigurationStatus(
nodeId: number,
userId: number,
tier: LicenseTier,
): Promise<ConfigurationStatus> {
const db = DatabaseService.getInstance();
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'): AgentStatus => {
const a = agents.find(ag => ag.type === type);
return { configured: !!a?.url, enabled: a?.enabled ?? false };
};
// Scope to stacks that exist on this node. stack_alerts has no node_id;
// intersecting with the node's compose directory is the per-node filter.
const stackNames = new Set(await FileSystemService.getInstance(nodeId).getStacks());
const alertRules = db.getStackAlerts().filter((a) => stackNames.has(a.stack_name)).length;
const notifRoutes = db.getNotificationRoutes();
const healPolicies = db.getAutoHealPolicies(undefined, nodeId);
const scheduledTasks = db.getScheduledTasks();
const nodeUpdateTasks = scheduledTasks.filter(t => t.action === 'update' && t.node_id === nodeId);
const autoUpdateTotal = nodeUpdateTasks.length;
const autoUpdateEnabled = nodeUpdateTasks.filter(t => t.enabled === 1).length;
const webhooks = db.getWebhooks();
const mfaRow = userId ? db.getUserMfa(userId) : undefined;
const ssoConfigs = db.getSSOConfigs();
const enabledSso = ssoConfigs.find(c => c.enabled === 1);
const scanPolicies = db.getScanPolicies();
const settings = db.getGlobalSettings();
const cpuLimit = parseInt(settings['host_cpu_limit'] ?? '90', 10);
const ramLimit = parseInt(settings['host_ram_limit'] ?? '90', 10);
const diskLimit = parseInt(settings['host_disk_limit'] ?? '90', 10);
const dockerJanitorGb = parseFloat(settings['docker_janitor_gb'] ?? '5');
const globalCrash = settings['global_crash'] === '1';
const hostAlertsEnabled = settings['host_alerts_enabled'] !== '0';
const cloudSvc = CloudBackupService.getInstance();
const cloudProvider = cloudSvc.getProvider();
const cloudAutoUpload = cloudSvc.isAutoUploadOn();
return {
tier,
notifications: {
agents: {
discord: agentByType('discord'),
slack: agentByType('slack'),
webhook: agentByType('webhook'),
apprise: agentByType('apprise'),
ntfy: agentByType('ntfy'),
},
alertRules,
// Notification routing is available on every tier.
routingRules: {
count: notifRoutes.length,
enabledCount: notifRoutes.filter(r => r.enabled).length,
locked: false,
},
suppressionRules: (() => {
const rules = db.getNotificationSuppressionRules();
return { total: rules.length, enabledCount: rules.filter(r => r.enabled).length };
})(),
},
automation: {
autoHeal: {
total: healPolicies.length,
enabled: healPolicies.filter(p => p.enabled === 1).length,
},
autoUpdate: {
enabled: autoUpdateEnabled,
total: autoUpdateTotal,
},
// Scheduled operations are available on every tier.
scheduledTasks: {
total: scheduledTasks.length,
enabled: scheduledTasks.filter(t => t.enabled === 1).length,
locked: false,
},
// Webhooks are available on every tier.
webhooks: {
total: webhooks.length,
enabled: webhooks.filter(w => w.enabled).length,
locked: false,
},
},
security: {
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
ssoEnabled: !!enabledSso,
ssoProvider: enabledSso?.provider ?? null,
trivyInstalled: TrivyService.getInstance().getSource() !== 'none',
// Scan policies are available on every tier.
scanPolicies: {
total: scanPolicies.length,
enabled: scanPolicies.filter(p => p.enabled === 1).length,
locked: false,
},
},
thresholds: {
cpuLimit,
ramLimit,
diskLimit,
dockerJanitorGb,
globalCrash,
hostAlertsEnabled,
},
backup: {
// Cloud Backup has a per-provider tier: Custom S3 is open to every
// tier; Sencho Cloud Backup requires a paid license. The row is rendered
// for every tier because Custom S3 is universally configurable, so no
// dashboard-level lock is meaningful.
provider: cloudProvider,
autoUpload: cloudAutoUpload,
locked: false,
},
};
}
// All routes below are protected by the global authGate mounted at app.use('/api', authGate)
dashboardRouter.get('/configuration', async (req: Request, res: Response): Promise<void> => {
try {
const debug = isDebugEnabled();
const startedAt = debug ? Date.now() : 0;
const nodeId = req.nodeId ?? 0;
const userId = req.user?.userId ?? 0;
const tier = effectiveTier(req);
const payload = await buildLocalConfigurationStatus(nodeId, userId, tier);
if (debug) {
console.debug(
`[Dashboard:debug] /configuration built in ${Date.now() - startedAt} ms (nodeId=${nodeId})`,
);
}
res.json(payload);
} catch (error) {
console.error('[Dashboard] Failed to build configuration status:', error);
res.status(500).json({ error: 'Failed to fetch configuration status' });
}
});
dashboardRouter.get('/stack-restarts', (req: Request, res: Response): void => {
try {
const debug = isDebugEnabled();
const startedAt = debug ? Date.now() : 0;
const db = DatabaseService.getInstance();
const nodeId = req.nodeId ?? 0;
const rawDays = parseInt(String(req.query['days'] ?? '7'), 10);
const days = isNaN(rawDays) || rawDays < 1 ? 7 : Math.min(rawDays, 30);
const result: StackRestartSummary[] = db.getStackRestartSummary(nodeId, days);
if (debug) {
console.debug(
`[Dashboard:debug] /stack-restarts returned ${result.length} rows for nodeId=${nodeId} over ${days}d in ${Date.now() - startedAt} ms`,
);
}
res.json(result);
} catch (error) {
console.error('[Dashboard] Failed to fetch stack restarts:', error);
res.status(500).json({ error: 'Failed to fetch stack restarts' });
}
});