Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/kotlin/cz/geek/spdreport/SpdreportApplication.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package cz.geek.spdreport

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class SpdreportApplication

fun main(args: Array<String>) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/cz/geek/spdreport/auth/WebSecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class WebSecurityConfig(
authorize("/settings/**", authenticated)
authorize(anyRequest, permitAll)
}
csrf {
ignoringRequestMatchers("/webhooks/pagerduty/incident")
}
logout {
logoutSuccessUrl = "/"
logoutRequestMatcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/logout")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.google.cloud.datastore.DatastoreOptions
import com.googlecode.objectify.ObjectifyFactory
import com.googlecode.objectify.ObjectifyService
import cz.geek.spdreport.model.ObjectifyOAuth2AuthorizedClient
import cz.geek.spdreport.model.ProcessedWebhookEvent
import cz.geek.spdreport.model.Settings
import cz.geek.spdreport.web.ObjectifyWebFilter
import mu.KotlinLogging
Expand Down Expand Up @@ -38,6 +39,7 @@ class ObjectifyConfiguration(
initObjectifyService()
ObjectifyService.register(ObjectifyOAuth2AuthorizedClient::class.java)
ObjectifyService.register(Settings::class.java)
ObjectifyService.register(ProcessedWebhookEvent::class.java)
}

private fun initObjectifyService() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cz.geek.spdreport.datastore

import com.googlecode.objectify.ObjectifyService.ofy
import com.googlecode.objectify.Work
import cz.geek.spdreport.model.ProcessedWebhookEvent
import org.springframework.stereotype.Service
import java.time.Instant

@Service
class ProcessedWebhookEventRepository {

/**
* Atomically records [id] as a processed webhook event.
*
* @return `true` if the id was newly recorded (first time seen), `false` if an event with the
* same id had already been recorded (duplicate). The check-and-save runs in a transaction so
* concurrent retries of the same delivery (e.g. a cold-start burst) yield exactly one `true`.
*/
fun recordIfNew(id: String): Boolean =
ofy().transactNew(Work {
if (load(id) != null) {
false
} else {
ofy().save().entities(ProcessedWebhookEvent(id, Instant.now())).now()
true
}
})

internal fun load(id: String): ProcessedWebhookEvent? =
ofy().load().type(ProcessedWebhookEvent::class.java).id(id).now()
}
15 changes: 15 additions & 0 deletions src/main/kotlin/cz/geek/spdreport/model/ProcessedWebhookEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cz.geek.spdreport.model

import com.googlecode.objectify.annotation.Entity
import com.googlecode.objectify.annotation.Id
import java.time.Instant

@Entity
data class ProcessedWebhookEvent(
@Id
var id: String,
var receivedAt: Instant = Instant.EPOCH,
) {
constructor() : this("")
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cz.geek.spdreport.pagerduty

import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component

@Component
class IncidentWebhookFilter(
properties: PagerDutyProperties,
) {

private val watchedPolicyId: String = properties.escalationPolicy.watchId
private val log = LoggerFactory.getLogger(javaClass)

fun match(payload: IncidentWebhookPayload): MatchedIncident? {
val data = payload.event?.data
if (data == null) {
log.debug("Skipping webhook: missing event.data")
return null
}
if (data.escalationPolicy?.id != watchedPolicyId) {
log.debug(
"Skipping webhook: escalation policy {} does not match watched {}",
data.escalationPolicy?.id,
watchedPolicyId,
)
return null
}
val incident = data.incident
if (incident == null) {
log.debug("Skipping webhook: missing incident data")
return null
}
if (incident.summary == null) {
log.debug("Skipping webhook: missing incident summary")
return null
}
if (incident.htmlUrl == null) {
log.debug("Skipping webhook: missing incident htmlUrl")
return null
}
log.info("Matched incident for watched escalation policy {}: {}", watchedPolicyId, incident.htmlUrl)
return MatchedIncident(incident.summary, incident.htmlUrl)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cz.geek.spdreport.pagerduty

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class IncidentWebhookPayload(
val event: IncidentWebhookEvent?,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class IncidentWebhookEvent(
val id: String?,
@JsonProperty("event_type") val eventType: String?,
val data: IncidentWebhookData?,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class IncidentWebhookData(
val incident: PagerDutyReference?,
val user: PagerDutyReference?,
@JsonProperty("escalation_policy") val escalationPolicy: PagerDutyReference?,
val message: String?,
val state: String?,
val type: String?,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class PagerDutyReference(
val id: String?,
val type: String?,
val summary: String?,
val self: String?,
@JsonProperty("html_url") val htmlUrl: String?,
)

data class MatchedIncident(
val title: String,
val htmlUrl: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cz.geek.spdreport.pagerduty

import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import mu.KotlinLogging
import org.springframework.core.MethodParameter
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer
import org.springframework.web.server.ResponseStatusException

private const val SIGNATURE_HEADER = "X-PagerDuty-Signature"

private val log = KotlinLogging.logger {}

@Component
class IncidentWebhookPayloadArgumentResolver(
private val verifier: PagerDutySignatureVerifier,
private val objectMapper: ObjectMapper,
) : HandlerMethodArgumentResolver {

override fun supportsParameter(parameter: MethodParameter): Boolean =
IncidentWebhookPayload::class.java == parameter.parameterType

override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): IncidentWebhookPayload {
val request = requireNotNull(webRequest.getNativeRequest(HttpServletRequest::class.java)) {
"HttpServletRequest is not available"
}
val rawBody: ByteArray = request.inputStream.readAllBytes()
val signature: String? = request.getHeader(SIGNATURE_HEADER)
if (!verifier.verify(rawBody, signature)) {
log.warn { "PagerDuty webhook signature verification failed" }
throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
}
return try {
objectMapper.readValue(rawBody, IncidentWebhookPayload::class.java)
} catch (e: JacksonException) {
log.warn(e) { "PagerDuty webhook body could not be parsed as JSON" }
IncidentWebhookPayload(event = null)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cz.geek.spdreport.pagerduty

import cz.geek.spdreport.auth.PagerDutyPrincipal
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor
Expand All @@ -19,10 +18,11 @@ import java.time.format.DateTimeFormatterBuilder
@Component
class PagerDutyClient(
clientManager: OAuth2AuthorizedClientManager,
@Value("\${pagerduty.api.url:https://api.pagerduty.com}")
private val apiUrl: String,
properties: PagerDutyProperties,
) {

private val apiUrl: String = properties.api.url

private val acceptHeader = "Accept" to "application/vnd.pagerduty+json;version=2"

private val format = DateTimeFormatterBuilder()
Expand Down
14 changes: 14 additions & 0 deletions src/main/kotlin/cz/geek/spdreport/pagerduty/PagerDutyProperties.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package cz.geek.spdreport.pagerduty

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "pagerduty")
data class PagerDutyProperties(
val api: Api = Api(),
val webhook: Webhook = Webhook(),
val escalationPolicy: EscalationPolicy = EscalationPolicy(),
) {
data class Api(val url: String = "https://api.pagerduty.com")
data class Webhook(val secret: String = "")
data class EscalationPolicy(val watchId: String = "")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cz.geek.spdreport.pagerduty

import mu.KotlinLogging
import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.util.HexFormat
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

private const val ALGORITHM = "HmacSHA256"
private const val SCHEME_PREFIX = "v1="

private val logger = KotlinLogging.logger {}

@Component
class PagerDutySignatureVerifier(
properties: PagerDutyProperties,
) {

private val keySpec: SecretKeySpec = SecretKeySpec(properties.webhook.secret.toByteArray(Charsets.UTF_8), ALGORITHM)

fun verify(body: ByteArray, signatureHeader: String?): Boolean {
val keySpec = keySpec ?: return false
if (signatureHeader.isNullOrBlank()) return false
val expected = computeMac(keySpec, body)
return signatureHeader.split(',')
.map { it.trim() }
.filter { it.startsWith(SCHEME_PREFIX) }
.map { it.removePrefix(SCHEME_PREFIX) }
.mapNotNull { runCatching { HexFormat.of().parseHex(it) }.getOrNull() }
.fold(false) { acc, candidate -> MessageDigest.isEqual(candidate, expected) or acc }
}

private fun computeMac(keySpec: SecretKeySpec, body: ByteArray): ByteArray {
val mac = Mac.getInstance(ALGORITHM)
mac.init(keySpec)
return mac.doFinal(body)
}
}
15 changes: 15 additions & 0 deletions src/main/kotlin/cz/geek/spdreport/pagerduty/WebhookWebMvcConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cz.geek.spdreport.pagerduty

import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebhookWebMvcConfig(
private val resolver: IncidentWebhookPayloadArgumentResolver,
) : WebMvcConfigurer {

override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(resolver)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cz.geek.spdreport.service

import cz.geek.spdreport.datastore.ProcessedWebhookEventRepository
import cz.geek.spdreport.pagerduty.IncidentWebhookFilter
import cz.geek.spdreport.pagerduty.IncidentWebhookPayload
import mu.KotlinLogging
import org.springframework.stereotype.Service

internal const val WATCHED_EVENT_TYPE = "incident.responder.added"
internal const val PING_EVENT_TYPE = "pagey.ping"

internal const val MAX_EVENT_ID_LENGTH = 1500

private val log = KotlinLogging.logger {}

private fun String?.forLog(): String = this?.replace(Regex("\\p{Cntrl}"), "_") ?: "null"

@Service
class PagerDutyWebhookService(
private val filter: IncidentWebhookFilter,
private val notifier: SlackNotifier,
private val processedEvents: ProcessedWebhookEventRepository,
) {

fun handle(payload: IncidentWebhookPayload) {
val event = payload.event
if (event == null) {
log.warn { "PagerDuty webhook ignored: payload has no event" }
return
}
val eventId = event.id
val incidentId = event.data?.incident?.id
val eventIdLog = eventId.forLog()
val incidentIdLog = incidentId.forLog()
if (event.eventType == PING_EVENT_TYPE) {
notifier.sendPing(eventId)
log.info { "PagerDuty webhook ping received and notified: event=$eventIdLog" }
return
}
if (event.eventType != WATCHED_EVENT_TYPE) {
log.info { "PagerDuty webhook ignored: event=$eventIdLog incident=$incidentIdLog eventType=${event.eventType.forLog()} not $WATCHED_EVENT_TYPE" }
return
}
val matched = filter.match(payload)
if (matched == null) {
log.info { "PagerDuty webhook ignored: event=$eventIdLog incident=$incidentIdLog did not match watched escalation policy" }
return
}

if (eventId.isNullOrBlank() || eventId.length > MAX_EVENT_ID_LENGTH) {
log.warn { "PagerDuty webhook cannot be deduplicated: invalid event id (event=$eventIdLog incident=$incidentIdLog); notifying anyway" }
} else if (!processedEvents.recordIfNew(eventId)) {
log.info { "PagerDuty webhook duplicate dropped: event=$eventIdLog incident=$incidentIdLog" }
return
}
notifier.send(matched)
log.info { "PagerDuty webhook notified: event=$eventIdLog incident=$incidentIdLog title='${matched.title}'" }
}
}
Loading
Loading