diff --git a/src/apps/apps.module.enabled.ts b/src/apps/apps.module.enabled.ts index 1f430478..4abdd6a4 100644 --- a/src/apps/apps.module.enabled.ts +++ b/src/apps/apps.module.enabled.ts @@ -11,8 +11,13 @@ import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver import { Auth } from '@waha/core/auth/config'; import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime'; import { GetApps } from '@waha/apps/app_sdk/apps/registry'; +import { HttpPathsRegistration } from '@waha/plugins/http.paths.module'; const QUEUES_IMPORTS_REQUIRED = [ + HttpPathsRegistration( + { prefix: '/jobs', include: { authBasic: false } }, + { prefix: '/jobs/', include: { authBasic: false, accessLog: false } }, + ), BullModule.forRoot({ connection: { url: process.env.REDIS_URL || 'redis://:redis@localhost:6379', diff --git a/src/apps/chatwoot/app.module.ts b/src/apps/chatwoot/app.module.ts index c7e9832f..f6eef82c 100644 --- a/src/apps/chatwoot/app.module.ts +++ b/src/apps/chatwoot/app.module.ts @@ -39,10 +39,15 @@ import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/con import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull'; import { BullModule } from '@nestjs/bullmq'; import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager'; +import { HttpPathsRegistration } from '@waha/plugins/http.paths.module'; const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController]; const IMPORTS = lodash.flatten([ + HttpPathsRegistration({ + prefix: '/webhooks/', + include: { authBasic: false }, + }), BullModule.registerFlowProducer({ name: FlowProducerName.MESSAGES_PULL_FLOW, }), diff --git a/src/apps/mcp/app.module.ts b/src/apps/mcp/app.module.ts index 3b1ed0c0..9ca745ea 100644 --- a/src/apps/mcp/app.module.ts +++ b/src/apps/mcp/app.module.ts @@ -3,6 +3,7 @@ import { AppName } from '@waha/apps/app_sdk/apps/apps'; import { McpController } from '@waha/apps/mcp/api/mcp.controller'; import { McpService } from '@waha/apps/mcp/mcp.service'; import { McpAppService } from '@waha/apps/mcp/services/McpAppService'; +import { HttpPathsRegistration } from '@waha/plugins/http.paths.module'; const McpAppModule: AppModule = { name: AppName.mcp, @@ -18,7 +19,12 @@ const McpAppModule: AppModule = { unique: false, }, nestjs: { - imports: [], + imports: [ + HttpPathsRegistration( + { prefix: '/mcp', include: { authBasic: false } }, + { prefix: 'mcp', include: { authApiKey: true } }, + ), + ], controllers: [McpController], providers: [McpService, McpAppService], }, diff --git a/src/core/SwaggerConfiguratorCore.ts b/src/core/SwaggerConfiguratorCore.ts index 7d230f01..45321da6 100644 --- a/src/core/SwaggerConfiguratorCore.ts +++ b/src/core/SwaggerConfiguratorCore.ts @@ -4,8 +4,7 @@ import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; import { DECORATORS } from '@nestjs/swagger/dist/constants'; import { GetAppsApiTags } from '@waha/apps/app_sdk/api/tags'; import { BasicAuthFunction } from '@waha/core/auth/basicAuth'; -import { DashboardConfigServiceCore } from '@waha/core/config/DashboardConfigServiceCore'; -import { getPrometheusExcludePaths } from '@waha/modules/waha-prometheus/prometheus.config'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; import { Logger } from 'nestjs-pino'; import { WhatsappConfigService } from '../config.service'; @@ -194,21 +193,8 @@ export class SwaggerConfiguratorCore { setUpAuth(credentials: [string, string]): void { const [username, password] = credentials; - const dashboardConfig = this.app.get(DashboardConfigServiceCore); - const config = this.app.get(WhatsappConfigService); - const exclude = lodash.uniq([ - '/api/', - '/mcp', - dashboardConfig.dashboardUri, - '/health', - '/ping', - '/ws', - '/webhooks/', - '/jobs', - '/jobs/', - ...config.getExcludedFullPaths(), - ...getPrometheusExcludePaths(), - ]); + const httpPaths = this.app.get(HttpPathsService); + const exclude = lodash.uniq(httpPaths.globalAuthExcludePrefixes()); const authFunction = BasicAuthFunction(username, password, exclude); this.app.use(authFunction); diff --git a/src/core/app.module.core.ts b/src/core/app.module.core.ts index 832265c6..1a00ea03 100644 --- a/src/core/app.module.core.ts +++ b/src/core/app.module.core.ts @@ -29,15 +29,14 @@ import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.stor import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig'; import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module'; import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module'; +import { HttpPathsModule } from '@waha/plugins/http.paths.module'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; import { SessionPluginsModule } from '@waha/plugins/session.plugins.module'; import { isPresenceAutoOnlineEnabled } from '@waha/modules/waha-maintain-online-status/maintain-online-status.config'; import { MaintainOnlineStatusModule } from '@waha/modules/waha-maintain-online-status/maintain-online-status.module'; import { isJidEngine } from '@waha/modules/waha-wid-jid/wid-jid.plugins'; import { MessageSourceModule } from '@waha/modules/waha-message-source/message-source.module'; -import { - getPrometheusPath, - isPrometheusEnabled, -} from '@waha/modules/waha-prometheus/prometheus.config'; +import { isPrometheusEnabled } from '@waha/modules/waha-prometheus/prometheus.config'; import { PrometheusModule } from '@waha/modules/waha-prometheus/prometheus.module'; import { WebhookModule } from '@waha/modules/waha-webhook/webhook.module'; import { SessionRuntimeInfoModule } from '@waha/modules/waha-session-runtime-info/session-runtime-info.module'; @@ -94,40 +93,37 @@ import { SessionService } from '@waha/core/services/SessionService'; export const IMPORTS_CORE = [ ...AppsModuleExports.imports, - LoggerModule.forRoot({ - renameContext: 'name', - pinoHttp: { - level: getPinoLogLevel(), - useLevel: getPinoHttpUseLevel(), - transport: getPinoTransport(), - autoLogging: { - ignore: (req) => { - return ( - req.url.startsWith('/ping') || - req.url.startsWith(getPrometheusPath()) || - req.url.startsWith('/dashboard/') || - req.url.startsWith('/api/files/') || - req.url.startsWith('/api/s3/') || - req.url.startsWith('/jobs/') - ); + LoggerModule.forRootAsync({ + imports: [HttpPathsModule], + inject: [HttpPathsService], + useFactory: (httpPaths: HttpPathsService) => { + return { + renameContext: 'name', + pinoHttp: { + level: getPinoLogLevel(), + useLevel: getPinoHttpUseLevel(), + transport: getPinoTransport(), + autoLogging: { + ignore: (req) => httpPaths.isAccessLogIgnored(req.url), + }, + redact: { + paths: ['req.query["x-api-key"]'], + censor: '[REDACTED]', + }, + customAttributeKeys: { req: 'req', res: 'res' }, + serializers: { + req: (req) => ({ + method: req.method, + url: redactUrlParams('x-api-key', req.url, req.query), + query: req.query, + params: req.params, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, }, - }, - redact: { - paths: ['req.query["x-api-key"]'], - censor: '[REDACTED]', - }, - customAttributeKeys: { req: 'req', res: 'res' }, - serializers: { - req: (req) => ({ - method: req.method, - url: redactUrlParams('x-api-key', req.url, req.query), - query: req.query, - params: req.params, - }), - res: (res) => ({ - statusCode: res.statusCode, - }), - }, + }; }, }), ConfigModule.forRoot({ @@ -154,6 +150,7 @@ export const IMPORTS_CORE = [ }), PassportModule, TerminusModule, + HttpPathsModule, SessionPluginsModule, SessionRuntimeInfoModule, WebhookModule, @@ -281,8 +278,29 @@ export class AppModuleCore { constructor( protected config: WhatsappConfigService, private dashboardConfig: DashboardConfigServiceCore, + private httpPaths: HttpPathsService, ) { this.startTimestamp = Date.now(); + this.httpPaths.register( + { prefix: '/ping', include: { accessLog: false, authBasic: false } }, + { prefix: '/api/', include: { authBasic: false } }, + { prefix: 'api', include: { authApiKey: true } }, + { prefix: '/health', include: { authBasic: false } }, + { prefix: 'health', include: { authApiKey: true } }, + { prefix: '/ws', include: { authBasic: false } }, + { + prefix: this.dashboardConfig.dashboardUri, + include: { authBasic: false }, + }, + { + prefix: this.dashboardConfig.dashboardUri + '/', + include: { accessLog: false }, + }, + ); + // WHATSAPP_API_KEY_EXCLUDE_PATH - env-driven auth exclusions + for (const path of this.config.getExcludedFullPaths()) { + this.httpPaths.register({ prefix: path, include: { authBasic: false } }); + } } static getHttpsOptions(logger: Logger) { @@ -311,7 +329,7 @@ export class AppModuleCore { consumer .apply(ApiKeyAuthMiddleware) .exclude(...exclude) - .forRoutes('api', 'health', 'mcp'); + .forRoutes(...this.httpPaths.apiKeyRoutes()); // Dashboard const dashboardCredentials = this.dashboardConfig.credentials; diff --git a/src/core/media/local/media.local.storage.module.ts b/src/core/media/local/media.local.storage.module.ts index e97f5b12..f545e659 100644 --- a/src/core/media/local/media.local.storage.module.ts +++ b/src/core/media/local/media.local.storage.module.ts @@ -4,9 +4,12 @@ import { WhatsappConfigService } from '@waha/config.service'; import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig'; import { MediaLocalStorageFactory } from '@waha/core/media/local/MediaLocalStorageFactory'; import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory'; +import { HttpPathsModule } from '@waha/plugins/http.paths.module'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; @Module({ imports: [ + HttpPathsModule, ServeStaticModule.forRootAsync({ imports: [], extraProviders: [MediaLocalStorageConfig, WhatsappConfigService], @@ -31,4 +34,11 @@ import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory'; ], exports: [MediaStorageFactory], }) -export class MediaLocalStorageModule {} +export class MediaLocalStorageModule { + constructor(config: MediaLocalStorageConfig, httpPaths: HttpPathsService) { + httpPaths.register({ + prefix: config.filesUri + '/', + include: { accessLog: false, metrics: false }, + }); + } +} diff --git a/src/core/media/psql/media.psql.storage.module.ts b/src/core/media/psql/media.psql.storage.module.ts index d8f47045..6d07207c 100644 --- a/src/core/media/psql/media.psql.storage.module.ts +++ b/src/core/media/psql/media.psql.storage.module.ts @@ -3,11 +3,14 @@ import { WhatsappConfigService } from '@waha/config.service'; import { EngineConfigService } from '@waha/core/config/EngineConfigService'; import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory'; import { PsqlFilesController } from '@waha/core/media/psql/api/psql.files.controller'; +import { HttpPathsModule } from '@waha/plugins/http.paths.module'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; import { MediaPsqlStorageConfig } from './MediaPsqlStorageConfig'; import { MediaPsqlStorageFactory } from './MediaPsqlStorageFactory'; @Module({ + imports: [HttpPathsModule], providers: [ { provide: MediaStorageFactory, @@ -21,4 +24,11 @@ import { MediaPsqlStorageFactory } from './MediaPsqlStorageFactory'; exports: [MediaStorageFactory], controllers: [PsqlFilesController], }) -export class MediaPsqlStorageModule {} +export class MediaPsqlStorageModule { + constructor(config: MediaPsqlStorageConfig, httpPaths: HttpPathsService) { + httpPaths.register({ + prefix: config.filesUri + '/', + include: { accessLog: false, metrics: false }, + }); + } +} diff --git a/src/core/media/s3/media.s3.storage.module.ts b/src/core/media/s3/media.s3.storage.module.ts index d24597a5..bd2130dd 100644 --- a/src/core/media/s3/media.s3.storage.module.ts +++ b/src/core/media/s3/media.s3.storage.module.ts @@ -10,8 +10,11 @@ import { S3ProxyUrl, S3Url, } from '@waha/core/media/s3/MediaS3UrlResolver'; +import { HttpPathsModule } from '@waha/plugins/http.paths.module'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; @Module({ + imports: [HttpPathsModule], providers: [ { provide: S3Client, @@ -44,4 +47,11 @@ import { exports: [MediaStorageFactory], controllers: [S3ProxyController], }) -export class MediaS3StorageModule {} +export class MediaS3StorageModule { + constructor(httpPaths: HttpPathsService) { + httpPaths.register({ + prefix: '/api/s3/', + include: { accessLog: false, metrics: false }, + }); + } +} diff --git a/src/modules/waha-prometheus/prometheus.config.ts b/src/modules/waha-prometheus/prometheus.config.ts index f5c273c4..7479dae2 100644 --- a/src/modules/waha-prometheus/prometheus.config.ts +++ b/src/modules/waha-prometheus/prometheus.config.ts @@ -46,13 +46,6 @@ export function getPrometheusPath(): string { return process.env[Env.WAHA_PROMETHEUS_PATH] || DEFAULT_PATH; } -export function getPrometheusExcludePaths(): string[] { - if (!isPrometheusEnabled(process.env)) { - return []; - } - return [getPrometheusPath()]; -} - @Injectable() export class PrometheusConfigService { private eventUnmask = new EventWildUnmask(WAHAEvents, WAHAEventsWild); diff --git a/src/modules/waha-prometheus/prometheus.http.middleware.ts b/src/modules/waha-prometheus/prometheus.http.middleware.ts index 2970fb28..9d9e9a90 100644 --- a/src/modules/waha-prometheus/prometheus.http.middleware.ts +++ b/src/modules/waha-prometheus/prometheus.http.middleware.ts @@ -1,18 +1,18 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; -import { PrometheusConfigService } from '@waha/modules/waha-prometheus/prometheus.config'; import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; import { NextFunction, Request, Response } from 'express'; @Injectable() export class HttpMetricsMiddleware implements NestMiddleware { constructor( private metrics: WahaMetrics, - private config: PrometheusConfigService, + private httpPaths: HttpPathsService, ) {} use(req: Request, res: Response, next: NextFunction): void { const pathname = (req.originalUrl || req.url || '').split('?')[0]; - if (pathname.startsWith(this.config.path)) { + if (this.httpPaths.isHttpMetricsIgnored(pathname)) { next(); return; } diff --git a/src/modules/waha-prometheus/prometheus.module.ts b/src/modules/waha-prometheus/prometheus.module.ts index 61a34887..7fd1c686 100644 --- a/src/modules/waha-prometheus/prometheus.module.ts +++ b/src/modules/waha-prometheus/prometheus.module.ts @@ -10,12 +10,15 @@ import { EventMetricsSubscriber } from '@waha/modules/waha-prometheus/prometheus import { HttpMetricsMiddleware } from '@waha/modules/waha-prometheus/prometheus.http.middleware'; import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics'; import { SessionMetricsCollector } from '@waha/modules/waha-prometheus/prometheus.sessions.collector'; +import { HttpPathsModule } from '@waha/plugins/http.paths.module'; +import { HttpPathsService } from '@waha/plugins/HttpPathsService'; @Module({ imports: [ ConfigModule.forRoot({ validationSchema: PrometheusEnvSchema, }), + HttpPathsModule, ], providers: [ PrometheusConfigService, @@ -32,7 +35,15 @@ import { SessionMetricsCollector } from '@waha/modules/waha-prometheus/prometheu * optional basic auth via WAHA_PROMETHEUS_USERNAME and WAHA_PROMETHEUS_PASSWORD. */ export class PrometheusModule implements NestModule { - constructor(private config: PrometheusConfigService) {} + constructor( + private config: PrometheusConfigService, + httpPaths: HttpPathsService, + ) { + httpPaths.register({ + prefix: this.config.path, + include: { accessLog: false, authBasic: false, metrics: false }, + }); + } configure(consumer: MiddlewareConsumer) { const credentials = this.config.credentials; diff --git a/src/plugins/HttpPathsService.ts b/src/plugins/HttpPathsService.ts new file mode 100644 index 00000000..2cfb7826 --- /dev/null +++ b/src/plugins/HttpPathsService.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; + +/** + * What the path takes part in. + */ +export interface HttpPathInclude { + /** Log requests to the path in the HTTP access log (default true). */ + accessLog?: boolean; + /** Protect the path with the global (Swagger) basic auth (default true). */ + authBasic?: boolean; + /** Protect the route with the api key middleware (default false). */ + authApiKey?: boolean; + /** Count requests to the path in the HTTP metrics (default true). */ + metrics?: boolean; +} + +/** + * A URL prefix contributed by a module - matched with url.startsWith(prefix); + * for authApiKey the prefix is passed verbatim to MiddlewareConsumer.forRoutes(). + */ +export interface HttpPathContribution { + prefix: string; + include?: HttpPathInclude; +} + +/** + * Collects HTTP path contributions from modules, so core auth, logging and metrics can honor them + * without importing the modules. + */ +@Injectable() +export class HttpPathsService { + private contributions: HttpPathContribution[] = []; + + register(...contributions: HttpPathContribution[]): void { + this.contributions.push(...contributions); + } + + isAccessLogIgnored(url: string): boolean { + return this.contributions.some( + (contribution) => + contribution.include?.accessLog === false && + url.startsWith(contribution.prefix), + ); + } + + globalAuthExcludePrefixes(): string[] { + return this.contributions + .filter((contribution) => contribution.include?.authBasic === false) + .map((contribution) => contribution.prefix); + } + + isHttpMetricsIgnored(pathname: string): boolean { + return this.contributions.some( + (contribution) => + contribution.include?.metrics === false && + pathname.startsWith(contribution.prefix), + ); + } + + apiKeyRoutes(): string[] { + return this.contributions + .filter((contribution) => contribution.include?.authApiKey === true) + .map((contribution) => contribution.prefix); + } +} diff --git a/src/plugins/http.paths.module.ts b/src/plugins/http.paths.module.ts new file mode 100644 index 00000000..26b6caea --- /dev/null +++ b/src/plugins/http.paths.module.ts @@ -0,0 +1,29 @@ +import { Inject, Module, Type } from '@nestjs/common'; +import { + HttpPathContribution, + HttpPathsService, +} from '@waha/plugins/HttpPathsService'; + +@Module({ + providers: [HttpPathsService], + exports: [HttpPathsService], +}) +export class HttpPathsModule {} + +/** + * Import-time registration for modules that have no class of their own (e.g. object-style apps) - + * returns a module that registers the contributions in HttpPathsService at bootstrap. + */ +export function HttpPathsRegistration( + ...contributions: HttpPathContribution[] +): Type { + @Module({ + imports: [HttpPathsModule], + }) + class HttpPathsRegistrationModule { + constructor(@Inject(HttpPathsService) httpPaths: HttpPathsService) { + httpPaths.register(...contributions); + } + } + return HttpPathsRegistrationModule; +}