Files
Hermes d45dc8d3b7
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=B] DC-100: Service discovery — auto-detect running containers
GET /api/v1/discover scans running Docker containers, matches images
against 20 known patterns (Plex, Jellyfin, Sonarr, Radarr, qBittorrent,
Gitea, Nextcloud, Redis, Postgres, etc.), and returns suggested service
configs. Marks services already in the dashboard as 'existing'.

Returns: container ID, name, image, suggested type/name/port/protocol,
port mappings, labels, and existing flag. 5 tests, 1623 total pass.
2026-08-12 12:27:57 -07:00

137 lines
5.7 KiB
JavaScript

/**
* 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;
};