/** * DC-100: Service Discovery — auto-detect running Docker containers * and suggest them as services to add to the dashboard. * * Scans all running containers, extracts port mappings, image info, * and labels to suggest service configurations. */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const { ErrorCodes } = require('../src/utilities/error-codes'); // Known image patterns → suggested service type and default config const IMAGE_PATTERNS = { 'plexinc/pms': { type: 'plex', name: 'Plex', port: 32400, https: false }, 'linuxserver/jellyfin': { type: 'jellyfin', name: 'Jellyfin', port: 8096, https: false }, 'linuxserver/emby': { type: 'emby', name: 'Emby', port: 8096, https: false }, 'lscr.io/linuxserver/sonarr': { type: 'sonarr', name: 'Sonarr', port: 8989, https: false }, 'lscr.io/linuxserver/radarr': { type: 'radarr', name: 'Radarr', port: 7878, https: false }, 'lscr.io/linuxserver/prowlarr': { type: 'prowlarr', name: 'Prowlarr', port: 9696, https: false }, 'lscr.io/linuxserver/lidarr': { type: 'lidarr', name: 'Lidarr', port: 8686, https: false }, 'lscr.io/linuxserver/readarr': { type: 'readarr', name: 'Readarr', port: 8787, https: false }, 'lscr.io/linuxserver/qbittorrent': { type: 'qbittorrent', name: 'qBittorrent', port: 8080, https: false }, 'lscr.io/linuxserver/transmission': { type: 'transmission', name: 'Transmission', port: 9091, https: false }, 'haugene/transmission-openvpn': { type: 'transmission', name: 'Transmission+VPN', port: 9091, https: false }, 'gitea/gitea': { type: 'gitea', name: 'Gitea', port: 3000, https: false }, 'nextcloud': { type: 'nextcloud', name: 'Nextcloud', port: 80, https: false }, 'vaultwarden': { type: 'vaultwarden', name: 'Vaultwarden', port: 80, https: false }, 'nginx': { type: 'web', name: 'Nginx', port: 80, https: false }, 'caddy': { type: 'web', name: 'Caddy', port: 80, https: false }, 'redis': { type: 'redis', name: 'Redis', port: 6379, https: false }, 'postgres': { type: 'postgres', name: 'PostgreSQL', port: 5432, https: false }, 'mariadb': { type: 'mariadb', name: 'MariaDB', port: 3306, https: false }, 'mongo': { type: 'mongodb', name: 'MongoDB', port: 27017, https: false }, }; module.exports = function({ docker, servicesStateManager, asyncHandler }) { const router = express.Router(); /** * GET /api/v1/discover — scan running containers for auto-detection * * Returns a list of discovered services with suggested configurations. * Services already in the dashboard are marked as `existing: true`. */ router.get('/discover', asyncHandler(async (req, res) => { if (!docker || !docker.client) { return errorResponse(res, 503, 'Docker daemon not available', { code: ErrorCodes.CONTAINER.DOCKER_UNREACHABLE, }); } try { // Get all running containers const containers = await docker.client.listContainers({ all: false }); // Get existing service IDs to mark duplicates let existingIds = new Set(); if (servicesStateManager) { try { const services = await servicesStateManager.read(); const list = Array.isArray(services) ? services : (services.services || []); existingIds = new Set(list.map(s => s.id)); } catch { /* ignore — treat as empty */ } } const discovered = []; const seen = new Set(); for (const container of containers) { const name = (container.Names && container.Names[0] || '').replace(/^\//, ''); if (!name || seen.has(name)) continue; seen.add(name); const image = container.Image || ''; const imageBase = image.split(':')[0].toLowerCase(); // Match against known patterns let matched = null; for (const [pattern, config] of Object.entries(IMAGE_PATTERNS)) { if (imageBase.includes(pattern)) { matched = config; break; } } // Extract port mappings const ports = (container.Ports || []).map(p => ({ ip: p.IP || '0.0.0.0', privatePort: p.PrivatePort, publicPort: p.PublicPort, type: p.Type || 'tcp', })).filter(p => p.publicPort); // Suggested config const suggestedPort = matched ? matched.port : (ports[0] && ports[0].publicPort) || null; const suggestedId = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); discovered.push({ containerId: container.Id.substring(0, 12), name, image, status: container.State, suggested: { id: suggestedId, name: matched ? matched.name : name.charAt(0).toUpperCase() + name.slice(1), type: matched ? matched.type : 'generic', port: suggestedPort, protocol: matched ? (matched.https ? 'https' : 'http') : 'http', }, ports, labels: container.Labels || {}, existing: existingIds.has(suggestedId), }); } // Sort: unmatched first (more interesting to discover), then by name discovered.sort((a, b) => { if (a.existing !== b.existing) return a.existing ? 1 : -1; return a.name.localeCompare(b.name); }); ok(res, { total: discovered.length, matched: discovered.filter(d => d.suggested.type !== 'generic').length, newServices: discovered.filter(d => !d.existing).length, discovered, }); } catch (err) { return errorResponse(res, 500, `Discovery failed: ${err.message}`, { code: ErrorCodes.GENERAL.INTERNAL, }); } })); return router; };