Compare commits

..

20 Commits

Author SHA1 Message Date
749a3bc9b3 don't remember what this does lol 2023-10-28 21:17:22 +01:00
8f0cf776b1 reload config on new webhook request 2022-02-12 14:07:11 +00:00
cdd19d95e9 closes #1, closes #3 update readme 2022-02-12 13:28:28 +00:00
dfd44b7ea1 update module path thingy 2022-01-21 21:08:05 +00:00
1e53930e8c Check if config is valid JSON 2021-09-01 18:58:18 +01:00
3cadcfbabe Fix signature logic 2021-08-14 01:06:29 +01:00
39fe4748e1 Add support for individually disabling signature verification 2021-08-14 01:02:36 +01:00
f2b2ac9368 update example config 2021-08-06 14:54:36 +01:00
4c8cb33c59 Better logging, comments 2021-08-06 14:53:22 +01:00
cf65488907 Implement Stringer in Command type 2021-08-06 14:29:24 +01:00
87ea4cc5e5 Don't print config on start, add a couple comments 2021-08-06 14:27:42 +01:00
5ab36c57ef convert stdout to string before printing 2021-08-04 22:18:53 +01:00
a3ded5a052 Run tests and script in parralel to prevent timing out 2021-08-04 22:17:43 +01:00
081aaee9c7 Actually print calcuated signature to stdout 2021-08-04 22:12:59 +01:00
0953baae50 Don't overwrite existing user configs 2021-08-04 22:12:38 +01:00
6cacc65013 Add config option SignaturePrefix 2021-08-04 21:43:37 +01:00
8677f5bfdd Move config to separate package 2021-08-04 12:04:01 +01:00
2189ee511c Allow executed script to have arguments 2021-08-04 11:51:40 +01:00
a2d4153c64 Create Makefile 2021-08-04 10:22:19 +01:00
294bd80cc7 Update service file to restart on app crash 2021-07-29 09:46:33 +01:00
13 changed files with 290 additions and 116 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
gohookr

20
Makefile Normal file
View File

@@ -0,0 +1,20 @@
all: install
clean:
rm -rf gohookr
install: build
cp gohookr /usr/local/bin/
cp gohookr.service /usr/lib/systemd/system/
cp -n config.json /etc/gohookr.json
systemctl daemon-reload
systemctl enable --now gohookr
build:
go mod tidy
go build -o gohookr
uninstall:
systemctl disable --now gohookr
rm -rf /usr/local/bin/gohookr /usr/lib/systemd/system/gohookr.service
systemctl daemon-reload

View File

@@ -2,13 +2,30 @@
"ListenAddress": "127.0.0.1:8654", "ListenAddress": "127.0.0.1:8654",
"Services": { "Services": {
"test": { "test": {
"Script": "./example.sh", "Script": {
"Secret": "THISISVERYSECRET", "Program": "./example.sh",
"SignatureHeader": "X-Gitea-Signature", "AppendPayload": true
},
"DisableSignatureVerification": true,
"Tests": [ "Tests": [
{ {
"Command": "git", "Program": "echo",
"Arguments": [ "pull" ] "Arguments": [ "test" ]
}
]
},
"fails_tests": {
"Script": {
"Program": "./example.sh",
"AppendPayload": true
},
"Secret": "who_cares",
"SignatureHeader": "X-Hub-Signature-256",
"SignaturePrefix": "sha256=",
"Tests": [
{
"Program": "false",
"Arguments": []
} }
] ]
} }

13
config.yml Normal file
View File

@@ -0,0 +1,13 @@
listenaddress: 127.0.0.1:8654
services:
test:
script:
program: "echo"
arguments:
- test
tests:
- program: ./example.sh
appendpayload: true
disablesignatureverification: false
signatureheader: test
secret: thisisasecret

30
config/command.go Normal file
View File

@@ -0,0 +1,30 @@
package config
import (
"fmt"
"os/exec"
)
type Command struct {
Program string
Arguments []string
AppendPayload bool
}
func (c Command) Execute(payload string) ([]byte, error) {
arguments := make([]string, 0)
copy(c.Arguments, arguments)
if c.AppendPayload {
arguments = append(arguments, payload)
}
return exec.Command(c.Program, arguments...).Output()
}
func (c Command) String() string {
return fmt.Sprintf(
"<Command cmd=%v AppendPayload=%v>",
append([]string{c.Program}, c.Arguments...),
c.AppendPayload,
)
}

62
config/config.go Normal file
View File

@@ -0,0 +1,62 @@
package config
import (
"encoding/json"
"io/ioutil"
"gopkg.in/yaml.v3"
)
// The struct that represents the config.json file
type Config struct {
ListenAddress string
Services map[string]struct {
Script Command
Secret string
SignaturePrefix string
SignatureHeader string
DisableSignatureVerification bool
Tests []Command
}
}
// Check that all required fields are filled in
func (c Config) Validate() error {
if c.ListenAddress == "" {
return requiredFieldError{"ListenAddress", ""}
}
for serviceName, service := range c.Services {
if service.Script.Program == "" {
return requiredFieldError{"Script.Program", serviceName}
}
if !service.DisableSignatureVerification && service.SignatureHeader == "" {
return requiredFieldError{"SignatureHeader", serviceName}
}
if !service.DisableSignatureVerification && service.Secret == "" {
return requiredFieldError{"Secret", serviceName}
}
}
return nil
}
func (c *Config) Load(config_filename string) error {
raw_config, err := ioutil.ReadFile(config_filename)
if err != nil {
return err
}
err = json.Unmarshal(raw_config, &c)
if err == nil {
return c.Validate()
}
err = yaml.Unmarshal(raw_config, &c)
if err == nil {
return c.Validate()
}
return err
}

12
config/errors.go Normal file
View File

@@ -0,0 +1,12 @@
package config
import "fmt"
type requiredFieldError struct {
fieldName string
serviceName string
}
func (e requiredFieldError) Error() string {
return fmt.Sprintf("%v cannot be empty (%v)", e.fieldName, e.serviceName)
}

1
example.sh Executable file → Normal file
View File

@@ -1,2 +1,3 @@
#!/usr/bin/bash #!/usr/bin/bash
date >> test_output date >> test_output
echo "$1" >> test_output

7
go.mod
View File

@@ -1,5 +1,8 @@
module git.alra.uk/alvierahman90/gohookr module git.alv.cx/alvierahman90/gohookr
go 1.16 go 1.16
require github.com/gorilla/mux v1.8.0 require (
github.com/gorilla/mux v1.8.0
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)

4
go.sum
View File

@@ -1,2 +1,6 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,6 +3,8 @@ Description=A really simple webhook receiver.
After=network.target After=network.target
[Service] [Service]
Restart=on-failure
RestartSec=5s
ExecStart=/usr/local/bin/gohookr ExecStart=/usr/local/bin/gohookr
[Install] [Install]

145
main.go
View File

@@ -4,36 +4,26 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"git.alv.cx/alvierahman90/gohookr/config"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
var config_filename = "/etc/gohookr.json" var config_filename = "/etc/gohookr.json"
var checkSignature = true var checkSignature = true
var config Config
func main() { func main() {
var c config.Config
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/webhooks/{service}", webhookHandler) r.HandleFunc("/webhooks/{service}", webhookHandler)
raw_config, err := ioutil.ReadFile(config_filename)
if err != nil {
panic(err.Error())
}
config = Config{}
json.Unmarshal(raw_config, &config)
if err := config.Validate(); err != nil {
panic(err.Error())
}
if p, ok := os.LookupEnv("CONFIG"); ok { if p, ok := os.LookupEnv("CONFIG"); ok {
config_filename = p config_filename = p
} }
@@ -42,52 +32,81 @@ func main() {
checkSignature = p != "true" checkSignature = p != "true"
} }
log.Fatal(http.ListenAndServe(config.ListenAddress, r)) var err = c.Load(config_filename)
if err != nil {
panic(err.Error())
}
fmt.Printf("CONFIG OK: %s\n", config_filename)
fmt.Printf("LISTENING AT: %s\n", c.ListenAddress)
for _, v := range os.Args {
if v == "checkConfig" {
return
}
}
log.Fatal(http.ListenAndServe(c.ListenAddress, r))
} }
func webhookHandler(w http.ResponseWriter, r *http.Request) { func webhookHandler(w http.ResponseWriter, r *http.Request) {
var c config.Config
var err = c.Load(config_filename)
if err != nil {
writeResponse(w, 500, "Unable to read config file")
}
// Check what service is specified in URL (/webhooks/{service}) and if it exists
serviceName := string(mux.Vars(r)["service"])
service, ok := c.Services[serviceName]
if !ok {
writeResponse(w, 404, "Service Not Found")
fmt.Printf("Service not found: %v\n", serviceName)
return
}
fmt.Printf("Got webhook for: %v\n", serviceName)
// Read payload or return 500 if that doesn't work out
payload := "" payload := ""
if p, err := ioutil.ReadAll(r.Body); err != nil { if p, err := ioutil.ReadAll(r.Body); err != nil {
writeResponse(w, 500, "Internal Server Error: Could not read payload") writeResponse(w, 500, "Internal Server Error: Could not read payload")
fmt.Println("Error: Could not read payload")
return return
} else { } else {
payload = string(p) payload = string(p)
} }
// check what service is specified in URL (/webhooks/{service}) and if it exists
service, ok := config.Services[string(mux.Vars(r)["service"])]
if !ok {
writeResponse(w, 404, "Service Not Found")
return
}
// Verify that signature provided matches signature calculated using secretsss // Verify that signature provided matches signature calculated using secretsss
signature := r.Header.Get(service.SignatureHeader) signature := r.Header.Get(service.SignatureHeader)
calculatedSignature := getSha256HMACSignature([]byte(service.Secret), payload) calculatedSignature := fmt.Sprintf(
fmt.Printf("signature = %v\n", signature) "%v%v",
fmt.Printf("calcuatedSignature = %v\n", signature) service.SignaturePrefix,
if signature != calculatedSignature && checkSignature { getSha256HMACSignature([]byte(service.Secret), payload),
)
fmt.Printf("signature = %v\n", signature)
fmt.Printf("calcuatedSignature = %v\n", calculatedSignature)
if checkSignature && !service.DisableSignatureVerification && signature != calculatedSignature {
writeResponse(w, 400, "Bad Request: Signatures do not match") writeResponse(w, 400, "Bad Request: Signatures do not match")
fmt.Println("Signatures do not match!")
return return
} }
// Run tests, immediately stop if one fails // Run tests and script as goroutine to prevent timing out
for _, test := range service.Tests { go func() {
if _, err := exec.Command(test.Command, test.Arguments...).Output(); err != nil { // Run tests, immediately stop if one fails
writeResponse(w, 409, for _, test := range service.Tests {
fmt.Sprintf("409 Conflict: Test failed: %v", err.Error()), if _, err := test.Execute(payload); err != nil {
) fmt.Printf("Test failed(%v) for service %v\n", test, serviceName)
return return
}
} }
} stdout, err := service.Script.Execute(payload)
fmt.Println(string(stdout))
if err != nil {
fmt.Println(err.Error())
}
}()
if stdout, err := exec.Command(service.Script, payload).Output(); err != nil { writeResponse(w, 200, "OK")
writeResponse(w, 500, err.Error()) return
return
} else {
writeResponse(w, 200, string(stdout))
return
}
} }
func writeResponse(w http.ResponseWriter, responseCode int, responseString string) { func writeResponse(w http.ResponseWriter, responseCode int, responseString string) {
@@ -100,49 +119,3 @@ func getSha256HMACSignature(secret []byte, data string) string {
io.WriteString(h, data) io.WriteString(h, data)
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
func (c Config) Validate() error {
if c.ListenAddress == "" {
return requiredFieldError{"ListenAddress", ""}
}
for serviceName, service := range c.Services {
if service.Script == "" {
return requiredFieldError{"Script", serviceName}
}
if service.SignatureHeader == "" {
return requiredFieldError{"SignatureHeader", serviceName}
}
if service.Secret == "" {
return requiredFieldError{"Secret", serviceName}
}
}
return nil
}
type Test struct {
Command string
Arguments []string
}
type Service struct {
Script string
Secret string
SignatureHeader string
Tests []Test
}
type Config struct {
ListenAddress string
Services map[string]Service
}
type requiredFieldError struct {
fieldName string
serviceName string
}
func (e requiredFieldError) Error() string {
return fmt.Sprintf("%v cannot be empty (%v)", e.fieldName, e.serviceName)
}

View File

@@ -2,37 +2,56 @@
A _really_ simple webhook receiver, which listens at `/webhooks/<webhook-name>`. A _really_ simple webhook receiver, which listens at `/webhooks/<webhook-name>`.
Default config path is `/etc/gohookr.conf` and can be overriden by setting environment variable
`CONFIG`.
Check below for an example configuration.
## Installation ## Installation
After you [install go](https://golang.org/doc/install): After you [install go](https://golang.org/doc/install):
``` ```
go mod tidy make
go build
cp gohookr /usr/local/bin/
cp gohookr.service /usr/lib/systemd/system/
systemctl daemon-reload
systemctl enable --now gohookr
``` ```
## Signature Verification ## Configuration
Default config path is `/etc/gohookr.json`.
It can be overriden by setting environment variable `CONFIG`.
The config file will be re-read every request so service configs can be changed without restarting
the service (unless you want to change the listening port).
Check below for an example configuration, which should tell you most of the things you need to know
to configure gohookr.
You can test your config file by running
```
gohookr checkConfig
```
### Signature Verification
Signature verificaiton is done using SHA256 HMACs. Signature verificaiton is done using SHA256 HMACs.
You **must** set which HTTP header gohookr will receive a signature from using the `SignatureHeader` You **must** set which HTTP header gohookr will receive a signature from using the `SignatureHeader`
key for each service. key for each service.
You should also specify a shared secret in the `Secret` key. You should also specify a shared secret in the `Secret` key.
### Disable Signature Verification You may also need to specify a `SignaturePrefix`.
For GitHub it would be `sha256=`.
You can disable signature verification altogether by setting environment variable #### Disable Signature Verification
You can disable signature verification by setting `DisableSignatureVerification` for a service to
`true`.
You can disable signature verification for all services by setting environment variable
`NO_SIGNATURE_VERIFICATION` to `true`. `NO_SIGNATURE_VERIFICATION` to `true`.
## Tests ### Writing Commands
gohookr doesn't care what the command is as long as the `Program` is executable.
You can specify extra arguments with the `Arguments` field.
You can ask it to put the payload as the last argument by setting `AppendPayload` to true.
### Writing Tests
gohookr can run test before running your script. gohookr can run test before running your script.
Tests must be in the form of bash scripts. Tests must be in the form of bash scripts.
@@ -40,13 +59,13 @@ A non-zero return code is considered a fail and gohookr will run no further test
deploy. deploy.
Tests are run in the order they're listed so any actions that need to be done before Tests are run in the order they're listed so any actions that need to be done before
real tests are run can simply be put before the tests. tests are run can simply be put in this section before the tests.
## Example Config ### Example Config
Required config keys are `ListenAddress` and `Services`. Required config keys are `ListenAddress` and `Services`.
Requried keys per service are `Script`, `Secret`, `SignatureHeader`. Requried keys per service are `Script.Program`, `Secret`, `SignatureHeader`.
An example config file can be found [here](./config.json) but also below: An example config file can be found [here](./config.json) but also below:
@@ -55,13 +74,30 @@ An example config file can be found [here](./config.json) but also below:
"ListenAddress": "127.0.0.1:8654", "ListenAddress": "127.0.0.1:8654",
"Services": { "Services": {
"test": { "test": {
"Script": "./example.sh", "Script": {
"Secret": "THISISVERYSECRET", "Program": "./example.sh",
"SignatureHeader": "X-Gitea-Signature", "AppendPayload": true
},
"DisableSignatureVerification": true,
"Tests": [ "Tests": [
{ {
"Command": "git", "Program": "echo",
"Arguments": [ "pull" ] "Arguments": [ "test" ]
}
]
},
"fails_tests": {
"Script": {
"Program": "./example.sh",
"AppendPayload": true
},
"Secret": "who_cares",
"SignatureHeader": "X-Hub-Signature-256",
"SignaturePrefix": "sha256=",
"Tests": [
{
"Program": "false",
"Arguments": []
} }
] ]
} }