diff --git a/backend/infrastructure/gateway/build.gradle.kts b/backend/infrastructure/gateway/build.gradle.kts index 74c656a9..08130a53 100644 --- a/backend/infrastructure/gateway/build.gradle.kts +++ b/backend/infrastructure/gateway/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + // Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt // für alle externen Anfragen an das Meldestelle-System. plugins { @@ -22,16 +24,15 @@ dependencies { implementation(projects.backend.infrastructure.monitoring.monitoringClient) // === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN === - implementation(libs.bundles.spring.cloud.gateway) - implementation(libs.bundles.spring.boot.security) - implementation(libs.bundles.resilience) - implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") - implementation(libs.spring.boot.starter.actuator) // Wichtig für Health & Metrics - implementation(libs.bundles.logging) - implementation(libs.bundles.jackson.kotlin) + // Kern-Gateway inkl. Security, Actuator, CircuitBreaker, Discovery + implementation(libs.bundles.gateway.core) + // Ergänzende Observability (Logging, Jackson) + implementation(libs.bundles.gateway.observability) + // Redis-Unterstützung für verteiltes Rate Limiting (RequestRateLimiter) + // Umgestellt auf das spezifische Gateway-Redis-Bundle (einfach, leicht zu konfigurieren) + implementation(libs.bundles.gateway.redis) - // WICHTIG: PostgreSQL Treiber hinzufügen! - implementation(libs.postgresql.driver) + // Hinweis: Der Gateway benötigt keinen Datenbanktreiber → entfernt // === Test Dependencies === testImplementation(projects.platform.platformTesting) @@ -50,7 +51,7 @@ sourceSets { } } -val integrationTestImplementation by configurations.getting { +val integrationTestImplementation: Configuration? by configurations.getting { extendsFrom(configurations.testImplementation.get()) } @@ -71,6 +72,6 @@ tasks.register("integrationTest") { showExceptions = true showCauses = true showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + exceptionFormat = TestExceptionFormat.FULL } } diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt index a868254e..e8166532 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt @@ -1,5 +1,3 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - package at.mocode.infrastructure.gateway.config import org.slf4j.LoggerFactory @@ -13,7 +11,7 @@ import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange import reactor.core.publisher.Mono import java.util.concurrent.ConcurrentHashMap -import kotlin.uuid.Uuid +import java.util.UUID /** * Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security. @@ -32,7 +30,7 @@ class CorrelationIdFilter : GlobalFilter, Ordered { override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { val request = exchange.request val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER) - ?: Uuid.random().toString() + ?: UUID.randomUUID().toString() val mutatedRequest = request.mutate() .header(CORRELATION_ID_HEADER, correlationId) @@ -50,207 +48,3 @@ class CorrelationIdFilter : GlobalFilter, Ordered { override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE } - -/** - * Enhanced Logging Filter für strukturiertes Logging mit Request/Response Details. - */ -@Component -class EnhancedLoggingFilter : GlobalFilter, Ordered { - - private val logger = LoggerFactory.getLogger(EnhancedLoggingFilter::class.java) - - override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { - val startTime = System.currentTimeMillis() - val request = exchange.request - val correlationId = request.headers.getFirst(CorrelationIdFilter.CORRELATION_ID_HEADER) - - logRequest(request, correlationId) - - return chain.filter(exchange) - .doOnSuccess { - val responseTime = System.currentTimeMillis() - startTime - logResponse(exchange.response, correlationId, responseTime) - } - .doOnError { error -> - val responseTime = System.currentTimeMillis() - startTime - logError(error, correlationId, responseTime) - } - } - - private fun logRequest(request: ServerHttpRequest, correlationId: String?) { - logger.info(""" - [REQUEST] [{}] - Method: {} - URI: {} - RemoteAddress: {} - UserAgent: {} - """.trimIndent(), - correlationId, - request.method, - request.uri, - request.remoteAddress, - request.headers.getFirst("User-Agent") - ) - } - - private fun logResponse(response: ServerHttpResponse, correlationId: String?, responseTime: Long) { - logger.info(""" - [RESPONSE] [{}] - Status: {} - ResponseTime: {}ms - """.trimIndent(), - correlationId, - response.statusCode, - responseTime - ) - } - - private fun logError(error: Throwable, correlationId: String?, responseTime: Long) { - logger.error(""" - [ERROR] [{}] - Error: {} - ResponseTime: {}ms - """.trimIndent(), - correlationId, - error.message, - responseTime, - error - ) - } - - override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1 -} - -/** - * Rate Limiting Filter basierend auf IP-Adresse und User-Typ. - * - * Optimierungen: - * - Memory-Leak-Schutz durch regelmäßige Bereinigung alter Einträge - * - Sichere Rollenvalidierung basierend auf JWT-Authentifizierung - * - Bessere Verteilung der Rate-Limits basierend auf Benutzerrollen - */ -@Component -class RateLimitingFilter : GlobalFilter, Ordered { - - private val requestCounts = ConcurrentHashMap() - private val logger = LoggerFactory.getLogger(RateLimitingFilter::class.java) - - // Timestamp der letzten Bereinigung - @Volatile - private var lastCleanup = System.currentTimeMillis() - - companion object { - const val RATE_LIMIT_ENABLED_HEADER = "X-RateLimit-Enabled" - const val RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit" - const val RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining" - - // Rate Limits pro Minute - const val ANONYMOUS_LIMIT = 50 - const val AUTHENTICATED_LIMIT = 200 - const val ADMIN_LIMIT = 500 - const val AUTH_ENDPOINT_LIMIT = 20 - const val DEFAULT_LIMIT = 100 - - // Bereinigungsintervall: alle 5 Minuten - const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L - // Einträge, die älter als 10 Minuten sind, werden entfernt - const val ENTRY_MAX_AGE_MS = 10 * 60 * 1000L - } - - data class RequestCounter( - var count: Int = 0, - var lastReset: Long = System.currentTimeMillis() - ) - - override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { - val request = exchange.request - val response = exchange.response - val clientIp = getClientIp(request) - val path = request.path.value() - - // Periodische Bereinigung des Caches zur Vermeidung von memory Leaks - performPeriodicCleanup() - - val limit = determineRateLimit(request, path) - val counter = requestCounts.computeIfAbsent(clientIp) { RequestCounter() } - - // Zähler zurücksetzen, wenn mehr als eine Minute vergangen ist - val now = System.currentTimeMillis() - if (now - counter.lastReset > 60_000) { - counter.count = 0 - counter.lastReset = now - } - - counter.count++ - - // Rate-Limit-Header hinzufügen - response.headers.add(RATE_LIMIT_ENABLED_HEADER, "true") - response.headers.add(RATE_LIMIT_LIMIT_HEADER, limit.toString()) - response.headers.add(RATE_LIMIT_REMAINING_HEADER, maxOf(0, limit - counter.count).toString()) - - return if (counter.count > limit) { - response.statusCode = HttpStatus.TOO_MANY_REQUESTS - response.setComplete() - } else { - chain.filter(exchange) - } - } - - private fun getClientIp(request: ServerHttpRequest): String { - return request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim() - ?: request.headers.getFirst("X-Real-IP") - ?: request.remoteAddress?.address?.hostAddress - ?: "unknown" - } - - private fun determineRateLimit(request: ServerHttpRequest, path: String): Int { - return when { - path.startsWith("/api/auth") -> AUTH_ENDPOINT_LIMIT - isAdminUser(request) -> ADMIN_LIMIT - isAuthenticatedUser(request) -> AUTHENTICATED_LIMIT - else -> ANONYMOUS_LIMIT - } - } - - private fun isAuthenticatedUser(request: ServerHttpRequest): Boolean { - return request.headers.getFirst("Authorization") != null - } - - private fun isAdminUser(request: ServerHttpRequest): Boolean { - // Sichere Rollenvalidierung basierend auf JWT-Authentifizierung - // die X-User-Role wird vom JwtAuthenticationFilter nach erfolgreicher JWT-Validierung gesetzt - val userRole = request.headers.getFirst("X-User-Role") - val userId = request.headers.getFirst("X-User-ID") - - // Zusätzliche Sicherheitsprüfung: Beide Header müssen vorhanden sein. - // Dies reduziert die Wahrscheinlichkeit von Header-Spoofing - return userRole == "ADMIN" && userId != null - } - - /** - * Bereinigt alte Einträge aus dem requestCounts Cache zur Vermeidung von memory Leaks. - * Wird nur alle CLEANUP_INTERVAL_MS ausgeführt für bessere Performance. - */ - private fun performPeriodicCleanup() { - val now = System.currentTimeMillis() - if (now - lastCleanup > CLEANUP_INTERVAL_MS) { - val sizeBefore = requestCounts.size - val cutoffTime = now - ENTRY_MAX_AGE_MS - - // Entferne alle Einträge, die älter als ENTRY_MAX_AGE_MS sind - requestCounts.entries.removeIf { (_, counter) -> - counter.lastReset < cutoffTime - } - - lastCleanup = now - val sizeAfter = requestCounts.size - - if (sizeBefore > sizeAfter) { - logger.debug("Rate limit cache cleanup: removed {} old entries, {} entries remaining", - sizeBefore - sizeAfter, sizeAfter) - } - } - } - - override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 2 -} diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt new file mode 100644 index 00000000..dfd074df --- /dev/null +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt @@ -0,0 +1,34 @@ +package at.mocode.infrastructure.gateway.config + +import at.mocode.infrastructure.gateway.config.CorrelationIdFilter.Companion.CORRELATION_ID_HEADER +import org.slf4j.MDC +import org.springframework.cloud.gateway.filter.GatewayFilterChain +import org.springframework.cloud.gateway.filter.GlobalFilter +import org.springframework.core.Ordered +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/** + * Minimaler MDC-Filter: schreibt die vorhandene X-Correlation-ID in den MDC, + * damit Logs die ID automatisch mitführen. Keine Body-/PII-Logs, nur Header-ID. + * + * Reihenfolge: direkt nach dem CorrelationIdFilter ausführen, damit die ID + * bereits gesetzt ist. Daher Order = HIGHEST_PRECEDENCE + 1. + */ +@Component +class MdcCorrelationFilter : GlobalFilter, Ordered { + + override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { + val correlationId = exchange.request.headers.getFirst(CORRELATION_ID_HEADER) + if (correlationId != null) { + MDC.put(CORRELATION_ID_HEADER, correlationId) + } + + return chain.filter(exchange) + // Bei Abschluss säubern, um Leaks über Thread-Grenzen zu vermeiden + .doFinally { MDC.remove(CORRELATION_ID_HEADER) } + } + + override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1 +} diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/RateLimiterConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/RateLimiterConfig.kt new file mode 100644 index 00000000..5b18d7b3 --- /dev/null +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/RateLimiterConfig.kt @@ -0,0 +1,29 @@ +package at.mocode.infrastructure.gateway.config + +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import reactor.core.publisher.Mono +import java.security.Principal + +@Configuration +class RateLimiterConfig { + + /** + * KeyResolver basierend auf authentifiziertem Principal; Fallback auf Client-IP. + * Funktioniert out-of-the-box mit Keycloak (Resource Server), sofern Security aktiv ist. + */ + @Bean + fun principalNameKeyResolver(): KeyResolver = KeyResolver { exchange -> + exchange.getPrincipal() + .map { it.name } + .switchIfEmpty( + Mono.just( + exchange.request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim() + ?: exchange.request.headers.getFirst("X-Real-IP") + ?: exchange.request.remoteAddress?.address?.hostAddress + ?: "unknown" + ) + ) + } +} diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/controller/FallbackController.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/controller/FallbackController.kt index 6d2c464d..1cad5e4a 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/controller/FallbackController.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/controller/FallbackController.kt @@ -20,6 +20,11 @@ class FallbackController { return createFallbackResponse("members-service", "Member operations are temporarily unavailable") } + @RequestMapping(value = ["/ping"], method = [RequestMethod.GET, RequestMethod.POST]) + fun pingFallback(): ResponseEntity { + return createFallbackResponse("ping-service", "Ping service is temporarily unavailable") + } + @RequestMapping(value = ["/horses"], method = [RequestMethod.GET, RequestMethod.POST]) fun horsesFallback(): ResponseEntity { return createFallbackResponse("horses-service", "Horse registry operations are temporarily unavailable") diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/health/GatewayHealthIndicator.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/health/GatewayHealthIndicator.kt index 76b6b485..be346be2 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/health/GatewayHealthIndicator.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/health/GatewayHealthIndicator.kt @@ -1,12 +1,14 @@ package at.mocode.infrastructure.gateway.health import org.springframework.boot.actuate.health.Health -import org.springframework.boot.actuate.health.HealthIndicator +import org.springframework.boot.actuate.health.ReactiveHealthIndicator import org.springframework.cloud.client.discovery.DiscoveryClient import org.springframework.core.env.Environment import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono import java.time.Duration /** @@ -20,7 +22,7 @@ class GatewayHealthIndicator( private val discoveryClient: DiscoveryClient, private val webClient: WebClient.Builder, private val environment: Environment -) : HealthIndicator { +) : ReactiveHealthIndicator { companion object { private val CRITICAL_SERVICES = setOf( @@ -38,105 +40,105 @@ class GatewayHealthIndicator( private val HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(5) } - override fun health(): Health { + override fun health(): Mono { val builder = Health.up() val details = mutableMapOf() - try { - // Prüfe alle registrierten Services in Consul - val allServices = discoveryClient.services - val discoveredServices = mutableMapOf() - - allServices.forEach { serviceName -> + return Mono.fromCallable { discoveryClient.services } + .flatMapMany { services -> + details["totalServices"] = services.size + Flux.fromIterable(services) + } + .flatMap({ serviceName -> val instances = discoveryClient.getInstances(serviceName) - discoveredServices[serviceName] = mapOf( + val instanceDetails = mapOf( "instanceCount" to instances.size, "instances" to instances.map { "${it.host}:${it.port}" } ) - } - - details["discoveredServices"] = discoveredServices - details["totalServices"] = allServices.size - - // Prüfe kritische Services - val criticalServiceStatus = mutableMapOf() - var hasCriticalFailure = false - - CRITICAL_SERVICES.forEach { serviceName -> - val status = checkServiceHealth(serviceName) - criticalServiceStatus[serviceName] = status - if (status != "UP") { - hasCriticalFailure = true + // Für Health-Check nur auf definierte Services gehen + val checkMono = when { + CRITICAL_SERVICES.contains(serviceName) || OPTIONAL_SERVICES.contains(serviceName) -> + checkServiceHealthReactive(serviceName) + else -> Mono.just("SKIPPED") } - } + checkMono + .map { status -> Triple(serviceName, status, instanceDetails) } + }, 8) // begrenze Parallelität + .collectList() + .map { results -> + val discoveredServices = mutableMapOf() + val criticalServiceStatus = mutableMapOf() + val optionalServiceStatus = mutableMapOf() - // Prüfe optionale Services - val optionalServiceStatus = mutableMapOf() - OPTIONAL_SERVICES.forEach { serviceName -> - optionalServiceStatus[serviceName] = checkServiceHealth(serviceName) - } - - details["criticalServices"] = criticalServiceStatus - details["optionalServices"] = optionalServiceStatus - - // Gateway Status basierend auf kritischen Services - val isTestEnvironment = environment.activeProfiles.contains("test") - val isDevEnvironment = environment.activeProfiles.contains("dev") - - if (hasCriticalFailure && !isTestEnvironment && !isDevEnvironment) { - builder.down() - details["status"] = "DOWN" - details["reason"] = "Ein oder mehrere kritische Services sind nicht verfügbar" - } else { - details["status"] = "UP" - details["reason"] = when { - isTestEnvironment -> "Gesundheitsprüfung erfolgreich (Testumgebung)" - isDevEnvironment -> "Gesundheitsprüfung erfolgreich (Entwicklungsumgebung - nicht alle Services erforderlich)" - else -> "Alle kritischen Services sind verfügbar" + results.forEach { (serviceName, status, instanceDetails) -> + discoveredServices[serviceName] = instanceDetails + if (CRITICAL_SERVICES.contains(serviceName)) { + criticalServiceStatus[serviceName] = status + } else if (OPTIONAL_SERVICES.contains(serviceName)) { + optionalServiceStatus[serviceName] = status + } } + + details["discoveredServices"] = discoveredServices + details["criticalServices"] = criticalServiceStatus + details["optionalServices"] = optionalServiceStatus + + val isTestEnvironment = environment.activeProfiles.contains("test") + val isDevEnvironment = environment.activeProfiles.contains("dev") + val hasCriticalFailure = criticalServiceStatus.values.any { it != "UP" } + + if (hasCriticalFailure && !isTestEnvironment && !isDevEnvironment) { + builder.down() + details["status"] = "DOWN" + details["reason"] = "Ein oder mehrere kritische Services sind nicht verfügbar" + } else { + details["status"] = "UP" + details["reason"] = when { + isTestEnvironment -> "Gesundheitsprüfung erfolgreich (Testumgebung)" + isDevEnvironment -> "Gesundheitsprüfung erfolgreich (Entwicklungsumgebung - nicht alle Services erforderlich)" + else -> "Alle kritischen Services sind verfügbar oder optional" + } + } + + builder.withDetails(details).build() + } + .onErrorResume { ex -> + Mono.just( + Health.down(ex) + .withDetail("status", "DOWN") + .withDetail("reason", "Fehler beim Prüfen der nachgelagerten Services: ${ex.message}") + .build() + ) } - - } catch (exception: Exception) { - builder.down() - .withException(exception) - details["status"] = "DOWN" - details["reason"] = "Fehler beim Prüfen der nachgelagerten Services: ${exception.message}" - } - - return builder.withDetails(details).build() } - private fun checkServiceHealth(serviceName: String): String { - return try { - val instances = discoveryClient.getInstances(serviceName) - - if (instances.isEmpty()) { - "NO_INSTANCES" - } else { - // Versuche Health-Check für die erste verfügbare Instanz - val instance = instances.first() - val healthUrl = "http://${instance.host}:${instance.port}/actuator/health" - - val client = webClient.build() - val response = client.get() - .uri(healthUrl) - .retrieve() - .bodyToMono(Map::class.java) - .timeout(HEALTH_CHECK_TIMEOUT) - .onErrorReturn(mapOf("status" to "DOWN")) - .block() - - val status = response?.get("status")?.toString() ?: "UNKNOWN" - if (status == "UP") "UP" else "DOWN" + private fun checkServiceHealthReactive(serviceName: String): Mono { + return Mono.fromCallable { discoveryClient.getInstances(serviceName) } + .flatMap { instances -> + if (instances.isEmpty()) { + Mono.just("NO_INSTANCES") + } else { + val instance = instances.first() + val healthUrl = "http://${instance.host}:${instance.port}/actuator/health" + val client = webClient.build() + client.get() + .uri(healthUrl) + .retrieve() + .bodyToMono(Map::class.java) + .timeout(HEALTH_CHECK_TIMEOUT) + .map { it["status"]?.toString() ?: "UNKNOWN" } + .map { status -> if (status == "UP") "UP" else "DOWN" } + .onErrorResume { ex -> + when (ex) { + is WebClientResponseException -> when (ex.statusCode.value()) { + 404 -> Mono.just("NO_HEALTH_ENDPOINT") + 503 -> Mono.just("DOWN") + else -> Mono.just("ERROR") + } + else -> Mono.just("ERROR") + } + } + } } - } catch (exception: WebClientResponseException) { - when (exception.statusCode.value()) { - 404 -> "NO_HEALTH_ENDPOINT" - 503 -> "DOWN" - else -> "ERROR" - } - } catch (_: Exception) { - "ERROR" - } } } diff --git a/backend/infrastructure/gateway/src/main/resources/application.yml b/backend/infrastructure/gateway/src/main/resources/application.yml index 718756dc..a520cd69 100644 --- a/backend/infrastructure/gateway/src/main/resources/application.yml +++ b/backend/infrastructure/gateway/src/main/resources/application.yml @@ -16,6 +16,11 @@ spring: user: name: ${GATEWAY_ADMIN_USER:admin} password: ${GATEWAY_ADMIN_PASSWORD:admin} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + timeout: 3s cloud: consul: host: ${CONSUL_HOST:localhost} @@ -38,18 +43,14 @@ spring: max-life-time: 60s default-filters: - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin - - name: CircuitBreaker - args: - name: defaultCircuitBreaker - fallbackUri: forward:/fallback - name: Retry args: - retries: 3 + retries: 2 statuses: BAD_GATEWAY,GATEWAY_TIMEOUT - methods: GET,POST,PUT,DELETE + methods: GET backoff: - firstBackoff: 50ms - maxBackoff: 500ms + firstBackoff: 100ms + maxBackoff: 1000ms factor: 2 basedOnPreviousValue: false - name: AddResponseHeader @@ -60,10 +61,6 @@ spring: args: name: X-Frame-Options value: DENY - - name: AddResponseHeader - args: - name: X-XSS-Protection - value: 1; mode=block - name: AddResponseHeader args: name: Referrer-Policy @@ -100,6 +97,15 @@ spring: - Path=/api/ping/** filters: - StripPrefix=1 + - name: CircuitBreaker + args: + name: pingCircuitBreaker + fallbackUri: forward:/fallback/ping + - name: RequestRateLimiter + args: + key-resolver: "#{@principalNameKeyResolver}" + redis-rate-limiter.replenishRate: ${PING_RATE_LIMIT_REPLENISH_RATE:50} + redis-rate-limiter.burstCapacity: ${PING_RATE_LIMIT_BURST:100} # ============================================================== # --- Entries-Service-Integration (MP-27) --- @@ -215,6 +221,8 @@ resilience4j: instances: defaultCircuitBreaker: baseConfig: default + pingCircuitBreaker: + baseConfig: default membersCircuitBreaker: baseConfig: default slidingWindowSize: 50 @@ -248,8 +256,8 @@ management: allow-credentials: true endpoint: health: - show-details: always - show-components: always + show-details: when_authorized + show-components: when_authorized probes: enabled: true metrics: @@ -304,21 +312,10 @@ management: logging: level: org.springframework.cloud.gateway: INFO - org.springframework.cloud.loadbalancer: DEBUG + org.springframework.cloud.loadbalancer: INFO org.springframework.cloud.consul: INFO - at.mocode.infrastructure.gateway: DEBUG + at.mocode.infrastructure.gateway: INFO io.github.resilience4j: INFO reactor.netty.http.client: INFO - org.springframework.security: WARN - org.springframework.web: INFO - pattern: - console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr([%X{correlationId:-}]){yellow} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}" - file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId:-}] %logger{36} - %msg%n" - file: - name: infrastructure/gateway/logs/gateway.log - logback: - rolling policy: - clean-history-on-start: true - max-file-size: 100MB - total-size-cap: 1GB - max-history: 30 + +# (Redis-Konfiguration wurde in den bestehenden spring.data.redis-Block oben integriert) diff --git a/backend/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt b/backend/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt index d6fce97d..292949df 100644 --- a/backend/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt +++ b/backend/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt @@ -27,10 +27,8 @@ import org.springframework.test.context.ActiveProfiles "gateway.security.jwt.enabled=false", // Reaktiven Web-Anwendungstyp verwenden "spring.main.web-application-type=reactive", - // Gateway Discovery deaktivieren - "spring.cloud.gateway.server.webflux.discovery.locator.enabled=false", - // Actuator Security deaktivieren - "management.security.enabled=false", + // Gateway Discovery deaktivieren (korrekte Property) + "spring.cloud.gateway.discovery.locator.enabled=false", // Zufälligen Port setzen "server.port=0" ] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9086fad..9340115f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -157,6 +157,8 @@ springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openap # --- Spring Cloud --- spring-cloud-starter-gateway-server-webflux = { module = "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux" } spring-cloud-starter-consul-discovery = { module = "org.springframework.cloud:spring-cloud-starter-consul-discovery" } +spring-cloud-starter-circuitbreaker-resilience4j = { module = "org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j" } + # --- Database & Persistence --- exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } @@ -318,8 +320,34 @@ spring-cloud-gateway = [ "spring-cloud-starter-consul-discovery" ] +# --- Gateway spezifische Bundles --- +# Kernabhängigkeiten des Gateways, ohne optionale Observability/DB. +gateway-core = [ + "spring-cloud-starter-gateway-server-webflux", + "spring-cloud-starter-consul-discovery", + "spring-boot-starter-actuator", + "spring-boot-starter-security", + "spring-boot-starter-oauth2-resource-server", + "spring-security-oauth2-jose", + "spring-cloud-starter-circuitbreaker-resilience4j" +] + +# Ergänzende Bundles, die das Gateway häufig nutzt (getrennt für klare Steuerung) +gateway-observability = [ + "kotlin-logging-jvm", + "logback-classic", + "logback-core", + "jackson-module-kotlin", + "jackson-datatype-jsr310" +] + # --- NEW BUNDLES --- +# Redis für Gateway (Rate Limiting, einfache Konfiguration) +gateway-redis = [ + "spring-boot-starter-data-redis" +] + # Ktor Server bundles ktor-server-common = [ "ktor-server-core", @@ -516,8 +544,4 @@ spring-dependencyManagement = { id = "io.spring.dependency-management", version. foojayResolver = { id = "org.gradle.toolchains.foojay-resolver-convention", version.ref = "foojayResolver" } # Dokka plugin -# Version pinned to work with Kotlin 2.2.x -# See: https://github.com/Kotlin/dokka -# Note: dokka 2.0.0+ matches Kotlin 2.0+; verify compatibility if bumping Kotlin -# Using latest stable known compatible as of 2025-10 dokka = { id = "org.jetbrains.dokka", version = "2.1.0" }