diff --git a/Dockerfile b/Dockerfile index 2a67936..e378802 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ RUN apt-get update && apt-get install -y \ libxrandr2 \ xdg-utils \ dumb-init \ + gosu \ && rm -rf /var/lib/apt/lists/* # Set Chrome executable path for Puppeteer @@ -73,9 +74,9 @@ COPY --from=builder /app/dist ./dist RUN mkdir -p ./data/sessions ./data/media && \ chown -R openwa:openwa /app -# Note: Running as root to allow Docker socket access for orchestration -# For production with stricter security, consider using a Docker socket proxy -# USER openwa +# Copy entrypoint script (runs as root to fix volume permissions, then drops to openwa) +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Expose port EXPOSE 2785 @@ -84,6 +85,6 @@ EXPOSE 2785 HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD node -e "require('http').get('http://localhost:2785/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" -# Start with dumb-init to handle signals properly -ENTRYPOINT ["dumb-init", "--"] +# Entrypoint: fixes volume permissions as root, then drops to openwa user via gosu +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "dist/main"] diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf index 0905812..5f30d18 100644 --- a/dashboard/nginx.conf +++ b/dashboard/nginx.conf @@ -15,7 +15,7 @@ server { # Proxy API requests to backend location /api/ { - proxy_pass http://openwa:2785; + proxy_pass http://openwa-api:2785; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -25,7 +25,7 @@ server { # WebSocket proxy location /socket.io/ { - proxy_pass http://openwa:2785; + proxy_pass http://openwa-api:2785; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; diff --git a/docker-compose.yml b/docker-compose.yml index 229ea4f..419f1e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,25 @@ # Smart Orchestration with Profiles services: + # ===== CORE: Docker Socket Proxy (restricts Docker API access) ===== + docker-proxy: + image: tecnativa/docker-socket-proxy + container_name: openwa-docker-proxy + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + CONTAINERS: 1 + IMAGES: 1 + VOLUMES: 1 + INFO: 1 + PING: 1 + POST: 1 + DELETE: 1 + labels: + - 'com.openwa.service=docker-proxy' + - 'com.openwa.core=true' + # ===== CORE: Traefik Reverse Proxy ===== traefik: image: traefik:v3.0 @@ -70,11 +89,14 @@ services: - PLUGINS_DIR=/app/data/plugins # Security - API_MASTER_KEY=${API_MASTER_KEY:-} + # Docker socket proxy (replaces direct socket mount) + - DOCKER_HOST=tcp://docker-proxy:2375 volumes: - openwa-data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro - ./docker-compose.yml:/app/docker-compose.yml:ro depends_on: + docker-proxy: + condition: service_started postgres: condition: service_healthy required: false diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..bced26d --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Fix data directory permissions for named volume mounts (runs as root before dropping privileges) +mkdir -p /app/data/sessions /app/data/media /app/data/plugins +chown -R openwa:openwa /app/data + +exec gosu openwa dumb-init "$@" diff --git a/src/app.module.ts b/src/app.module.ts index ca4a704..e5a40c2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module, DynamicModule, Type } from '@nestjs/common'; +import { Module, DynamicModule, Type, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; @@ -24,6 +24,7 @@ import { StatsModule } from './modules/stats/stats.module'; import { StatusModule } from './modules/status/status.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { HooksModule } from './core/hooks'; +import { BullBoardAuthMiddleware } from './common/security/bull-board-auth.middleware'; import { PluginsModule } from './core/plugins'; import { PluginsApiModule } from './modules/plugins/plugins.module'; @@ -38,6 +39,7 @@ if (process.env.QUEUE_ENABLED === 'true') { } @Module({ + providers: [BullBoardAuthMiddleware], imports: [ // Configuration ConfigModule.forRoot({ @@ -161,4 +163,8 @@ if (process.env.QUEUE_ENABLED === 'true') { PluginsApiModule, // Phase 5: Plugins API ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(BullBoardAuthMiddleware).forRoutes('/admin/queues'); + } +} diff --git a/src/common/security/bull-board-auth.middleware.ts b/src/common/security/bull-board-auth.middleware.ts new file mode 100644 index 0000000..64344c5 --- /dev/null +++ b/src/common/security/bull-board-auth.middleware.ts @@ -0,0 +1,31 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { AuthService } from '../../modules/auth/auth.service'; +import { ApiKeyRole } from '../../modules/auth/entities/api-key.entity'; + +@Injectable() +export class BullBoardAuthMiddleware implements NestMiddleware { + constructor(private readonly authService: AuthService) {} + + async use(req: Request, res: Response, next: NextFunction): Promise { + const xApiKey = req.headers['x-api-key'] as string; + const authHeader = req.headers['authorization']; + const rawKey = xApiKey || (authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : undefined); + + if (!rawKey) { + res.status(401).json({ message: 'API key required' }); + return; + } + + try { + const apiKey = await this.authService.validateApiKey(rawKey); + if (!this.authService.hasPermission(apiKey, ApiKeyRole.ADMIN)) { + res.status(403).json({ message: 'Admin role required' }); + return; + } + next(); + } catch { + res.status(401).json({ message: 'Invalid API key' }); + } + } +} diff --git a/src/modules/docker/docker.service.ts b/src/modules/docker/docker.service.ts index 63cba17..19fafd7 100644 --- a/src/modules/docker/docker.service.ts +++ b/src/modules/docker/docker.service.ts @@ -71,13 +71,23 @@ export class DockerService implements OnModuleInit { private async initializeDocker(): Promise { try { - this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); + const dockerHost = process.env.DOCKER_HOST; + if (dockerHost?.startsWith('tcp://')) { + const url = new URL(dockerHost); + this.docker = new Docker({ + host: url.hostname, + port: parseInt(url.port, 10) || 2375, + protocol: 'http' as const, + }); + } else { + this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); + } await this.docker.ping(); this.isAvailable = true; this.logger.log('Docker API connected successfully'); } catch (error) { this.logger.warn( - 'Docker socket not available. Container orchestration disabled.', + 'Docker not available. Container orchestration disabled.', error instanceof Error ? error.message : error, ); this.isAvailable = false;