upgrade Java-25 Kotlin-2.3.0 usw.
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||||
|
|
||||||
// Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt
|
// Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt
|
||||||
// für alle externen Anfragen an das Meldestelle-System.
|
// für alle externen Anfragen an das Meldestelle-System.
|
||||||
plugins {
|
plugins {
|
||||||
@@ -22,16 +24,15 @@ dependencies {
|
|||||||
implementation(projects.backend.infrastructure.monitoring.monitoringClient)
|
implementation(projects.backend.infrastructure.monitoring.monitoringClient)
|
||||||
|
|
||||||
// === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN ===
|
// === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN ===
|
||||||
implementation(libs.bundles.spring.cloud.gateway)
|
// Kern-Gateway inkl. Security, Actuator, CircuitBreaker, Discovery
|
||||||
implementation(libs.bundles.spring.boot.security)
|
implementation(libs.bundles.gateway.core)
|
||||||
implementation(libs.bundles.resilience)
|
// Ergänzende Observability (Logging, Jackson)
|
||||||
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
|
implementation(libs.bundles.gateway.observability)
|
||||||
implementation(libs.spring.boot.starter.actuator) // Wichtig für Health & Metrics
|
// Redis-Unterstützung für verteiltes Rate Limiting (RequestRateLimiter)
|
||||||
implementation(libs.bundles.logging)
|
// Umgestellt auf das spezifische Gateway-Redis-Bundle (einfach, leicht zu konfigurieren)
|
||||||
implementation(libs.bundles.jackson.kotlin)
|
implementation(libs.bundles.gateway.redis)
|
||||||
|
|
||||||
// WICHTIG: PostgreSQL Treiber hinzufügen!
|
// Hinweis: Der Gateway benötigt keinen Datenbanktreiber → entfernt
|
||||||
implementation(libs.postgresql.driver)
|
|
||||||
|
|
||||||
// === Test Dependencies ===
|
// === Test Dependencies ===
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
@@ -50,7 +51,7 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val integrationTestImplementation by configurations.getting {
|
val integrationTestImplementation: Configuration? by configurations.getting {
|
||||||
extendsFrom(configurations.testImplementation.get())
|
extendsFrom(configurations.testImplementation.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,6 @@ tasks.register<Test>("integrationTest") {
|
|||||||
showExceptions = true
|
showExceptions = true
|
||||||
showCauses = true
|
showCauses = true
|
||||||
showStackTraces = true
|
showStackTraces = true
|
||||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
exceptionFormat = TestExceptionFormat.FULL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-208
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
|
||||||
|
|
||||||
package at.mocode.infrastructure.gateway.config
|
package at.mocode.infrastructure.gateway.config
|
||||||
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -13,7 +11,7 @@ import org.springframework.stereotype.Component
|
|||||||
import org.springframework.web.server.ServerWebExchange
|
import org.springframework.web.server.ServerWebExchange
|
||||||
import reactor.core.publisher.Mono
|
import reactor.core.publisher.Mono
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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.
|
* 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<Void> {
|
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
||||||
val request = exchange.request
|
val request = exchange.request
|
||||||
val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER)
|
val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER)
|
||||||
?: Uuid.random().toString()
|
?: UUID.randomUUID().toString()
|
||||||
|
|
||||||
val mutatedRequest = request.mutate()
|
val mutatedRequest = request.mutate()
|
||||||
.header(CORRELATION_ID_HEADER, correlationId)
|
.header(CORRELATION_ID_HEADER, correlationId)
|
||||||
@@ -50,207 +48,3 @@ class CorrelationIdFilter : GlobalFilter, Ordered {
|
|||||||
|
|
||||||
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
|
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<Void> {
|
|
||||||
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<String, RequestCounter>()
|
|
||||||
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<Void> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
+34
@@ -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<Void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+29
@@ -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<Principal>()
|
||||||
|
.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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -20,6 +20,11 @@ class FallbackController {
|
|||||||
return createFallbackResponse("members-service", "Member operations are temporarily unavailable")
|
return createFallbackResponse("members-service", "Member operations are temporarily unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequestMapping(value = ["/ping"], method = [RequestMethod.GET, RequestMethod.POST])
|
||||||
|
fun pingFallback(): ResponseEntity<ErrorResponse> {
|
||||||
|
return createFallbackResponse("ping-service", "Ping service is temporarily unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
@RequestMapping(value = ["/horses"], method = [RequestMethod.GET, RequestMethod.POST])
|
@RequestMapping(value = ["/horses"], method = [RequestMethod.GET, RequestMethod.POST])
|
||||||
fun horsesFallback(): ResponseEntity<ErrorResponse> {
|
fun horsesFallback(): ResponseEntity<ErrorResponse> {
|
||||||
return createFallbackResponse("horses-service", "Horse registry operations are temporarily unavailable")
|
return createFallbackResponse("horses-service", "Horse registry operations are temporarily unavailable")
|
||||||
|
|||||||
+90
-88
@@ -1,12 +1,14 @@
|
|||||||
package at.mocode.infrastructure.gateway.health
|
package at.mocode.infrastructure.gateway.health
|
||||||
|
|
||||||
import org.springframework.boot.actuate.health.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.cloud.client.discovery.DiscoveryClient
|
||||||
import org.springframework.core.env.Environment
|
import org.springframework.core.env.Environment
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.reactive.function.client.WebClient
|
import org.springframework.web.reactive.function.client.WebClient
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException
|
import org.springframework.web.reactive.function.client.WebClientResponseException
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
import reactor.core.publisher.Mono
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +22,7 @@ class GatewayHealthIndicator(
|
|||||||
private val discoveryClient: DiscoveryClient,
|
private val discoveryClient: DiscoveryClient,
|
||||||
private val webClient: WebClient.Builder,
|
private val webClient: WebClient.Builder,
|
||||||
private val environment: Environment
|
private val environment: Environment
|
||||||
) : HealthIndicator {
|
) : ReactiveHealthIndicator {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val CRITICAL_SERVICES = setOf(
|
private val CRITICAL_SERVICES = setOf(
|
||||||
@@ -38,105 +40,105 @@ class GatewayHealthIndicator(
|
|||||||
private val HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(5)
|
private val HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun health(): Health {
|
override fun health(): Mono<Health> {
|
||||||
val builder = Health.up()
|
val builder = Health.up()
|
||||||
val details = mutableMapOf<String, Any>()
|
val details = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
try {
|
return Mono.fromCallable { discoveryClient.services }
|
||||||
// Prüfe alle registrierten Services in Consul
|
.flatMapMany { services ->
|
||||||
val allServices = discoveryClient.services
|
details["totalServices"] = services.size
|
||||||
val discoveredServices = mutableMapOf<String, Any>()
|
Flux.fromIterable(services)
|
||||||
|
}
|
||||||
allServices.forEach { serviceName ->
|
.flatMap({ serviceName ->
|
||||||
val instances = discoveryClient.getInstances(serviceName)
|
val instances = discoveryClient.getInstances(serviceName)
|
||||||
discoveredServices[serviceName] = mapOf(
|
val instanceDetails = mapOf(
|
||||||
"instanceCount" to instances.size,
|
"instanceCount" to instances.size,
|
||||||
"instances" to instances.map { "${it.host}:${it.port}" }
|
"instances" to instances.map { "${it.host}:${it.port}" }
|
||||||
)
|
)
|
||||||
}
|
// Für Health-Check nur auf definierte Services gehen
|
||||||
|
val checkMono = when {
|
||||||
details["discoveredServices"] = discoveredServices
|
CRITICAL_SERVICES.contains(serviceName) || OPTIONAL_SERVICES.contains(serviceName) ->
|
||||||
details["totalServices"] = allServices.size
|
checkServiceHealthReactive(serviceName)
|
||||||
|
else -> Mono.just("SKIPPED")
|
||||||
// Prüfe kritische Services
|
|
||||||
val criticalServiceStatus = mutableMapOf<String, String>()
|
|
||||||
var hasCriticalFailure = false
|
|
||||||
|
|
||||||
CRITICAL_SERVICES.forEach { serviceName ->
|
|
||||||
val status = checkServiceHealth(serviceName)
|
|
||||||
criticalServiceStatus[serviceName] = status
|
|
||||||
if (status != "UP") {
|
|
||||||
hasCriticalFailure = true
|
|
||||||
}
|
}
|
||||||
}
|
checkMono
|
||||||
|
.map { status -> Triple(serviceName, status, instanceDetails) }
|
||||||
|
}, 8) // begrenze Parallelität
|
||||||
|
.collectList()
|
||||||
|
.map { results ->
|
||||||
|
val discoveredServices = mutableMapOf<String, Any>()
|
||||||
|
val criticalServiceStatus = mutableMapOf<String, String>()
|
||||||
|
val optionalServiceStatus = mutableMapOf<String, String>()
|
||||||
|
|
||||||
// Prüfe optionale Services
|
results.forEach { (serviceName, status, instanceDetails) ->
|
||||||
val optionalServiceStatus = mutableMapOf<String, String>()
|
discoveredServices[serviceName] = instanceDetails
|
||||||
OPTIONAL_SERVICES.forEach { serviceName ->
|
if (CRITICAL_SERVICES.contains(serviceName)) {
|
||||||
optionalServiceStatus[serviceName] = checkServiceHealth(serviceName)
|
criticalServiceStatus[serviceName] = status
|
||||||
}
|
} else if (OPTIONAL_SERVICES.contains(serviceName)) {
|
||||||
|
optionalServiceStatus[serviceName] = status
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun checkServiceHealthReactive(serviceName: String): Mono<String> {
|
||||||
return try {
|
return Mono.fromCallable { discoveryClient.getInstances(serviceName) }
|
||||||
val instances = discoveryClient.getInstances(serviceName)
|
.flatMap { instances ->
|
||||||
|
if (instances.isEmpty()) {
|
||||||
if (instances.isEmpty()) {
|
Mono.just("NO_INSTANCES")
|
||||||
"NO_INSTANCES"
|
} else {
|
||||||
} else {
|
val instance = instances.first()
|
||||||
// Versuche Health-Check für die erste verfügbare Instanz
|
val healthUrl = "http://${instance.host}:${instance.port}/actuator/health"
|
||||||
val instance = instances.first()
|
val client = webClient.build()
|
||||||
val healthUrl = "http://${instance.host}:${instance.port}/actuator/health"
|
client.get()
|
||||||
|
.uri(healthUrl)
|
||||||
val client = webClient.build()
|
.retrieve()
|
||||||
val response = client.get()
|
.bodyToMono(Map::class.java)
|
||||||
.uri(healthUrl)
|
.timeout(HEALTH_CHECK_TIMEOUT)
|
||||||
.retrieve()
|
.map { it["status"]?.toString() ?: "UNKNOWN" }
|
||||||
.bodyToMono(Map::class.java)
|
.map { status -> if (status == "UP") "UP" else "DOWN" }
|
||||||
.timeout(HEALTH_CHECK_TIMEOUT)
|
.onErrorResume { ex ->
|
||||||
.onErrorReturn(mapOf("status" to "DOWN"))
|
when (ex) {
|
||||||
.block()
|
is WebClientResponseException -> when (ex.statusCode.value()) {
|
||||||
|
404 -> Mono.just("NO_HEALTH_ENDPOINT")
|
||||||
val status = response?.get("status")?.toString() ?: "UNKNOWN"
|
503 -> Mono.just("DOWN")
|
||||||
if (status == "UP") "UP" else "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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ spring:
|
|||||||
user:
|
user:
|
||||||
name: ${GATEWAY_ADMIN_USER:admin}
|
name: ${GATEWAY_ADMIN_USER:admin}
|
||||||
password: ${GATEWAY_ADMIN_PASSWORD:admin}
|
password: ${GATEWAY_ADMIN_PASSWORD:admin}
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
timeout: 3s
|
||||||
cloud:
|
cloud:
|
||||||
consul:
|
consul:
|
||||||
host: ${CONSUL_HOST:localhost}
|
host: ${CONSUL_HOST:localhost}
|
||||||
@@ -38,18 +43,14 @@ spring:
|
|||||||
max-life-time: 60s
|
max-life-time: 60s
|
||||||
default-filters:
|
default-filters:
|
||||||
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
|
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
|
||||||
- name: CircuitBreaker
|
|
||||||
args:
|
|
||||||
name: defaultCircuitBreaker
|
|
||||||
fallbackUri: forward:/fallback
|
|
||||||
- name: Retry
|
- name: Retry
|
||||||
args:
|
args:
|
||||||
retries: 3
|
retries: 2
|
||||||
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
|
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
|
||||||
methods: GET,POST,PUT,DELETE
|
methods: GET
|
||||||
backoff:
|
backoff:
|
||||||
firstBackoff: 50ms
|
firstBackoff: 100ms
|
||||||
maxBackoff: 500ms
|
maxBackoff: 1000ms
|
||||||
factor: 2
|
factor: 2
|
||||||
basedOnPreviousValue: false
|
basedOnPreviousValue: false
|
||||||
- name: AddResponseHeader
|
- name: AddResponseHeader
|
||||||
@@ -60,10 +61,6 @@ spring:
|
|||||||
args:
|
args:
|
||||||
name: X-Frame-Options
|
name: X-Frame-Options
|
||||||
value: DENY
|
value: DENY
|
||||||
- name: AddResponseHeader
|
|
||||||
args:
|
|
||||||
name: X-XSS-Protection
|
|
||||||
value: 1; mode=block
|
|
||||||
- name: AddResponseHeader
|
- name: AddResponseHeader
|
||||||
args:
|
args:
|
||||||
name: Referrer-Policy
|
name: Referrer-Policy
|
||||||
@@ -100,6 +97,15 @@ spring:
|
|||||||
- Path=/api/ping/**
|
- Path=/api/ping/**
|
||||||
filters:
|
filters:
|
||||||
- StripPrefix=1
|
- 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) ---
|
# --- Entries-Service-Integration (MP-27) ---
|
||||||
@@ -215,6 +221,8 @@ resilience4j:
|
|||||||
instances:
|
instances:
|
||||||
defaultCircuitBreaker:
|
defaultCircuitBreaker:
|
||||||
baseConfig: default
|
baseConfig: default
|
||||||
|
pingCircuitBreaker:
|
||||||
|
baseConfig: default
|
||||||
membersCircuitBreaker:
|
membersCircuitBreaker:
|
||||||
baseConfig: default
|
baseConfig: default
|
||||||
slidingWindowSize: 50
|
slidingWindowSize: 50
|
||||||
@@ -248,8 +256,8 @@ management:
|
|||||||
allow-credentials: true
|
allow-credentials: true
|
||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: when_authorized
|
||||||
show-components: always
|
show-components: when_authorized
|
||||||
probes:
|
probes:
|
||||||
enabled: true
|
enabled: true
|
||||||
metrics:
|
metrics:
|
||||||
@@ -304,21 +312,10 @@ management:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
org.springframework.cloud.gateway: INFO
|
org.springframework.cloud.gateway: INFO
|
||||||
org.springframework.cloud.loadbalancer: DEBUG
|
org.springframework.cloud.loadbalancer: INFO
|
||||||
org.springframework.cloud.consul: INFO
|
org.springframework.cloud.consul: INFO
|
||||||
at.mocode.infrastructure.gateway: DEBUG
|
at.mocode.infrastructure.gateway: INFO
|
||||||
io.github.resilience4j: INFO
|
io.github.resilience4j: INFO
|
||||||
reactor.netty.http.client: INFO
|
reactor.netty.http.client: INFO
|
||||||
org.springframework.security: WARN
|
|
||||||
org.springframework.web: INFO
|
# (Redis-Konfiguration wurde in den bestehenden spring.data.redis-Block oben integriert)
|
||||||
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
|
|
||||||
|
|||||||
+2
-4
@@ -27,10 +27,8 @@ import org.springframework.test.context.ActiveProfiles
|
|||||||
"gateway.security.jwt.enabled=false",
|
"gateway.security.jwt.enabled=false",
|
||||||
// Reaktiven Web-Anwendungstyp verwenden
|
// Reaktiven Web-Anwendungstyp verwenden
|
||||||
"spring.main.web-application-type=reactive",
|
"spring.main.web-application-type=reactive",
|
||||||
// Gateway Discovery deaktivieren
|
// Gateway Discovery deaktivieren (korrekte Property)
|
||||||
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
|
"spring.cloud.gateway.discovery.locator.enabled=false",
|
||||||
// Actuator Security deaktivieren
|
|
||||||
"management.security.enabled=false",
|
|
||||||
// Zufälligen Port setzen
|
// Zufälligen Port setzen
|
||||||
"server.port=0"
|
"server.port=0"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openap
|
|||||||
# --- Spring Cloud ---
|
# --- Spring Cloud ---
|
||||||
spring-cloud-starter-gateway-server-webflux = { module = "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux" }
|
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-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 ---
|
# --- Database & Persistence ---
|
||||||
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
||||||
@@ -318,8 +320,34 @@ spring-cloud-gateway = [
|
|||||||
"spring-cloud-starter-consul-discovery"
|
"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 ---
|
# --- NEW BUNDLES ---
|
||||||
|
|
||||||
|
# Redis für Gateway (Rate Limiting, einfache Konfiguration)
|
||||||
|
gateway-redis = [
|
||||||
|
"spring-boot-starter-data-redis"
|
||||||
|
]
|
||||||
|
|
||||||
# Ktor Server bundles
|
# Ktor Server bundles
|
||||||
ktor-server-common = [
|
ktor-server-common = [
|
||||||
"ktor-server-core",
|
"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" }
|
foojayResolver = { id = "org.gradle.toolchains.foojay-resolver-convention", version.ref = "foojayResolver" }
|
||||||
|
|
||||||
# Dokka plugin
|
# 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" }
|
dokka = { id = "org.jetbrains.dokka", version = "2.1.0" }
|
||||||
|
|||||||
Reference in New Issue
Block a user