diff --git a/dashcaddy-api/__tests__/routes/discover.routes.test.js b/dashcaddy-api/__tests__/routes/discover.routes.test.js new file mode 100644 index 0000000..05b97a8 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/discover.routes.test.js @@ -0,0 +1,136 @@ +/** + * DC-100: Service discovery tests + */ +const express = require('express'); +const request = require('supertest'); + +function createApp(docker, servicesStateManager) { + const app = express(); + app.use(express.json()); + + const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + const discoverRoutes = require('../../routes/discover'); + + app.use('/api/v1', discoverRoutes({ + docker, + servicesStateManager, + asyncHandler, + })); + return app; +} + +describe('DC-100: Service Discovery', () => { + it('returns 503 when Docker is not available', async () => { + const app = createApp(null, null); + const res = await request(app).get('/api/v1/discover'); + expect(res.status).toBe(503); + expect(res.body.success).toBe(false); + expect(res.body.code).toBe('DC-CONT-011'); + }); + + it('discovers running containers with pattern matching', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([ + { + Id: 'abc123def456', + Names: ['/plex-server'], + Image: 'plexinc/pms-docker:latest', + State: 'running', + Ports: [ + { IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' }, + ], + Labels: {}, + }, + { + Id: 'def789abc012', + Names: ['/redis-cache'], + Image: 'redis:7-alpine', + State: 'running', + Ports: [ + { IP: '0.0.0.0', PrivatePort: 6379, PublicPort: 6379, Type: 'tcp' }, + ], + Labels: {}, + }, + ]), + }, + }; + + const mockStateManager = { + read: jest.fn().mockResolvedValue([]), + }; + + const app = createApp(mockDocker, mockStateManager); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.total).toBe(2); + expect(res.body.discovered).toHaveLength(2); + + const plex = res.body.discovered.find(d => d.name === 'plex-server'); + expect(plex.suggested.type).toBe('plex'); + expect(plex.suggested.name).toBe('Plex'); + expect(plex.suggested.port).toBe(32400); + expect(plex.existing).toBe(false); + + const redis = res.body.discovered.find(d => d.name === 'redis-cache'); + expect(redis.suggested.type).toBe('redis'); + }); + + it('marks already-added services as existing', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([ + { + Id: 'abc123def456', + Names: ['/plex-server'], + Image: 'plexinc/pms-docker:latest', + State: 'running', + Ports: [], + Labels: {}, + }, + ]), + }, + }; + + const mockStateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex-server' }]), + }; + + const app = createApp(mockDocker, mockStateManager); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.discovered[0].existing).toBe(true); + }); + + it('handles empty container list', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([]), + }, + }; + + const app = createApp(mockDocker, null); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(0); + expect(res.body.discovered).toEqual([]); + }); + + it('returns 500 on Docker error', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockRejectedValue(new Error('connection refused')), + }, + }; + + const app = createApp(mockDocker, null); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(500); + expect(res.body.success).toBe(false); + }); +}); diff --git a/dashcaddy-api/routes/discover.js b/dashcaddy-api/routes/discover.js new file mode 100644 index 0000000..2416572 --- /dev/null +++ b/dashcaddy-api/routes/discover.js @@ -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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 5574fa4..f985344 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -61,6 +61,7 @@ const updatesRoutes = require('../routes/updates'); const authRoutes = require('../routes/auth'); const shareRoutes = require('../routes/share'); const i18nRoutes = require('../routes/i18n'); +const discoverRoutes = require('../routes/discover'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -599,6 +600,13 @@ async function createApp() { // DC-077: i18n — language metadata and translations (public, no auth needed) apiRouter.use(i18nRoutes()); + + // DC-100: Service discovery — auto-detect running containers + apiRouter.use(discoverRoutes({ + docker: ctx.docker, + servicesStateManager: ctx.servicesStateManager, + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,