From 960f41957afab7bcbba2d3eabe7ab589f49fb3db Mon Sep 17 00:00:00 2001 From: AdamShannag Date: Mon, 21 Apr 2025 14:47:22 +0300 Subject: [PATCH] feat: support reusable templates --- Dockerfile | 1 + README.md | 179 ++++++++++++--------------- cmd/hookah/main.go | 38 +++++- deploy/config.json | 24 +--- deploy/docker-compose.yaml | 2 + deploy/templates/discord.tmpl | 19 +++ deploy/templates/discord_simple.tmpl | 5 + internal/auth/auth.go | 44 +++++++ internal/auth/auth_test.go | 44 +++++++ internal/config/config.go | 46 +++++++ internal/config/config_test.go | 76 ++++++++++++ internal/flow/flow.go | 48 +++++++ internal/flow/flow_test.go | 95 ++++++++++++++ internal/render/funcs.go | 53 ++++++++ internal/render/render.go | 10 +- internal/render/render_test.go | 29 ----- internal/server/routes.go | 4 +- internal/server/routes_test.go | 40 +++--- internal/server/server.go | 6 +- internal/server/util.go | 62 +++++----- internal/types/auth.go | 7 -- internal/types/config.go | 66 ---------- internal/types/template_config.go | 15 --- internal/types/types.go | 21 ++++ 24 files changed, 635 insertions(+), 299 deletions(-) create mode 100644 deploy/templates/discord.tmpl create mode 100644 deploy/templates/discord_simple.tmpl create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/flow/flow.go create mode 100644 internal/flow/flow_test.go create mode 100644 internal/render/funcs.go delete mode 100644 internal/types/auth.go delete mode 100644 internal/types/config.go delete mode 100644 internal/types/template_config.go create mode 100644 internal/types/types.go diff --git a/Dockerfile b/Dockerfile index 4d41dd9..e157245 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ WORKDIR /app ENV PORT=3000 ENV CONFIG_PATH=/etc/hookah/config.json +ENV TEMPLATES_PATH=/etc/hookah/templates COPY --from=build /app/hookah /app/hookah diff --git a/README.md b/README.md index 769e9de..553131c 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,14 @@ between webhook sources (like GitLab, GitHub, etc.) and target destinations (such as Discord), forwarding events only when they match predefined conditions. ---- - -Roadmap ------- - -- [ ] Add example configuration files for each `auth.flow`: - - [ ] `none` - - [ ] `plain secret` - - [ ] `basic auth` - - [ ] `gitlab` - - [ ] `github` -- [ ] Implement utility helper functions for the template engine: - - [ ] Get current date - - [ ] Format dates - - [ ] Add appropriate suffixes (`-d` or `-ed`) to words - Features ------ - **Webhook Receiver:** Accepts incoming webhooks from various platforms. - **Rule Engine:** Applies filters based on request headers/url query params and body content. - **Conditional Forwarding:** Sends a message to a target webhook only if the rules match. +- **Reusable Templates:** Define multiple templates and reuse them across different configurations and webhook + scenarios. - **Template Support:** Allows dynamic message generation using data from the incoming webhook payload. - **Lightweight & Extensible:** Simple design with future support for multiple rules, formats, and targets. @@ -36,6 +22,7 @@ The server requires the following environment variables: - `PORT`: the port on which the server should listen (e.g., `8080`) - `CONFIG_PATH`: path to the JSON config file defining receivers and rules +- `TEMPALTES_PATH`: path to the templates directory that contains all templates Endpoint Structure ------ @@ -79,24 +66,7 @@ and how they work. { "name": "discord", "endpoint_key": "x-discord-url", - "body": { - "username": "{{ .user.username }}", - "avatar_url": "{{ .user.avatar_url }}", - "embeds": [ - { - "author": { - "name": "{{ .user.username }}", - "icon_url": "{{ .user.avatar_url }}" - }, - "title": "{{ .object_attributes.title }}", - "description": "{{ .user.name }} {{ .object_attributes.action }} a merge request in [{{ .project.path_with_namespace }}]({{ .project.web_url }})", - "color": 15258703, - "footer": { - "text": "Woah! So cool! :smirk:" - } - } - ] - } + "body": "discord.tmpl" } ] } @@ -112,6 +82,24 @@ Hookah uses a JSON array of receiver configurations. Each configuration defines: - **Authentication:** rules for verifying webhook authenticity (`auth` block). - **Event routing rules:** Defines how to extract event types and which hooks to trigger when conditions are met. +### Multiple Receivers + +The configuration supports **multiple receivers** — each with its own auth rules, event types, and hook logic. This +enables you to route webhooks from different sources independently: + +```json +[ + { + "receiver": "gitlab", + ... + }, + { + "receiver": "github", + ... + } +] +``` + ### Event Type Resolution ```json @@ -126,7 +114,23 @@ These two keys tell Hookah **where to look** for the event type: Hookah will match the extracted event type against the entries in the `events` array. ---- +### Authentication (`auth`) + +Each receiver must define an `auth` block to control who can send webhooks. The supported flows are: + +| Flow | Description | +|----------------|--------------------------------------------------------------------------------------------------------------------------------| +| `none` | No authentication; accepts all requests. | +| `plain secret` | Matches the value in the request header against the configured `secret`. | +| `basic auth` | Verifies username and password in basic auth header matches the `secret`, in the format `username:password`. | +| `gitlab` | Compares the configured `secret` with the GitLab token header using constant-time comparison (SHA-512). | +| `github` | Verifies HMAC SHA-256 signature in a header (e.g. X-Hub-Signature-256 or custom) using the configured secret and request body. | + +**Fields:** + +- `flow`: One of `gitlab`, `github`, `basic auth`, `plain secret`, or `none`. +- `header_secret_key`: The header to extract the token from (e.g., `X-Gitlab-Token` or `X-Custom-Token`). +- `secret`: The expected secret value (or in `basic auth`, the `username:password` pair). ### Events & Conditional Hooks @@ -154,7 +158,23 @@ Each event: You can define **multiple hooks** per event to notify different targets like Discord, Slack, etc. All matching hooks will be triggered concurrently when conditions are satisfied. ---- +### Condition Syntax + +Conditions use a simple templated language: + +- `{Header.X-Foo}` refers to a request header/url query param +- `{Body.foo.bar}` refers to a nested body field +- `{Body.foo[].bar}` supports iterating over arrays, should be used with the {in} operator + +Example: + +```json +"{Header.x-gitlab-label} {in} {Body.object_attributes.labels[].title}" +``` + +This checks whether the value of `x-gitlab-label` header or url query param exists in any of the `title` fields in the +incoming body +array `object_attributes.labels`. ### Hook Structure & Templating @@ -164,81 +184,45 @@ Each `hook` can look like this: { "name": "discord", "endpoint_key": "x-discord-url", - "body": { - "username": "{{ .user.name }}", - "content": "Merge request received by {{ .project.name }}" - } + "body": "template_file_name.some_extension" } ``` - `endpoint_key`: Specifies the request header key, or the url query param that contains the **target webhook URL**. this will be used to make the webhook request for the target hook. -- `body`: The payload to send to the target. **All fields Support Go-style templating.** +- `body`: The name of the template file to use, from the templates' directory. -You can reference values from the original request body using `{{ .some.path }}`. -For example, `.user.name` is available if GitLab includes `user.name` in its payload. +> Note: After rendering, the template content must result in a well-formed JSON payload, as it will be used in outgoing +> webhook requests. ---- +### Template Usage -### Multiple Receivers +In the template files located in the `templates` directory, you can use Go's native templating language. -The configuration supports **multiple receivers** — each with its own auth rules, event types, and hook logic. This -enables you to route webhooks from different sources independently: +You may reference values from the original request body using dot notation like `{{ .some.path }}`. +For example, if the incoming payload contains a field `user.name`, you can access it in your template as: -```json -[ - { - "receiver": "gitlab", - ... - }, - { - "receiver": "github", - ... - } -] +```gohtml +{{ .user.name }} ``` ---- +### Built-in Template Functions -### Authentication (`auth`) - -Each receiver must define an `auth` block to control who can send webhooks. The supported flows are: +Your templates also support the following built-in utility functions: -| Flow | Description | -|----------------|--------------------------------------------------------------------------------------------------------------------------------| -| `none` | No authentication; accepts all requests. | -| `plain secret` | Matches the value in the request header against the configured `secret`. | -| `basic auth` | Verifies username and password in basic auth header matches the `secret`, in the format `username:password`. | -| `gitlab` | Compares the configured `secret` with the GitLab token header using constant-time comparison (SHA-512). | -| `github` | Verifies HMAC SHA-256 signature in a header (e.g. X-Hub-Signature-256 or custom) using the configured secret and request body. | - -**Fields:** - -- `flow`: One of `gitlab`, `github`, `basic auth`, `plain secret`, or `none`. -- `header_secret_key`: The header to extract the token from (e.g., `X-Gitlab-Token` or `X-Custom-Token`). -- `secret`: The expected secret value (or in `basic auth`, the `username:password` pair). - ---- - -### Condition Syntax - -Conditions use a simple templated language: - -- `{Header.X-Foo}` refers to a request header/url query param -- `{Body.foo.bar}` refers to a nested body field -- `{Body.foo[].bar}` supports iterating over arrays, should be used with the {in} operator - -Example: - -```json -"{Header.x-gitlab-label} {in} {Body.object_attributes.labels[].title}" -``` - -This checks whether the value of `x-gitlab-label` header or url query param exists in any of the `title` fields in the -incoming body -array `object_attributes.labels`. - ---- +| Function | Description | +|-------------|-----------------------------------------------------------------------------------------------------------------| +| `now` | Returns the current time. | +| `format` | Formats a `time.Time` object using Go's time layout. Example: `{{ format now "2006-01-02" }}` | +| `parseTime` | Parses a string into a `time.Time` using the given layout. Example: `{{ parseTime "2023-01-01" "2006-01-02" }}` | +| `pastTense` | Appends `-ed` or `-d` to a word to form the past tense. Example: `{{ pastTense "open" }}` → `opened` | +| `lower` | Converts a string to lowercase. Example: `{{ lower "HELLO" }}` → `hello` | +| `upper` | Converts a string to uppercase. Example: `{{ upper "hello" }}` → `HELLO` | +| `title` | Converts a string to title case. Example: `{{ title "hello world" }}` → `HELLO WORLD` | +| `trim` | Trims leading and trailing whitespace. Example: `{{ trim " hello " }}` → `hello` | +| `contains` | Checks if a string contains a substring. Example: `{{ contains "hello world" "world" }}` → `true` | +| `replace` | Replaces all occurrences of a substring. Example: `{{ replace "hello world" "world" "Go" }}` → `hello Go` | +| `default` | Returns a fallback value if the input is empty or nil. Example: `{{ default .user.name "Guest" }}` | Running with Docker Compose ------ @@ -277,6 +261,7 @@ curl -X POST http://localhost:3000/webhooks/gitlab?discord-url=your_discord_webh }, "object_attributes": { "title": "MS-Viewport", + "updated_at": "2013-12-03T17:23:34Z", "labels": [ { "title": "API", diff --git a/cmd/hookah/main.go b/cmd/hookah/main.go index da4c748..bd65997 100644 --- a/cmd/hookah/main.go +++ b/cmd/hookah/main.go @@ -5,23 +5,33 @@ import ( "encoding/json" "errors" "fmt" + "github.com/AdamShannag/hookah/internal/auth" + "github.com/AdamShannag/hookah/internal/config" "github.com/AdamShannag/hookah/internal/server" "github.com/AdamShannag/hookah/internal/types" "log" "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" ) func main() { - config, err := parseConfigFile(os.Getenv("CONFIG_PATH")) + templateConfigs, err := parseConfigFile(os.Getenv("CONFIG_PATH")) if err != nil { log.Fatal(err) } - srv := server.NewServer(config) + templates, err := parseTemplates(os.Getenv("TEMPLATES_PATH")) + if err != nil { + log.Fatal(err) + } + + conf := config.New(templateConfigs, templates, auth.NewDefault()) + + srv := server.NewServer(conf) done := make(chan bool, 1) go gracefulShutdown(srv, done) @@ -53,16 +63,34 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) { done <- true } -func parseConfigFile(filePath string) (*types.Config, error) { +func parseConfigFile(filePath string) ([]types.Template, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } - var result types.Config + var result []types.Template if err = json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) } - return &result, nil + return result, nil +} + +func parseTemplates(dirPath string) (map[string]string, error) { + dir, err := os.ReadDir(dirPath) + if err != nil { + return nil, fmt.Errorf("failed to read templates directory: %w", err) + } + + templates := make(map[string]string) + for _, file := range dir { + bytes, readErr := os.ReadFile(filepath.Join(dirPath, file.Name())) + if readErr != nil { + return nil, fmt.Errorf("failed to read file: %w", readErr) + } + templates[file.Name()] = string(bytes) + } + + return templates, nil } diff --git a/deploy/config.json b/deploy/config.json index 8862c60..431f2b1 100644 --- a/deploy/config.json +++ b/deploy/config.json @@ -16,24 +16,12 @@ { "name": "discord", "endpoint_key": "discord-url", - "body": { - "username": "{{ .user.username }}", - "avatar_url": "{{ .user.avatar_url }}", - "embeds": [ - { - "author": { - "name": "{{ .user.username }}", - "icon_url": "{{ .user.avatar_url }}" - }, - "title": "{{ .object_attributes.title }}", - "description": "{{ .user.name }} {{ .object_attributes.action }} a merge request in [{{ .project.path_with_namespace }}]({{ .project.web_url }})", - "color": 15258703, - "footer": { - "text": "Woah! So cool! :smirk:" - } - } - ] - } + "body": "discord.tmpl" + }, + { + "name": "discord_simple", + "endpoint_key": "discord-url", + "body": "discord_simple.tmpl" } ] } diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 266ffc2..4022958 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -8,6 +8,8 @@ services: environment: PORT: 3000 CONFIG_PATH: /etc/hookah/config.json + TEMPLATES_PATH: /etc/hookah/templates volumes: - ./config.json:/etc/hookah/config.json:ro + - ./templates:/etc/hookah/templates:ro restart: unless-stopped diff --git a/deploy/templates/discord.tmpl b/deploy/templates/discord.tmpl new file mode 100644 index 0000000..65a9fed --- /dev/null +++ b/deploy/templates/discord.tmpl @@ -0,0 +1,19 @@ +{ + "username": "{{title .user.username }}", + "avatar_url": "{{ .user.avatar_url }}", + "content": "today is {{format now "2006-01-02"}}", + "embeds": [ + { + "author": { + "name": "{{upper .user.username }}", + "icon_url": "{{ .user.avatar_url }}" + }, + "title": "{{ .object_attributes.title }}", + "description": "{{ .user.name }} {{pastTense .object_attributes.action }} a merge request in [{{ .project.path_with_namespace }}]({{ .project.web_url }})", + "color": 15258703, + "footer": { + "text": "{{format (parseTime .object_attributes.updated_at "2006-01-02T15:04:05Z07:00") "2006-01-02"}}" + } + } + ] +} \ No newline at end of file diff --git a/deploy/templates/discord_simple.tmpl b/deploy/templates/discord_simple.tmpl new file mode 100644 index 0000000..ce423bc --- /dev/null +++ b/deploy/templates/discord_simple.tmpl @@ -0,0 +1,5 @@ +{ + "username": "{{title .user.username }}", + "avatar_url": "{{ .user.avatar_url }}", + "content": "{{lower "SIMPLE CONTENT"}}" +} \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..a087dfb --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,44 @@ +package auth + +import ( + "github.com/AdamShannag/hookah/internal/flow" + "github.com/AdamShannag/hookah/internal/types" + "net/http" +) + +type Auth interface { + RegisterFlow(flow string, flowFunc flow.Func) Auth + ApplyFlow(auth types.Auth, r *http.Request, payload []byte) bool +} + +type auth struct { + flows map[string]flow.Func +} + +func New() Auth { + return &auth{make(map[string]flow.Func)} +} + +func NewDefault() Auth { + return New(). + RegisterFlow("none", flow.None). + RegisterFlow("plain secret", flow.PlainSecret). + RegisterFlow("basic auth", flow.BasicAuth). + RegisterFlow("gitlab", flow.Gitlab). + RegisterFlow("github", flow.Github) +} + +func (a *auth) RegisterFlow(flow string, flowFunc flow.Func) Auth { + a.flows[flow] = flowFunc + return a +} + +func (a *auth) ApplyFlow(auth types.Auth, r *http.Request, payload []byte) bool { + flowFunc, ok := a.flows[auth.Flow] + if !ok { + + return false + } + + return flowFunc(auth, r, payload) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..b9d9435 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,44 @@ +package auth_test + +import ( + "bytes" + "github.com/AdamShannag/hookah/internal/auth" + "github.com/AdamShannag/hookah/internal/types" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRegisterAndApplyFlow(t *testing.T) { + a := auth.New() + + a.RegisterFlow("mock-flow-pass", mockFlow(true)) + a.RegisterFlow("mock-flow-fail", mockFlow(false)) + + req := httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte(`{"data":"test"}`))) + + tests := []struct { + name string + auth types.Auth + expected bool + }{ + {"FlowPasses", types.Auth{Flow: "mock-flow-pass"}, true}, + {"FlowFails", types.Auth{Flow: "mock-flow-fail"}, false}, + {"FlowMissing", types.Auth{Flow: "unregistered"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := a.ApplyFlow(tt.auth, req, []byte("test")) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func mockFlow(expected bool) func(types.Auth, *http.Request, []byte) bool { + return func(a types.Auth, r *http.Request, b []byte) bool { + return expected + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3238ac5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "github.com/AdamShannag/hookah/internal/auth" + "github.com/AdamShannag/hookah/internal/types" + "log" + "net/http" +) + +type Config struct { + templateConfigs []types.Template + templates map[string]string + auth auth.Auth +} + +func New(templateConfigs []types.Template, templates map[string]string, auth auth.Auth) *Config { + return &Config{ + templateConfigs: templateConfigs, + templates: templates, + auth: auth, + } +} + +func (c *Config) GetTemplate(template string) string { + body, ok := c.templates[template] + if !ok { + return "{}" + } + return body +} + +func (c *Config) GetConfigTemplates(receiver string, r *http.Request, payload []byte) (templates []types.Template) { + for _, template := range c.templateConfigs { + if template.Receiver != receiver { + continue + } + + if !c.auth.ApplyFlow(template.Auth, r, payload) { + log.Printf("[AUTH] failed for receiver: %s with flow: %s", receiver, template.Auth.Flow) + continue + } + + templates = append(templates, template) + } + return +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..33d1ab2 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config_test + +import ( + "bytes" + "github.com/AdamShannag/hookah/internal/auth" + "github.com/AdamShannag/hookah/internal/config" + "github.com/AdamShannag/hookah/internal/types" + "net/http/httptest" + "testing" +) + +func TestNewConfig(t *testing.T) { + tmpls := []types.Template{{Receiver: "discord", Auth: types.Auth{Flow: "none"}}} + tmplMap := map[string]string{"discord": `{ "msg": "hello" }`} + + cfg := config.New(tmpls, tmplMap, auth.NewDefault()) + + if len(cfg.GetConfigTemplates("discord", httptest.NewRequest("POST", "/", nil), nil)) != 1 { + t.Error("expected one template to match") + } +} + +func TestGetTemplate(t *testing.T) { + cfg := config.New(nil, map[string]string{ + "discord": `{ "msg": "hello" }`, + }, auth.NewDefault()) + + val := cfg.GetTemplate("discord") + if val != `{ "msg": "hello" }` { + t.Errorf("expected template body, got %s", val) + } + + val = cfg.GetTemplate("unknown") + if val != "{}" { + t.Errorf("expected fallback '{}', got %s", val) + } +} + +func TestGetConfigTemplates_AuthFilter(t *testing.T) { + templates := []types.Template{ + {Receiver: "slack", Auth: types.Auth{Flow: "plain secret"}}, + {Receiver: "slack", Auth: types.Auth{Flow: "none"}}, + } + + req := httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte("test"))) + + t.Run("auth passes", func(t *testing.T) { + cfg := config.New(templates, nil, auth.NewDefault()) + result := cfg.GetConfigTemplates("slack", req, []byte("test")) + + if len(result) != 2 { + t.Errorf("expected 2 templates to pass auth, got %d", len(result)) + } + }) + + t.Run("auth fails", func(t *testing.T) { + cfg := config.New([]types.Template{ + {Receiver: "slack", Auth: types.Auth{Flow: "no flow"}}, + }, nil, auth.NewDefault()) + + result := cfg.GetConfigTemplates("slack", req, []byte("test")) + + if len(result) != 0 { + t.Errorf("expected 0 templates to pass auth, got %d", len(result)) + } + }) + + t.Run("receiver mismatch", func(t *testing.T) { + cfg := config.New(templates, nil, auth.NewDefault()) + result := cfg.GetConfigTemplates("discord", req, []byte("test")) + + if len(result) != 0 { + t.Errorf("expected 0 templates for mismatched receiver, got %d", len(result)) + } + }) +} diff --git a/internal/flow/flow.go b/internal/flow/flow.go new file mode 100644 index 0000000..cb763ac --- /dev/null +++ b/internal/flow/flow.go @@ -0,0 +1,48 @@ +package flow + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/hex" + "fmt" + "github.com/AdamShannag/hookah/internal/types" + "net/http" + "strings" +) + +type Func func(auth types.Auth, r *http.Request, payload []byte) bool + +func None(_ types.Auth, _ *http.Request, _ []byte) bool { + return true +} + +func BasicAuth(auth types.Auth, r *http.Request, _ []byte) bool { + username, password, ok := r.BasicAuth() + return ok && auth.Secret == fmt.Sprintf("%s:%s", username, password) +} + +func PlainSecret(auth types.Auth, r *http.Request, _ []byte) bool { + return auth.Secret == r.Header.Get(auth.HeaderSecretKey) +} + +func Gitlab(auth types.Auth, r *http.Request, _ []byte) bool { + expected := sha512.Sum512([]byte(auth.Secret)) + actual := sha512.Sum512([]byte(r.Header.Get(auth.HeaderSecretKey))) + return subtle.ConstantTimeCompare(actual[:], expected[:]) == 1 +} + +func Github(auth types.Auth, r *http.Request, payload []byte) bool { + signature := r.Header.Get(auth.HeaderSecretKey) + if signature == "" { + return false + } + signature = strings.TrimPrefix(signature, "sha256=") + + mac := hmac.New(sha256.New, []byte(auth.Secret)) + _, _ = mac.Write(payload) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(signature), []byte(expectedMAC)) +} diff --git a/internal/flow/flow_test.go b/internal/flow/flow_test.go new file mode 100644 index 0000000..462b581 --- /dev/null +++ b/internal/flow/flow_test.go @@ -0,0 +1,95 @@ +package flow_test + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "github.com/AdamShannag/hookah/internal/flow" + "github.com/AdamShannag/hookah/internal/types" + "net/http/httptest" + "testing" +) + +func TestNone(t *testing.T) { + auth := types.Auth{} + req := httptest.NewRequest("POST", "/", nil) + if !flow.None(auth, req, nil) { + t.Error("None should always return true") + } +} + +func TestBasicAuth(t *testing.T) { + auth := types.Auth{Secret: "user:pass"} + req := httptest.NewRequest("POST", "/", nil) + req.SetBasicAuth("user", "pass") + + if !flow.BasicAuth(auth, req, nil) { + t.Error("BasicAuth should succeed with matching credentials") + } + + req.SetBasicAuth("user", "wrong") + if flow.BasicAuth(auth, req, nil) { + t.Error("BasicAuth should fail with incorrect credentials") + } +} + +func TestPlainSecret(t *testing.T) { + auth := types.Auth{ + Secret: "my-secret", + HeaderSecretKey: "X-Secret-Key", + } + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("X-Secret-Key", "my-secret") + + if !flow.PlainSecret(auth, req, nil) { + t.Error("PlainSecret should succeed with correct secret") + } + + req.Header.Set("X-Secret-Key", "wrong-secret") + if flow.PlainSecret(auth, req, nil) { + t.Error("PlainSecret should fail with incorrect secret") + } +} + +func TestGitlab(t *testing.T) { + secret := "top-secret" + auth := types.Auth{ + Secret: secret, + HeaderSecretKey: "X-Gitlab-Token", + } + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("X-Gitlab-Token", secret) + + if !flow.Gitlab(auth, req, nil) { + t.Error("Gitlab should succeed with correct hash") + } + + req.Header.Set("X-Gitlab-Token", "invalid") + if flow.Gitlab(auth, req, nil) { + t.Error("Gitlab should fail with incorrect hash") + } +} + +func TestGithub(t *testing.T) { + secret := "github-secret" + auth := types.Auth{ + Secret: secret, + HeaderSecretKey: "X-Hub-Signature-256", + } + payload := []byte(`{"key":"value"}`) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + signature := hex.EncodeToString(mac.Sum(nil)) + req := httptest.NewRequest("POST", "/", bytes.NewReader(payload)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + if !flow.Github(auth, req, payload) { + t.Error("Github should succeed with correct signature") + } + + req.Header.Set("X-Hub-Signature-256", "sha256=invalidsignature") + if flow.Github(auth, req, payload) { + t.Error("Github should fail with incorrect signature") + } +} diff --git a/internal/render/funcs.go b/internal/render/funcs.go new file mode 100644 index 0000000..ca0b045 --- /dev/null +++ b/internal/render/funcs.go @@ -0,0 +1,53 @@ +package render + +import ( + "log" + "strings" + "text/template" + "time" +) + +var funcMap = template.FuncMap{ + "now": now, + "format": format, + "parseTime": parseTime, + "pastTense": pastTense, + "lower": strings.ToLower, + "upper": strings.ToUpper, + "title": strings.ToTitle, + "trim": strings.TrimSpace, + "contains": strings.Contains, + "replace": strings.ReplaceAll, + "default": defaultValue, +} + +func now() time.Time { return time.Now() } + +func format(t time.Time, format string) string { + return t.Format(format) +} + +func parseTime(tm string, layout string) time.Time { + t, err := time.Parse(layout, tm) + if err != nil { + log.Printf("[Template] %v", err) + return time.Time{} + } + + return t +} + +func pastTense(word string) string { + if len(word) > 0 && word[len(word)-1] == 'e' { + return word + "d" + } + + return word + "ed" +} + +func defaultValue(val, fallback any) any { + if val == nil || val == "" { + return fallback + } + return val +} diff --git a/internal/render/render.go b/internal/render/render.go index 4757d59..8aa773c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -8,7 +8,7 @@ import ( ) func ToMap(tmplStr string, dataSource map[string]any) (map[string]any, error) { - tmpl, err := template.New("map-template").Parse(tmplStr) + tmpl, err := template.New("map-template").Funcs(funcMap).Parse(tmplStr) if err != nil { return nil, fmt.Errorf("error parsing template: %w", err) } @@ -25,11 +25,3 @@ func ToMap(tmplStr string, dataSource map[string]any) (map[string]any, error) { return result, nil } - -func ToString(m map[string]any) (string, error) { - b, err := json.Marshal(m) - if err != nil { - return "", fmt.Errorf("failed to marshal map: %w", err) - } - return string(b), nil -} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 278d36f..6efa4e0 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -1,39 +1,10 @@ package render import ( - "encoding/json" "strings" "testing" ) -func TestToString_Success(t *testing.T) { - input := map[string]any{ - "name": "hookah", - "ok": true, - } - - result, err := ToString(input) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - var out map[string]any - if err = json.Unmarshal([]byte(result), &out); err != nil { - t.Fatalf("result is not valid JSON: %v", err) - } - - if out["name"] != "hookah" || out["ok"] != true { - t.Fatalf("unexpected output: %v", out) - } -} - -func TestToString_Failure(t *testing.T) { - _, err := ToString(map[string]any{"chan": make(chan int)}) - if err == nil { - t.Fatal("expected error for non-serializable value, got nil") - } -} - func TestToMap_Success(t *testing.T) { tmpl := `{"msg": "hello {{.name}}", "active": {{.active}}}` data := map[string]any{ diff --git a/internal/server/routes.go b/internal/server/routes.go index fabc5ef..77f18de 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -25,7 +25,7 @@ func (s *Server) WebhookHandler(w http.ResponseWriter, r *http.Request) { return } - templates := s.config.GetTemplates(receiver, r, payload) + templates := s.config.GetConfigTemplates(receiver, r, payload) if len(templates) == 0 { w.WriteHeader(http.StatusOK) @@ -39,7 +39,7 @@ func (s *Server) WebhookHandler(w http.ResponseWriter, r *http.Request) { } for _, tmpl := range templates { - go handleTemplate(tmpl, r.Header, request) + go s.handleTemplate(tmpl, r.Header, request) } w.WriteHeader(http.StatusOK) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 64d7dc2..a8a7f8c 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -3,6 +3,8 @@ package server import ( "bytes" "encoding/json" + "github.com/AdamShannag/hookah/internal/auth" + "github.com/AdamShannag/hookah/internal/config" "github.com/AdamShannag/hookah/internal/types" "io" "net/http" @@ -33,7 +35,7 @@ func TestWebhookHandler_DispatchesToSimulatedEndpoint(t *testing.T) { defer mockDiscord.Close() testServer := &Server{ - config: &(types.Config{ + config: config.New([]types.Template{ { Receiver: "gitlab", Auth: types.Auth{Flow: "none"}, @@ -47,15 +49,15 @@ func TestWebhookHandler_DispatchesToSimulatedEndpoint(t *testing.T) { { Name: "MockDiscord", EndpointKey: "Webhook-URL", - Body: map[string]any{ - "content": "Issue received", - }, + Body: "discord.tmpl", }, }, }, }, }, - }), + }, map[string]string{ + "discord.tmpl": getBodyTemplate("Issue received"), + }, auth.NewDefault()), } reqBody := map[string]any{ @@ -96,7 +98,7 @@ func TestWebhookHandler_DoesNotDispatchWhenConditionFails(t *testing.T) { defer mockDiscord.Close() testServer := &Server{ - config: &(types.Config{ + config: config.New([]types.Template{ { Receiver: "gitlab", Auth: types.Auth{Flow: "none"}, @@ -110,15 +112,15 @@ func TestWebhookHandler_DoesNotDispatchWhenConditionFails(t *testing.T) { { Name: "MockDiscord", EndpointKey: "Webhook-URL", - Body: map[string]any{ - "content": "Should not be triggered", - }, + Body: "discord.tmpl", }, }, }, }, }, - }), + }, map[string]string{ + "discord.tmpl": getBodyTemplate("Should not be triggered"), + }, auth.NewDefault()), } reqBody := map[string]any{ @@ -166,7 +168,7 @@ func TestWebhookHandler_UsesQueryParamsAsHeaders(t *testing.T) { defer mockDiscord.Close() testServer := &Server{ - config: &(types.Config{ + config: config.New([]types.Template{ { Receiver: "gitlab", Auth: types.Auth{Flow: "none"}, @@ -180,15 +182,15 @@ func TestWebhookHandler_UsesQueryParamsAsHeaders(t *testing.T) { { Name: "MockDiscord", EndpointKey: "Webhook-URL", - Body: map[string]any{ - "content": "Query param test passed", - }, + Body: "discord.tmpl", }, }, }, }, }, - }), + }, map[string]string{ + "discord.tmpl": getBodyTemplate("Query param test passed"), + }, auth.NewDefault()), } reqBody := map[string]any{ @@ -214,3 +216,11 @@ func TestWebhookHandler_UsesQueryParamsAsHeaders(t *testing.T) { t.Fatalf("expected payload 'Query param test passed', got: %v", receivedPayload) } } + +func getBodyTemplate(content string) string { + marshal, _ := json.Marshal(map[string]string{ + "content": content, + }) + + return string(marshal) +} diff --git a/internal/server/server.go b/internal/server/server.go index e5b5333..9b0a48c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,7 +2,7 @@ package server import ( "fmt" - "github.com/AdamShannag/hookah/internal/types" + "github.com/AdamShannag/hookah/internal/config" "net/http" "os" "strconv" @@ -11,10 +11,10 @@ import ( type Server struct { port int - config *types.Config + config *config.Config } -func NewServer(config *types.Config) *http.Server { +func NewServer(config *config.Config) *http.Server { port, _ := strconv.Atoi(os.Getenv("PORT")) newServer := &Server{ port: port, diff --git a/internal/server/util.go b/internal/server/util.go index 01613ca..44e7394 100644 --- a/internal/server/util.go +++ b/internal/server/util.go @@ -11,7 +11,7 @@ import ( "net/http" ) -func handleTemplate(tmpl types.TemplateConfig, headers http.Header, body map[string]any) { +func (s *Server) handleTemplate(tmpl types.Template, headers http.Header, body map[string]any) { eventType, err := extractEventType(tmpl, headers, body) if err != nil { log.Printf("[Template] %v", err) @@ -25,34 +25,11 @@ func handleTemplate(tmpl types.TemplateConfig, headers http.Header, body map[str } for _, evt := range events { - go processEvent(evt, headers, body) + go s.processEvent(evt, headers, body) } } -func extractEventType(tmpl types.TemplateConfig, headers http.Header, body map[string]any) (string, error) { - switch tmpl.EventTypeIn { - case "header": - eventType := headers.Get(tmpl.EventTypeKey) - if eventType == "" { - return "", fmt.Errorf("event key '%s' not found in headers", tmpl.EventTypeKey) - } - return eventType, nil - case "body": - rawEvent, ok := body[tmpl.EventTypeKey] - if !ok { - return "", fmt.Errorf("event key '%s' not found in body", tmpl.EventTypeKey) - } - eventType, ok := rawEvent.(string) - if !ok { - return "", fmt.Errorf("event key '%s' is not a string in body", tmpl.EventTypeKey) - } - return eventType, nil - default: - return "", fmt.Errorf("unknown EventTypeIn value: '%s'", tmpl.EventTypeIn) - } -} - -func processEvent(evt types.Event, headers http.Header, body map[string]any) { +func (s *Server) processEvent(evt types.Event, headers http.Header, body map[string]any) { ok, err := condition.Evaluate(evt.Conditions, headers, body) if err != nil { log.Printf("[Condition] Evaluation error: %v", err) @@ -64,16 +41,12 @@ func processEvent(evt types.Event, headers http.Header, body map[string]any) { } for _, hook := range evt.Hooks { - go triggerHook(hook, body, headers) + go s.triggerHook(hook, body, headers) } } -func triggerHook(hook types.Hook, body map[string]any, headers http.Header) { - templateStr, err := render.ToString(hook.Body) - if err != nil { - log.Printf("[Render] Failed to convert hook body to string (%s): %v", hook.Name, err) - return - } +func (s *Server) triggerHook(hook types.Hook, body map[string]any, headers http.Header) { + templateStr := s.config.GetTemplate(hook.Body) payload, err := render.ToMap(templateStr, body) if err != nil { @@ -93,6 +66,29 @@ func triggerHook(hook types.Hook, body map[string]any, headers http.Header) { } } +func extractEventType(tmpl types.Template, headers http.Header, body map[string]any) (string, error) { + switch tmpl.EventTypeIn { + case "header": + eventType := headers.Get(tmpl.EventTypeKey) + if eventType == "" { + return "", fmt.Errorf("event key '%s' not found in headers", tmpl.EventTypeKey) + } + return eventType, nil + case "body": + rawEvent, ok := body[tmpl.EventTypeKey] + if !ok { + return "", fmt.Errorf("event key '%s' not found in body", tmpl.EventTypeKey) + } + eventType, ok := rawEvent.(string) + if !ok { + return "", fmt.Errorf("event key '%s' is not a string in body", tmpl.EventTypeKey) + } + return eventType, nil + default: + return "", fmt.Errorf("unknown EventTypeIn value: '%s'", tmpl.EventTypeIn) + } +} + func postJSON(url string, data map[string]any) error { jsonData, err := json.Marshal(data) if err != nil { diff --git a/internal/types/auth.go b/internal/types/auth.go deleted file mode 100644 index 46a9635..0000000 --- a/internal/types/auth.go +++ /dev/null @@ -1,7 +0,0 @@ -package types - -type Auth struct { - Flow string `json:"flow"` - HeaderSecretKey string `json:"header_secret_key,omitempty"` - Secret string `json:"secret"` -} diff --git a/internal/types/config.go b/internal/types/config.go deleted file mode 100644 index 6c70ef4..0000000 --- a/internal/types/config.go +++ /dev/null @@ -1,66 +0,0 @@ -package types - -import ( - "crypto/hmac" - "crypto/sha256" - "crypto/sha512" - "crypto/subtle" - "encoding/hex" - "fmt" - "log" - "net/http" - "strings" -) - -type Config []TemplateConfig - -func (c Config) GetTemplates(receiver string, r *http.Request, payload []byte) (templates []TemplateConfig) { - for _, template := range c { - if template.Receiver != receiver { - continue - } - - if !isAuthorized(template.Auth, r, payload) { - log.Printf("[AUTH] failed for receiver: %s with flow: %s", receiver, template.Auth.Flow) - continue - } - - templates = append(templates, template) - } - return -} - -func isAuthorized(auth Auth, r *http.Request, payload []byte) bool { - switch auth.Flow { - case "none": - return true - - case "basic auth": - username, password, ok := r.BasicAuth() - return ok && auth.Secret == fmt.Sprintf("%s:%s", username, password) - - case "gitlab": - expected := sha512.Sum512([]byte(auth.Secret)) - actual := sha512.Sum512([]byte(r.Header.Get(auth.HeaderSecretKey))) - return subtle.ConstantTimeCompare(actual[:], expected[:]) == 1 - - case "github": - signature := r.Header.Get(auth.HeaderSecretKey) - if signature == "" { - return false - } - signature = strings.TrimPrefix(signature, "sha256=") - - mac := hmac.New(sha256.New, []byte(auth.Secret)) - _, _ = mac.Write(payload) - expectedMAC := hex.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(signature), []byte(expectedMAC)) - - case "plain secret": - return auth.Secret == r.Header.Get(auth.HeaderSecretKey) - - default: - return false - } -} diff --git a/internal/types/template_config.go b/internal/types/template_config.go deleted file mode 100644 index 7d883b9..0000000 --- a/internal/types/template_config.go +++ /dev/null @@ -1,15 +0,0 @@ -package types - -type TemplateConfig struct { - Receiver string `json:"receiver"` - Auth Auth `json:"auth"` - EventTypeIn string `json:"event_type_in"` - EventTypeKey string `json:"event_type_key"` - Events Events `json:"events,omitempty"` -} - -type Hook struct { - Name string `json:"name"` - EndpointKey string `json:"endpoint_key"` - Body map[string]any `json:"body,omitempty"` -} diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..bfe366f --- /dev/null +++ b/internal/types/types.go @@ -0,0 +1,21 @@ +package types + +type Template struct { + Receiver string `json:"receiver"` + Auth Auth `json:"auth"` + EventTypeIn string `json:"event_type_in"` + EventTypeKey string `json:"event_type_key"` + Events Events `json:"events,omitempty"` +} + +type Hook struct { + Name string `json:"name"` + EndpointKey string `json:"endpoint_key"` + Body string `json:"body,omitempty"` +} + +type Auth struct { + Flow string `json:"flow"` + HeaderSecretKey string `json:"header_secret_key,omitempty"` + Secret string `json:"secret"` +}