Remove hardcoded container names for multi-instance deployment support

- Dashboard: auto-discovers containers from Docker network via socket
  proxy API instead of hardcoded 30-name list. Labels derived from
  docker compose service metadata.
- Email/Settings: mailhog host read from env.SMTP_HOST instead of
  hardcoded 'mailhog-changemaker' string
- Pangolin: grafana container derived from env.GRAFANA_URL hostname;
  newt container/service names from NEWT_CONTAINER_NAME/NEWT_COMPOSE_SERVICE
- SSRF blocklist: built dynamically from all service URL env vars
  instead of hardcoded hostname list
- New env vars: DOCKER_NETWORK_NAME, DOCKER_PROXY_URL,
  NEWT_CONTAINER_NAME, NEWT_COMPOSE_SERVICE

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-25 17:35:05 -06:00
parent 204e90dd3b
commit 3262d92065
10 changed files with 149 additions and 80 deletions

View File

@ -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

View File

@ -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),

View File

@ -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<string> {
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

View File

@ -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<ContainerInfo[]> {
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<WeatherData | null> {
@ -655,7 +610,11 @@ export async function getContainerResources(): Promise<ContainerResource[]> {
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<ContainerResource[]> {
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,

View File

@ -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}`);

View File

@ -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;

View File

@ -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<DockerContaine
}
}
/**
* Derive a human-readable label from Docker Compose metadata.
* Prefers compose service name, falls back to container name.
*/
function deriveLabel(container: Record<string, unknown>): string {
const labels = (container.Labels || {}) as Record<string, string>;
// 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<DockerContainerInfo[]> {
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<string, unknown>[];
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,
};

View File

@ -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;
}
}

View File

@ -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:-}

View File

@ -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:-}