[grade=B] DC-100: Service discovery — auto-detect running containers
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

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.
This commit is contained in:
Hermes
2026-08-12 12:27:57 -07:00
parent a38d1350eb
commit d45dc8d3b7
3 changed files with 280 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* 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;
};