feat: webhooks support
This commit is contained in:
parent
efa964406a
commit
f22b3953b1
|
|
@ -10,6 +10,7 @@ require (
|
||||||
connectrpc.com/connect v1.19.1
|
connectrpc.com/connect v1.19.1
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/MicahParks/keyfunc/v3 v3.7.0
|
github.com/MicahParks/keyfunc/v3 v3.7.0
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1
|
||||||
github.com/alexedwards/argon2id v1.0.0
|
github.com/alexedwards/argon2id v1.0.0
|
||||||
github.com/bufbuild/buf v1.61.0
|
github.com/bufbuild/buf v1.61.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
|
@ -55,6 +56,7 @@ require (
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
github.com/MicahParks/jwkset v0.11.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/PaesslerAG/gval v1.0.0 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect
|
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3
|
||||||
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||||
|
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ type Action struct {
|
||||||
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
|
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
|
||||||
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
|
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
|
||||||
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
|
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
|
||||||
|
ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"`
|
||||||
Triggers []string `koanf:"triggers"`
|
Triggers []string `koanf:"triggers"`
|
||||||
MaxConcurrent int `koanf:"maxConcurrent"`
|
MaxConcurrent int `koanf:"maxConcurrent"`
|
||||||
MaxRate []RateSpec `koanf:"maxRate"`
|
MaxRate []RateSpec `koanf:"maxRate"`
|
||||||
Arguments []ActionArgument `koanf:"arguments"`
|
Arguments []ActionArgument `koanf:"arguments"`
|
||||||
PopupOnStart string `koanf:"popupOnStart"`
|
PopupOnStart string `koanf:"popupOnStart"`
|
||||||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||||
|
EnabledExpression string `koanf:"enabledExpression"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionArgument objects appear on Actions.
|
// ActionArgument objects appear on Actions.
|
||||||
|
|
@ -55,6 +57,18 @@ type RateSpec struct {
|
||||||
Duration string `koanf:"duration"`
|
Duration string `koanf:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookConfig defines configuration for generic webhook triggers.
|
||||||
|
type WebhookConfig struct {
|
||||||
|
Secret string `koanf:"secret"` // Optional: secret for signature verification
|
||||||
|
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
|
||||||
|
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
|
||||||
|
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
|
||||||
|
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
|
||||||
|
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
|
||||||
|
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
|
||||||
|
Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr")
|
||||||
|
}
|
||||||
|
|
||||||
// Entity represents a "thing" that can have multiple actions associated with it.
|
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||||
// for example, a media player with a start and stop action.
|
// for example, a media player with a start and stop action.
|
||||||
type EntityFile struct {
|
type EntityFile struct {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
|
"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
|
||||||
config "github.com/OliveTin/OliveTin/internal/config"
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
"github.com/OliveTin/OliveTin/internal/executor"
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/webhooks"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
|
||||||
|
|
||||||
mux.HandleFunc("/readyz", handleReadyz)
|
mux.HandleFunc("/readyz", handleReadyz)
|
||||||
|
|
||||||
|
webhookHandler := webhooks.NewWebhookHandler(cfg, ex)
|
||||||
|
mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook)
|
||||||
|
mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook)
|
||||||
|
|
||||||
webuiServer := NewWebUIServer(cfg)
|
webuiServer := NewWebUIServer(cfg)
|
||||||
|
|
||||||
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
|
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthVerifier struct {
|
||||||
|
config config.WebhookConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier {
|
||||||
|
return &AuthVerifier{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool {
|
||||||
|
switch v.config.AuthType {
|
||||||
|
case "hmac-sha256":
|
||||||
|
return v.verifyHMAC256(r, payload)
|
||||||
|
case "hmac-sha1":
|
||||||
|
return v.verifyHMAC1(r, payload)
|
||||||
|
case "bearer":
|
||||||
|
return v.verifyBearer(r)
|
||||||
|
case "basic":
|
||||||
|
return v.verifyBasic(r)
|
||||||
|
case "none", "":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"authType": v.config.AuthType,
|
||||||
|
}).Warnf("Unknown auth type, rejecting")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("HMAC-SHA256 auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
headerName := v.config.AuthHeader
|
||||||
|
if headerName == "" {
|
||||||
|
headerName = "X-Webhook-Signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := r.Header.Get(headerName)
|
||||||
|
if signature == "" {
|
||||||
|
log.Debugf("Missing signature header: %s", headerName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSig := strings.TrimPrefix(signature, "sha256=")
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(v.config.Secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("HMAC-SHA1 auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
headerName := v.config.AuthHeader
|
||||||
|
if headerName == "" {
|
||||||
|
headerName = "X-Webhook-Signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := r.Header.Get(headerName)
|
||||||
|
if signature == "" {
|
||||||
|
log.Debugf("Missing signature header: %s", headerName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSig := strings.TrimPrefix(signature, "sha1=")
|
||||||
|
|
||||||
|
mac := hmac.New(sha1.New, []byte(v.config.Secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBearer(r *http.Request) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("Bearer auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
log.Debugf("Missing or invalid Bearer token")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
return token == v.config.Secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBasic(r *http.Request) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("Basic auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
log.Debugf("Missing Basic auth header")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(v.config.Secret, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return username == parts[0] && password == parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return password == v.config.Secret
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyGitHubTemplate applies GitHub-specific template configurations
|
||||||
|
// This allows users to use simple template names instead of configuring everything manually
|
||||||
|
func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) {
|
||||||
|
switch template {
|
||||||
|
case "github-push":
|
||||||
|
applyGitHubPushTemplate(cfg)
|
||||||
|
case "github-pr", "github-pull-request":
|
||||||
|
applyGitHubPRTemplate(cfg)
|
||||||
|
case "github-release":
|
||||||
|
applyGitHubReleaseTemplate(cfg)
|
||||||
|
case "github-workflow":
|
||||||
|
applyGitHubWorkflowTemplate(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
|
||||||
|
if cfg.AuthHeader == "" {
|
||||||
|
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
if cfg.AuthType == "" {
|
||||||
|
cfg.AuthType = "hmac-sha256"
|
||||||
|
}
|
||||||
|
if len(cfg.MatchHeaders) == 0 {
|
||||||
|
cfg.MatchHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
|
||||||
|
cfg.MatchHeaders["X-GitHub-Event"] = "push"
|
||||||
|
}
|
||||||
|
if len(cfg.Extract) == 0 {
|
||||||
|
cfg.Extract = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_repository"]; !exists {
|
||||||
|
cfg.Extract["git_repository"] = "$.repository.full_name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_ref"]; !exists {
|
||||||
|
cfg.Extract["git_ref"] = "$.ref"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_commit"]; !exists {
|
||||||
|
cfg.Extract["git_commit"] = "$.head_commit.id"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_branch"]; !exists {
|
||||||
|
cfg.Extract["git_branch"] = "$.ref"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_message"]; !exists {
|
||||||
|
cfg.Extract["git_message"] = "$.head_commit.message"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_author"]; !exists {
|
||||||
|
cfg.Extract["git_author"] = "$.head_commit.author.name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
|
||||||
|
if cfg.AuthHeader == "" {
|
||||||
|
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
if cfg.AuthType == "" {
|
||||||
|
cfg.AuthType = "hmac-sha256"
|
||||||
|
}
|
||||||
|
if len(cfg.MatchHeaders) == 0 {
|
||||||
|
cfg.MatchHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
|
||||||
|
cfg.MatchHeaders["X-GitHub-Event"] = "pull_request"
|
||||||
|
}
|
||||||
|
if len(cfg.Extract) == 0 {
|
||||||
|
cfg.Extract = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_number"]; !exists {
|
||||||
|
cfg.Extract["pr_number"] = "$.number"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_title"]; !exists {
|
||||||
|
cfg.Extract["pr_title"] = "$.pull_request.title"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_author"]; !exists {
|
||||||
|
cfg.Extract["pr_author"] = "$.pull_request.user.login"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_action"]; !exists {
|
||||||
|
cfg.Extract["pr_action"] = "$.action"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_repository"]; !exists {
|
||||||
|
cfg.Extract["git_repository"] = "$.repository.full_name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_state"]; !exists {
|
||||||
|
cfg.Extract["pr_state"] = "$.pull_request.state"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["pr_head_sha"]; !exists {
|
||||||
|
cfg.Extract["pr_head_sha"] = "$.pull_request.head.sha"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
|
||||||
|
if cfg.AuthHeader == "" {
|
||||||
|
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
if cfg.AuthType == "" {
|
||||||
|
cfg.AuthType = "hmac-sha256"
|
||||||
|
}
|
||||||
|
if len(cfg.MatchHeaders) == 0 {
|
||||||
|
cfg.MatchHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
|
||||||
|
cfg.MatchHeaders["X-GitHub-Event"] = "release"
|
||||||
|
}
|
||||||
|
if len(cfg.Extract) == 0 {
|
||||||
|
cfg.Extract = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["release_action"]; !exists {
|
||||||
|
cfg.Extract["release_action"] = "$.action"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["release_tag"]; !exists {
|
||||||
|
cfg.Extract["release_tag"] = "$.release.tag_name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["release_name"]; !exists {
|
||||||
|
cfg.Extract["release_name"] = "$.release.name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_repository"]; !exists {
|
||||||
|
cfg.Extract["git_repository"] = "$.repository.full_name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["release_author"]; !exists {
|
||||||
|
cfg.Extract["release_author"] = "$.release.author.login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
|
||||||
|
if cfg.AuthHeader == "" {
|
||||||
|
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
if cfg.AuthType == "" {
|
||||||
|
cfg.AuthType = "hmac-sha256"
|
||||||
|
}
|
||||||
|
if len(cfg.MatchHeaders) == 0 {
|
||||||
|
cfg.MatchHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
|
||||||
|
cfg.MatchHeaders["X-GitHub-Event"] = "workflow_run"
|
||||||
|
}
|
||||||
|
if len(cfg.Extract) == 0 {
|
||||||
|
cfg.Extract = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["workflow_name"]; !exists {
|
||||||
|
cfg.Extract["workflow_name"] = "$.workflow_run.name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["workflow_status"]; !exists {
|
||||||
|
cfg.Extract["workflow_status"] = "$.workflow_run.status"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["workflow_conclusion"]; !exists {
|
||||||
|
cfg.Extract["workflow_conclusion"] = "$.workflow_run.conclusion"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_repository"]; !exists {
|
||||||
|
cfg.Extract["git_repository"] = "$.repository.full_name"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_commit"]; !exists {
|
||||||
|
cfg.Extract["git_commit"] = "$.workflow_run.head_sha"
|
||||||
|
}
|
||||||
|
if _, exists := cfg.Extract["git_branch"]; !exists {
|
||||||
|
cfg.Extract["git_branch"] = "$.workflow_run.head_branch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/auth"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionWebhookConfig struct {
|
||||||
|
Action *config.Action
|
||||||
|
Config config.WebhookConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
executor *executor.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler {
|
||||||
|
return &WebhookHandler{
|
||||||
|
cfg: cfg,
|
||||||
|
executor: ex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := int64(1024 * 1024)
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize))
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Failed to read webhook payload")
|
||||||
|
http.Error(w, "Failed to read payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyData interface{}
|
||||||
|
if err := json.Unmarshal(payload, &bodyData); err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Webhook payload is not valid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingActions := h.findMatchingActions(r, payload, bodyData)
|
||||||
|
|
||||||
|
if len(matchingActions) == 0 {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
}).Debugf("No matching webhook actions found")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for _, actionConfig := range matchingActions {
|
||||||
|
if h.processWebhook(actionConfig, r, payload) {
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"matched": len(matchingActions),
|
||||||
|
"processed": processed,
|
||||||
|
}).Infof("Webhook processed")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte, bodyData interface{}) []ActionWebhookConfig {
|
||||||
|
var matches []ActionWebhookConfig
|
||||||
|
|
||||||
|
for _, action := range h.cfg.Actions {
|
||||||
|
for _, webhookConfig := range action.ExecOnWebhook {
|
||||||
|
webhookConfigCopy := webhookConfig
|
||||||
|
|
||||||
|
if webhookConfigCopy.Template != "" {
|
||||||
|
ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template)
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := NewWebhookMatcher(webhookConfigCopy, r, payload, bodyData)
|
||||||
|
|
||||||
|
if matcher.Matches() {
|
||||||
|
matches = append(matches, ActionWebhookConfig{
|
||||||
|
Action: action,
|
||||||
|
Config: webhookConfigCopy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool {
|
||||||
|
verifier := NewAuthVerifier(actionConfig.Config)
|
||||||
|
if !verifier.Verify(r, payload) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": actionConfig.Action.Title,
|
||||||
|
"authType": actionConfig.Config.AuthType,
|
||||||
|
}).Warnf("Webhook authentication failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyData interface{}
|
||||||
|
json.Unmarshal(payload, &bodyData)
|
||||||
|
|
||||||
|
matcher := NewWebhookMatcher(actionConfig.Config, r, payload, bodyData)
|
||||||
|
|
||||||
|
args, err := matcher.ExtractArguments()
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": actionConfig.Action.Title,
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Failed to extract webhook arguments")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
h.executeAction(actionConfig.Action, args)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) {
|
||||||
|
binding := h.executor.FindBindingWithNoEntity(action)
|
||||||
|
if binding == nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": action.Title,
|
||||||
|
}).Warnf("Action binding not found, skipping execution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &executor.ExecutionRequest{
|
||||||
|
Binding: binding,
|
||||||
|
Cfg: h.cfg,
|
||||||
|
Tags: []string{"webhook"},
|
||||||
|
Arguments: args,
|
||||||
|
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.executor.ExecRequest(req)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/PaesslerAG/jsonpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JSONMatcher struct {
|
||||||
|
payload interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal(payload, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &JSONMatcher{payload: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) {
|
||||||
|
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
valueStr := fmt.Sprintf("%v", value)
|
||||||
|
return valueStr == expectedValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
|
||||||
|
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) GetPayload() interface{} {
|
||||||
|
return m.payload
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebhookMatcher struct {
|
||||||
|
config config.WebhookConfig
|
||||||
|
req *http.Request
|
||||||
|
body interface{}
|
||||||
|
bodyBytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte, body interface{}) *WebhookMatcher {
|
||||||
|
return &WebhookMatcher{
|
||||||
|
config: cfg,
|
||||||
|
req: r,
|
||||||
|
body: body,
|
||||||
|
bodyBytes: bodyBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) Matches() bool {
|
||||||
|
if !m.matchHeaders() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.matchQuery() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.matchPath() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchHeaders() bool {
|
||||||
|
if len(m.config.MatchHeaders) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expectedValue := range m.config.MatchHeaders {
|
||||||
|
actualValue := m.req.Header.Get(key)
|
||||||
|
if !m.compareValues(actualValue, expectedValue) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"header": key,
|
||||||
|
"expected": expectedValue,
|
||||||
|
"actual": actualValue,
|
||||||
|
}).Debugf("Header mismatch")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchQuery() bool {
|
||||||
|
if len(m.config.MatchQuery) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
query := m.req.URL.Query()
|
||||||
|
for key, expectedValue := range m.config.MatchQuery {
|
||||||
|
actualValue := query.Get(key)
|
||||||
|
if !m.compareValues(actualValue, expectedValue) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"query": key,
|
||||||
|
"expected": expectedValue,
|
||||||
|
"actual": actualValue,
|
||||||
|
}).Debugf("Query parameter mismatch")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchPath() bool {
|
||||||
|
if m.config.MatchPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(m.config.MatchPath, "=", 2)
|
||||||
|
jsonPath := parts[0]
|
||||||
|
expectedValue := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
expectedValue = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to create JSON matcher")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedValue == "" {
|
||||||
|
_, err := matcher.ExtractValue(jsonPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := matcher.MatchPath(jsonPath, expectedValue)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"jsonPath": jsonPath,
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to match JSONPath")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) compareValues(actual, expected string) bool {
|
||||||
|
if strings.HasPrefix(expected, "regex:") {
|
||||||
|
pattern := strings.TrimPrefix(expected, "regex:")
|
||||||
|
matched, err := regexp.MatchString(pattern, actual)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"pattern": pattern,
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Invalid regex pattern")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
return actual == expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
|
||||||
|
args := make(map[string]string)
|
||||||
|
|
||||||
|
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for argName, jsonPath := range m.config.Extract {
|
||||||
|
value, err := matcher.ExtractValue(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"argName": argName,
|
||||||
|
"jsonPath": jsonPath,
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to extract value")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args[argName] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
args["webhook_method"] = m.req.Method
|
||||||
|
args["webhook_path"] = m.req.URL.Path
|
||||||
|
args["webhook_query"] = m.req.URL.RawQuery
|
||||||
|
|
||||||
|
for key, values := range m.req.Header {
|
||||||
|
if len(values) > 0 {
|
||||||
|
args["webhook_header_"+strings.ToLower(key)] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue