diff --git a/.env.example b/.env.example index 048c98d0..161eb6db 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,15 @@ JITSI_EMBED_PORT=8893 GRAFANA_EMBED_PORT=8894 ALERTMANAGER_EMBED_PORT=8895 +# --- Docker / Container Management --- +# Docker network name (used by dashboard to auto-discover containers) +DOCKER_NETWORK_NAME=changemaker-lite +# Docker socket proxy URL (read-only container inspection) +DOCKER_PROXY_URL=http://docker-socket-proxy:2375 +# Newt tunnel container (for Pangolin restart/status checks) +NEWT_CONTAINER_NAME=newt-changemaker +NEWT_COMPOSE_SERVICE=newt + # --- SMTP / Email --- SMTP_HOST=mailhog-changemaker SMTP_PORT=1025 diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 5cc85315..2de77ff3 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -205,6 +205,12 @@ const envSchema = z.object({ MKDOCS_SITE_SERVER_URL: z.string().default('http://mkdocs-site-server-changemaker:80'), MKDOCS_SITE_SERVER_PORT: z.coerce.number().default(4004), + // Docker (container status dashboard + service management) + DOCKER_PROXY_URL: z.string().default('http://docker-socket-proxy:2375'), + DOCKER_NETWORK_NAME: z.string().default('changemaker-lite'), + NEWT_CONTAINER_NAME: z.string().default('newt-changemaker'), + NEWT_COMPOSE_SERVICE: z.string().default('newt'), + // Monitoring Services (behind 'monitoring' profile) PROMETHEUS_URL: z.string().default('http://prometheus-changemaker:9090'), PROMETHEUS_PORT: z.coerce.number().default(9090), diff --git a/api/src/modules/calendar/feed.service.ts b/api/src/modules/calendar/feed.service.ts index 2f474575..9b26e26e 100644 --- a/api/src/modules/calendar/feed.service.ts +++ b/api/src/modules/calendar/feed.service.ts @@ -13,6 +13,7 @@ import { import ical, { ICalCalendarMethod } from 'ical-generator'; import nodeIcal from 'node-ical'; import { prisma } from '../../config/database'; +import { env } from '../../config/env'; import { logger } from '../../utils/logger'; import { AppError } from '../../middleware/error-handler'; import type { CreateFeedInput, UpdateFeedInput, CreateExportTokenInput } from './feed.schemas'; @@ -22,14 +23,37 @@ const FETCH_TIMEOUT_MS = 30_000; const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB const MATERIALIZE_MONTHS = 3; -// SSRF protection: block requests to private/reserved IP ranges and internal hosts -const BLOCKED_HOSTNAMES = new Set([ - 'localhost', '0.0.0.0', '[::]', '[::1]', - // Common Docker internal hostnames - 'changemaker-v2-postgres', 'redis-changemaker', 'changemaker-v2-api', - 'changemaker-v2-admin', 'changemaker-v2-nginx', 'changemaker-v2-nocodb', - 'listmonk-app', 'listmonk-db', 'mailhog-changemaker', -]); +/** Extract hostname from a URL string, returning empty string on failure. */ +function extractHost(url: string): string { + try { return new URL(url).hostname; } catch { return ''; } +} + +/** + * SSRF protection: build blocklist dynamically from env-configured service URLs. + * This adapts to any container naming convention used by the deployment. + */ +function buildBlockedHostnames(): Set { + const hosts = new Set([ + 'localhost', '0.0.0.0', '[::]', '[::1]', + ]); + // Extract hostnames from all internal service URLs + const serviceUrls = [ + env.REDIS_URL, env.NOCODB_URL, env.N8N_URL, env.GITEA_URL, + env.MAILHOG_URL, env.MINI_QR_URL, env.EXCALIDRAW_URL, env.HOMEPAGE_URL, + env.VAULTWARDEN_URL, env.ROCKETCHAT_URL, env.GANCIO_URL, env.JITSI_URL, + env.CODE_SERVER_URL, env.MKDOCS_PREVIEW_URL, env.MKDOCS_SITE_SERVER_URL, + env.PROMETHEUS_URL, env.GRAFANA_URL, env.ALERTMANAGER_URL, env.GOTIFY_URL, + env.CADVISOR_URL, env.NODE_EXPORTER_URL, env.REDIS_EXPORTER_URL, + env.DOCKER_PROXY_URL, env.API_URL, + ]; + for (const url of serviceUrls) { + const h = extractHost(url); + if (h) hosts.add(h); + } + return hosts; +} + +const BLOCKED_HOSTNAMES = buildBlockedHostnames(); function isPrivateIP(ip: string): boolean { // IPv4 private/reserved ranges diff --git a/api/src/modules/dashboard/dashboard.service.ts b/api/src/modules/dashboard/dashboard.service.ts index 17d9943a..2c74fed9 100644 --- a/api/src/modules/dashboard/dashboard.service.ts +++ b/api/src/modules/dashboard/dashboard.service.ts @@ -137,45 +137,9 @@ export interface WeatherData { time: string; } -// --- Container definitions --- - -const CONTAINERS: { name: string; label: string }[] = [ - // Core - { name: 'changemaker-v2-api', label: 'API' }, - { name: 'changemaker-media-api', label: 'Media API' }, - { name: 'changemaker-v2-admin', label: 'Admin' }, - { name: 'changemaker-v2-postgres', label: 'PostgreSQL' }, - { name: 'redis-changemaker', label: 'Redis' }, - { name: 'changemaker-v2-nginx', label: 'Nginx' }, - // Services - { name: 'changemaker-v2-nocodb', label: 'NocoDB' }, - { name: 'listmonk-app', label: 'Listmonk' }, - { name: 'listmonk-db', label: 'Listmonk DB' }, - { name: 'n8n-changemaker', label: 'n8n' }, - { name: 'homepage-changemaker', label: 'Homepage' }, - { name: 'gitea-changemaker', label: 'Gitea' }, - { name: 'gitea-mysql', label: 'Gitea MySQL' }, - { name: 'mailhog-changemaker', label: 'MailHog' }, - { name: 'mini-qr', label: 'Mini QR' }, - { name: 'excalidraw-changemaker', label: 'Excalidraw' }, - { name: 'code-server-changemaker', label: 'Code Server' }, - { name: 'mkdocs-changemaker', label: 'MkDocs' }, - { name: 'mkdocs-site-server-changemaker', label: 'MkDocs Site' }, - // Communication - { name: 'rocketchat-changemaker', label: 'Rocket.Chat' }, - { name: 'mongodb-rocketchat', label: 'RC MongoDB' }, - { name: 'nats-rocketchat', label: 'RC NATS' }, - { name: 'vaultwarden-changemaker', label: 'Vaultwarden' }, - { name: 'gancio-changemaker', label: 'Gancio' }, - // Jitsi Meet - { name: 'jitsi-web-changemaker', label: 'Jitsi Web' }, - { name: 'jitsi-prosody-changemaker', label: 'Jitsi Prosody' }, - { name: 'jitsi-jicofo-changemaker', label: 'Jitsi Jicofo' }, - { name: 'jitsi-jvb-changemaker', label: 'Jitsi JVB' }, - // Infrastructure - { name: 'newt-changemaker', label: 'Newt Tunnel' }, - { name: 'docker-socket-proxy', label: 'Docker Proxy' }, -]; +// Container list is now auto-discovered from Docker network (no hardcoded names needed). +// See dockerService.listNetworkContainers() — queries Docker API for all containers +// on env.DOCKER_NETWORK_NAME and derives labels from compose service names. // --- WMO weather code descriptions --- @@ -513,17 +477,8 @@ export function getSystemInfo(): SystemInfo { } export async function getContainerStatuses(): Promise { - const results = await Promise.all( - CONTAINERS.map(async (c) => { - try { - const status = await dockerService.getContainerStatus(c.name); - return { name: c.name, label: c.label, running: status.running, status: status.status }; - } catch { - return { name: c.name, label: c.label, running: false, status: 'unknown' }; - } - }), - ); - return results; + // Auto-discover containers from Docker network — no hardcoded list needed + return dockerService.listNetworkContainers(); } export async function getWeather(): Promise { @@ -655,7 +610,11 @@ export async function getContainerResources(): Promise { const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`); if (!online) return []; - const containerNames = CONTAINERS.map(c => c.name).join('|'); + // Auto-discover containers from Docker network + const containers = await dockerService.listNetworkContainers(); + if (containers.length === 0) return []; + + const containerNames = containers.map(c => c.name).join('|'); const nameFilter = `name=~"${containerNames}"`; const queries = [ @@ -689,7 +648,7 @@ export async function getContainerResources(): Promise { return isNaN(v) || !isFinite(v) ? 0 : v; }; - return CONTAINERS.map(c => ({ + return containers.map(c => ({ name: c.name, label: c.label, cpuPercent: Math.round(getVal(cpuResults, c.name) * 100 * 100) / 100, diff --git a/api/src/modules/pangolin/pangolin.routes.ts b/api/src/modules/pangolin/pangolin.routes.ts index cceee5d8..dd1cf530 100644 --- a/api/src/modules/pangolin/pangolin.routes.ts +++ b/api/src/modules/pangolin/pangolin.routes.ts @@ -87,7 +87,8 @@ async function validateContainer( ): Promise<{ valid: boolean; warning?: string; shouldSkip: boolean }> { try { if (def.profile === 'monitoring') { - const grafanaStatus = await dockerService.getContainerStatus('grafana-changemaker'); + const grafanaHost = new URL(env.GRAFANA_URL).hostname; + const grafanaStatus = await dockerService.getContainerStatus(grafanaHost); if (!grafanaStatus.running) { return { valid: false, @@ -211,7 +212,7 @@ router.get('/config', (_req: Request, res: Response) => { // GET /api/pangolin/newt-status — Check newt container status router.get('/newt-status', async (_req: Request, res: Response) => { try { - const containerStatus = await dockerService.getContainerStatus('newt-changemaker'); + const containerStatus = await dockerService.getContainerStatus(env.NEWT_CONTAINER_NAME); res.json({ newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), @@ -241,7 +242,7 @@ router.post('/newt-restart', async (_req: Request, res: Response) => { return; } - const result = await dockerService.restartContainer('newt'); + const result = await dockerService.restartContainer(env.NEWT_COMPOSE_SERVICE); if (!result.success) { res.status(500).json({ @@ -646,7 +647,7 @@ router.post('/setup', pangolinSetupLimiter, async (req: Request, res: Response) if (autoWriteEnv && envWriteResult?.success) { logger.info('Setup Step 6: Restarting Newt container...'); try { - const result = await dockerService.restartContainer('newt'); + const result = await dockerService.restartContainer(env.NEWT_COMPOSE_SERVICE); newtRestarted = result.success; if (!result.success) { warnings.push(`Newt restart failed: ${result.output}`); diff --git a/api/src/modules/settings/settings.service.ts b/api/src/modules/settings/settings.service.ts index 0c9c0c99..7de8ab0e 100644 --- a/api/src/modules/settings/settings.service.ts +++ b/api/src/modules/settings/settings.service.ts @@ -39,7 +39,7 @@ export const siteSettingsService = { let host: string, port: number, user: string, hasPassword: boolean, fromAddress: string, fromName: string; if (provider === 'mailhog') { - host = 'mailhog-changemaker'; + host = env.SMTP_HOST; port = 1025; user = ''; hasPassword = false; diff --git a/api/src/services/docker.service.ts b/api/src/services/docker.service.ts index 2b9716da..88f714a2 100644 --- a/api/src/services/docker.service.ts +++ b/api/src/services/docker.service.ts @@ -2,12 +2,10 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { request as httpRequest } from 'http'; import { logger } from '../utils/logger'; +import { env } from '../config/env'; const execAsync = promisify(exec); -/** Docker socket proxy URL (tecnativa/docker-socket-proxy) — read-only container inspection */ -const DOCKER_PROXY_URL = process.env.DOCKER_PROXY_URL || 'http://docker-socket-proxy:2375'; - interface DockerContainerStatus { running: boolean; status: string; @@ -15,6 +13,13 @@ interface DockerContainerStatus { error?: string; } +export interface DockerContainerInfo { + name: string; + label: string; + running: boolean; + status: string; +} + /** * Make a request to the Docker Engine API via the socket proxy (HTTP). */ @@ -23,12 +28,12 @@ function dockerRequest( path: string, ): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { - const url = new URL(path, DOCKER_PROXY_URL); + const url = new URL(path, env.DOCKER_PROXY_URL); const options = { hostname: url.hostname, port: url.port, - path: url.pathname, + path: url.pathname + (url.search || ''), method, }; @@ -78,15 +83,71 @@ async function getContainerStatus(containerName: string): Promise): string { + const labels = (container.Labels || {}) as Record; + // Docker Compose v2 sets this label on every container + const service = labels['com.docker.compose.service']; + if (service) { + // Capitalize and clean up: "media-api" → "Media API", "v2-postgres" → "V2 Postgres" + return service + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/\bV2\b/i, 'V2') + .replace(/\bApi\b/, 'API') + .replace(/\bDb\b/, 'DB'); + } + // Fallback: strip common prefixes from container name + const names = (container.Names || []) as string[]; + const name = names[0]?.replace(/^\//, '') || 'unknown'; + return name; +} + +/** + * List all containers in the configured Docker network with their statuses. + * Eliminates the need for a hardcoded container name list. + */ +async function listNetworkContainers(): Promise { + try { + const network = env.DOCKER_NETWORK_NAME; + const filterJson = JSON.stringify({ network: [network] }); + const path = `/containers/json?all=true&filters=${encodeURIComponent(filterJson)}`; + const response = await dockerRequest('GET', path); + + if (response.statusCode !== 200) { + logger.error(`Failed to list containers: HTTP ${response.statusCode}`); + return []; + } + + const containers = JSON.parse(response.body) as Record[]; + return containers.map((c) => { + const names = (c.Names || []) as string[]; + const name = names[0]?.replace(/^\//, '') || 'unknown'; + const state = (c.State as string) || 'unknown'; + return { + name, + label: deriveLabel(c), + running: state === 'running', + status: state, + }; + }); + } catch (err) { + logger.error('Failed to list network containers:', err); + return []; + } +} + /** * Restart/recreate a container using docker compose. * This ensures environment variables are picked up. */ -async function restartContainer(containerName: string): Promise<{ success: boolean; output: string }> { +async function restartContainer(serviceName: string): Promise<{ success: boolean; output: string }> { try { // Use docker compose to recreate the container with updated env vars - // -d = detached mode, service name is 'newt' in docker-compose.yml - const { stdout, stderr } = await execAsync('docker compose up -d newt', { + const { stdout, stderr } = await execAsync(`docker compose up -d ${serviceName}`, { cwd: '/app', // Assuming docker-compose.yml is in /app timeout: 30000, // 30 second timeout }); @@ -98,12 +159,13 @@ async function restartContainer(containerName: string): Promise<{ success: boole } catch (err) { const error = err as { stdout?: string; stderr?: string; message: string }; const output = (error.stdout || '') + (error.stderr || '') + error.message; - logger.error(`Failed to restart container: ${containerName}`, err); + logger.error(`Failed to restart container: ${serviceName}`, err); return { success: false, output }; } } export const dockerService = { getContainerStatus, + listNetworkContainers, restartContainer, }; diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index dd5a76f0..af9fea2a 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -73,7 +73,7 @@ class EmailService { let host: string, port: number, user: string, pass: string; if (provider === 'mailhog') { - host = 'mailhog-changemaker'; + host = env.SMTP_HOST; port = 1025; user = ''; pass = ''; @@ -499,7 +499,9 @@ class EmailService { const settings = await siteSettingsService.get(); return settings.smtpActiveProvider === 'production' && !!settings.smtpHost; } catch { - return env.SMTP_HOST !== 'mailhog-changemaker' && !!env.SMTP_HOST; + // env.SMTP_HOST defaults to 'mailhog-changemaker' — if it's still the default, SMTP isn't configured + const defaultHost = 'mailhog'; + return !env.SMTP_HOST.startsWith(defaultHost) && !!env.SMTP_HOST; } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 69273ea7..0501673e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -101,8 +101,11 @@ services: - SMS_MAX_RETRIES=${SMS_MAX_RETRIES:-3} - SMS_RESPONSE_SYNC_INTERVAL_MS=${SMS_RESPONSE_SYNC_INTERVAL_MS:-30000} - SMS_DEVICE_MONITOR_INTERVAL_MS=${SMS_DEVICE_MONITOR_INTERVAL_MS:-30000} - # Docker container status via socket proxy (read-only, containers endpoint only) - - DOCKER_PROXY_URL=http://docker-socket-proxy:2375 + # Docker container management (socket proxy, network discovery, service names) + - DOCKER_PROXY_URL=${DOCKER_PROXY_URL:-http://docker-socket-proxy:2375} + - DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME:-changemaker-lite} + - NEWT_CONTAINER_NAME=${NEWT_CONTAINER_NAME:-newt-changemaker} + - NEWT_COMPOSE_SERVICE=${NEWT_COMPOSE_SERVICE:-newt} # Container Registry - GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin} - GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-} diff --git a/docker-compose.yml b/docker-compose.yml index 31eb12a5..db8d1889 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,8 +100,11 @@ services: - SMS_MAX_RETRIES=${SMS_MAX_RETRIES:-3} - SMS_RESPONSE_SYNC_INTERVAL_MS=${SMS_RESPONSE_SYNC_INTERVAL_MS:-30000} - SMS_DEVICE_MONITOR_INTERVAL_MS=${SMS_DEVICE_MONITOR_INTERVAL_MS:-30000} - # Docker container status via socket proxy (read-only, containers endpoint only) - - DOCKER_PROXY_URL=http://docker-socket-proxy:2375 + # Docker container management (socket proxy, network discovery, service names) + - DOCKER_PROXY_URL=${DOCKER_PROXY_URL:-http://docker-socket-proxy:2375} + - DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME:-changemaker-lite} + - NEWT_CONTAINER_NAME=${NEWT_CONTAINER_NAME:-newt-changemaker} + - NEWT_COMPOSE_SERVICE=${NEWT_COMPOSE_SERVICE:-newt} # Container Registry - GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin} - GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}