File weblug-0.5.obscpio of Package weblug
07070100000000000081A4000000000000000000000001660FAA5800000234000000000000000000000000000000000000001600000000weblug-0.5/.gitignore# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
/weblug
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
vendor/
# Task files
.task
# Go workspace file
go.work
# Possible key and cert files
*.key
*.pem
*.cert
07070100000001000081A4000000000000000000000001660FAA5800000436000000000000000000000000000000000000001300000000weblug-0.5/LICENSEMIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
07070100000002000081A4000000000000000000000001660FAA58000000EC000000000000000000000000000000000000001400000000weblug-0.5/Makefiledefault: all
all: weblug
.PHONY: test
weblug: cmd/weblug/*.go
go build -o weblug $^
static: cmd/weblug/*.go
CGO_ENABLED=0 go build -ldflags="-w -s" -o weblug $^
test: weblug
go test ./...
sudo bash -c "cd test && ./blackbox.sh"
07070100000003000081A4000000000000000000000001660FAA580000081D000000000000000000000000000000000000001500000000weblug-0.5/README.md# weblug
`weblug` is is a configurable webhook receiver that allows users to run arbitrary programs and script when a webhook is triggered.
The configuration happens via a [yaml file](weblug.yml). Read the [usage caveats](#caveats)!
`weblug` supports multiple webhooks via different URL paths, concurrency limitations, background execution and running webhooks as separate user (`uid`/`gid`).
## Usage
Webooks are defined via a yaml file. See [weblug.yml](weblug.yml) for an example configuration. Pass the yaml file(s) to `weblug` to starting the webserver and waiting for incoming webhooks:
./weblug YAML-FILE
`weblug` can run as any user, however for custom `uid`/`gid` webhooks, the program needs to run as root.
### Caveats
1. `weblug` should not face the open internet
weblug is expected to run behind a http reverse proxy (e.g. `apache` or `nginx`).
While the program itself does support tls, to avoid a whole class of security issues, `weblug` should never run on the open internet without a http reverse proxy.
2. `weblug` runs as root, when using custom UID/GIDs
In it's current implementation, `weblug` requires to remain running as root without dropping privileges when using custom UID/GIDs.
### TLS
`weblug` now supports tls. To generate self-signed keys
```
openssl genrsa -out weblug.key 2048
openssl req -new -x509 -sha256 -key weblug.key -out weblug1.pem -days 365
```
Then configure the `weblug.yml` file accordingly. You can use multiple keys/certificates.
## Build
make # Build weblug
make static # Make a static binary
Alternatively you there is also a [Taskfile](https://taskfile.dev)
task
task static # Build static binary
## Run as systemd unit
This repository provides an example [weblug.service](weblug.service), which can be used to start `weblug` as systemd service.
This file can be placed in `/etc/systemd/system/weblug.service` and in conjunction with an adequate `weblug.yml` file e.g. in `/etc/weblug.yml` this provides a way of running weblug as a native systemd service.
07070100000004000081A4000000000000000000000001660FAA5800000490000000000000000000000000000000000000001800000000weblug-0.5/Taskfile.yml# https://taskfile.dev
version: '3'
tasks:
default:
cmds:
- go build -o weblug cmd/weblug/*.go
silent: false
aliases: [weblug]
generates:
- weblug
sources:
- cmd/weblug/*.go
static:
cmds:
- go build -ldflags="-w -s" -o weblug cmd/weblug/*.go
env:
CGO_ENABLED: '0'
silent: false
aliases: [weblug-static]
generates:
- weblug
sources:
- cmd/weblug/*.go
test:
deps: [weblug]
# Ensure a weblug binary is present
preconditions:
- test -f weblug
cmds:
- go test ./...
- sudo bash -c "cd test && ./blackbox.sh"
vet:
cmds:
- go vet ./...
certs:
cmds:
- openssl genrsa -out weblug.key 2048
- openssl req -new -x509 -sha256 -key weblug.key -subj "/C=XX/ST=None/L=Nirwana/O=Adeptus Mechanicus/OU=Cult Mechanicus/CN=example1.local" -addext "subjectAltName = DNS:example1.local" -out weblug1.pem -days 365
- openssl req -new -x509 -sha256 -key weblug.key -subj "/C=XX/ST=None/L=Nirwana/O=Adeptus Mechanicus/OU=Cult Mechanicus/CN=example1.local" -addext "subjectAltName = DNS:example1.local" -out weblug2.pem -days 365
07070100000005000041ED000000000000000000000002660FAA5800000000000000000000000000000000000000000000000F00000000weblug-0.5/cmd07070100000006000041ED000000000000000000000002660FAA5800000000000000000000000000000000000000000000001600000000weblug-0.5/cmd/weblug07070100000007000081A4000000000000000000000001660FAA5800000A1A000000000000000000000000000000000000002000000000weblug-0.5/cmd/weblug/config.gopackage main
import (
"crypto/tls"
"fmt"
"io/ioutil"
"gopkg.in/yaml.v2"
)
var cf Config
type Config struct {
Settings ConfigSettings `yaml:"settings"`
Hooks []Hook `yaml:"hooks"`
}
type ConfigSettings struct {
BindAddress string `yaml:"bind"` // Bind address for the webserver
UID int `yaml:"uid"` // Custom user ID or 0, if not being used
GID int `yaml:"gid"` // Custom group ID or 0, if not being used
ReadTimeout int `yaml:"readtimeout"` // Timeout for reading the whole request
WriteTimeout int `yaml:"writetimeout"` // Timeout for writing the whole response
MaxHeaderBytes int `yaml:"maxheadersize"` // Maximum size of the receive header
MaxBodySize int64 `yaml:"maxbodysize"` // Maximum size of the receive body
TLS TLSSettings `yaml:"tls"`
}
type TLSSettings struct {
Enabled bool `yaml:"enabled"`
MinVersion string `yaml:"minversion"`
MaxVersion string `yaml:"maxversion"`
Keypairs []TLSKeypairs `yaml:"keypairs"`
}
type TLSKeypairs struct {
Keyfile string `yaml:"keyfile"`
Certificate string `yaml:"certificate"`
}
func (cf *Config) SetDefaults() {
cf.Settings.BindAddress = ":2088"
cf.Settings.UID = 0
cf.Settings.GID = 0
cf.Settings.ReadTimeout = 0
cf.Settings.WriteTimeout = 0
cf.Settings.MaxHeaderBytes = 0
}
// Check performs sanity checks on the config
func (cf *Config) Check() error {
if cf.Settings.BindAddress == "" {
return fmt.Errorf("no bind address configured")
}
for _, hook := range cf.Hooks {
if hook.Name == "" {
return fmt.Errorf("hook without name")
}
if hook.Route == "" {
return fmt.Errorf("hook %s with no route", hook.Name)
}
if hook.Command == "" {
return fmt.Errorf("hook %s with no command", hook.Name)
}
if hook.Concurrency < 1 {
hook.Concurrency = 1
}
}
return nil
}
func (cf *Config) LoadYAML(filename string) error {
content, err := ioutil.ReadFile(filename)
if err != nil {
return err
}
if err := yaml.Unmarshal(content, cf); err != nil {
return err
}
return cf.Check()
}
func ParseTLSVersion(version string) (uint16, error) {
if version == "" {
return tls.VersionTLS12, nil
} else if version == "1.3" {
return tls.VersionTLS13, nil
} else if version == "1.2" {
return tls.VersionTLS12, nil
} else if version == "1.1" {
return tls.VersionTLS11, nil
} else if version == "1.0" {
return tls.VersionTLS10, nil
} else {
return 0, fmt.Errorf("invalid tls version string")
}
}
07070100000008000081A4000000000000000000000001660FAA5800001711000000000000000000000000000000000000001E00000000weblug-0.5/cmd/weblug/hook.gopackage main
import (
"bytes"
"fmt"
"net"
"os/exec"
"strings"
"sync/atomic"
"syscall"
)
type Hook struct {
Name string `yaml:"name"` // name of the hook
Route string `yaml:"route"` // http route
Hosts []string `yaml:"hosts"` // allowed remote hosts
Command string `yaml:"command"` // Actual command to execute
Background bool `yaml:"background"` // Run in background
Concurrency int `yaml:"concurrency"` // Number of allowed concurrent runs
concurrentRuns int32 // Number of current concurrent runs
UID int `yaml:"uid"` // UID to use when running the command
GID int `yaml:"gid"` // GID to use when running the command
Output bool `yaml:"output"` // Print program output
AllowAddresses []string `yaml:"allowed"` // Addresses that are explicitly allowed
BlockedAddresses []string `yaml:"blocked"` // Addresses that are explicitly blocked
HttpBasicAuth BasicAuth `yaml:"basic_auth"` // Optional requires http basic auth
Env map[string]string `yaml:"env"` // Optional environment variables
maxBodySize int64 // Maximum allowed body size for this hook
}
type BasicAuth struct {
Username string `yaml:"username"` // Optional: Required username for the webhook to be allowed. If empty, any username will be accepted
Password string `yaml:"password"` // If set, the http basic auth is enabled and the request must contain this password for being allowed
}
// Tries to lock a spot. Returns false, if the max. number of concurrent runs has been reached
func (hook *Hook) TryLock() bool {
res := int(atomic.AddInt32(&hook.concurrentRuns, 1))
if res > hook.Concurrency {
atomic.AddInt32(&hook.concurrentRuns, -1)
return false
}
return true
}
func (hook *Hook) Unlock() {
atomic.AddInt32(&hook.concurrentRuns, -1)
}
// Split a command into program arguments, obey quotation mark escapes
func cmdSplit(command string) []string {
null := rune(0)
esc := null // Escape character or \0 if not escaped currently
ret := make([]string, 0)
buf := "" // Current command
for _, char := range command {
if esc != null {
if char == esc {
esc = null
} else {
buf += string(char)
}
} else {
// Check for quotation marks
if char == '\'' || char == '"' {
esc = char
} else if char == ' ' {
ret = append(ret, buf)
buf = ""
} else {
buf += string(char)
}
}
}
// Remaining characters
if buf != "" {
ret = append(ret, buf)
buf = ""
}
return ret
}
// Run executes the given command and return it's return code.
// Will pass the given input string to the command
// It also respects the given concurrency number and will block until resources are free
func (hook *Hook) Run(buffer []byte) ([]byte, error) {
if hook.Command == "" {
return make([]byte, 0), nil
}
split := cmdSplit(hook.Command)
args := make([]string, 0)
if len(split) > 1 {
args = split[1:]
}
cmd := exec.Command(split[0], args...)
if hook.UID > 0 || hook.GID > 0 {
cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(hook.UID), Gid: uint32(hook.GID)}
}
cmd.Env = make([]string, 0)
if hook.Env != nil {
// Build environment variable list as expected by cmd.Env
for k, v := range hook.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
cmd.Stdin = bytes.NewReader(buffer)
return cmd.Output()
}
func isAddressInList(addr string, addrList []string) (bool, error) {
ip, _, err := net.ParseCIDR(addr)
if err != nil {
return false, err
}
for _, item := range addrList {
iAddr, iNet, err := net.ParseCIDR(item)
if err != nil {
return false, err
}
if ip.Equal(iAddr) {
return true, nil
}
if iNet.Contains(ip) {
return true, nil
}
}
return false, nil
}
// Extract only the CIDR address from the given address identifier. This removes the port, if present
func cidr(addr string) string {
// Check for IPv6 address
s, e := strings.Index(addr, "["), strings.Index(addr, "]")
if s >= 0 && e > 0 {
return addr[s+1:e] + "/128"
}
// Simply remove the port
i := strings.Index(addr, ":")
if i > 0 {
return addr[:i-1] + "/32"
}
return addr + "/32"
}
// IsAddressAllowed checks if the hook allows the given address. An address is allowed, if it is present in the AllowAddresses list (if non-empty) and if it is not present in the BlockedAddresses list (if non-empty)
func (hook *Hook) IsAddressAllowed(addr string) (bool, error) {
if addr == "" {
// If we cannot determine the source address, but there are element in either the Allow or the Block list, the only safe thing we can do is to reject
if hook.AllowAddresses != nil && len(hook.AllowAddresses) > 0 {
return false, fmt.Errorf("no source address")
}
if hook.BlockedAddresses != nil && len(hook.BlockedAddresses) > 0 {
return false, fmt.Errorf("no source address")
}
}
addr = cidr(addr)
if hook.AllowAddresses != nil && len(hook.AllowAddresses) > 0 {
// If AllowAddresses is defined and not empty, the given addr must be in the AllowAddresses list
found, err := isAddressInList(addr, hook.AllowAddresses)
if err != nil {
return false, err
}
// If not present in the list, block the request. Otherwise we still need to pass the BlockedAddresses check
if !found {
return false, err
}
}
if hook.BlockedAddresses != nil && len(hook.BlockedAddresses) > 0 {
// If BlockedAddresses is defined and not empty, the given addr must not be in the BlockedAddresses list
found, err := isAddressInList(addr, hook.BlockedAddresses)
if err != nil {
return false, err
}
if found {
return false, err
}
}
return true, nil
}
07070100000009000081A4000000000000000000000001660FAA5800002C3C000000000000000000000000000000000000002000000000weblug-0.5/cmd/weblug/weblug.go/*
* weblug main program
*/
package main
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type Handler func(http.ResponseWriter, *http.Request)
func usage() {
fmt.Println("weblug is a webhook receiver")
fmt.Printf("Usage: %s [OPTIONS] YAML1[,YAML2...]\n\n", os.Args[0])
fmt.Println("OPTIONS")
fmt.Println(" -h, --help Print this help message")
fmt.Println()
fmt.Println("The program loads the given yaml files for webhook definitions")
}
// awaits SIGINT or SIGTERM
func awaitTerminationSignal() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println(sig)
os.Exit(1)
}()
}
// Perform sanity check on hooks
func sanityCheckHooks(hooks []Hook) error {
// Check UID and GID settings. When hooks have their own UID and GID settings, we need the main program to run as root (required for setgid/setuid)
uid := cf.Settings.UID
if uid == 0 {
uid = os.Getuid()
}
for _, hook := range hooks {
// If a hook sets a custom uid or gid, ensure we're running as root, otherwise print a warning
if hook.UID != 0 && uid != 0 {
fmt.Fprintf(os.Stderr, "Warning: Hook '%s' sets 'uid = %d' but we're not running as root\n", hook.Name, hook.UID)
}
if hook.GID != 0 && uid != 0 {
fmt.Fprintf(os.Stderr, "Warning: Hook '%s' sets 'gid = %d' but we're not running as root\n", hook.Name, hook.GID)
}
}
return nil
}
// Extract the hostname from an URL
func hostname(url string) string {
hostname := url
i := strings.Index(hostname, "://")
if i > 0 {
hostname = hostname[i+3:]
}
i = strings.Index(hostname, ":")
if i > 0 {
hostname = hostname[:i]
}
return hostname
}
func main() {
cf.SetDefaults()
if len(os.Args) < 2 {
usage()
}
for _, arg := range os.Args[1:] {
if arg == "" {
continue
} else if arg == "-h" || arg == "--help" {
usage()
os.Exit(0)
} else {
if err := cf.LoadYAML((arg)); err != nil {
fmt.Fprintf(os.Stderr, "yaml error: %s\n", err)
os.Exit(1)
}
}
}
if len(cf.Hooks) == 0 {
fmt.Fprintf(os.Stderr, "error: no webhooks defined\n")
os.Exit(2)
}
// Sanity check
if err := sanityCheckHooks(cf.Hooks); err != nil {
fmt.Fprintf(os.Stderr, "hook sanity check failed: %s\n", err)
os.Exit(3)
}
// Drop privileges?
if cf.Settings.GID != 0 {
if err := syscall.Setgid(cf.Settings.GID); err != nil {
fmt.Fprintf(os.Stderr, "setgid failed: %s\n", err)
os.Exit(1)
}
}
if cf.Settings.UID != 0 {
if err := syscall.Setuid(cf.Settings.UID); err != nil {
fmt.Fprintf(os.Stderr, "setuid failed: %s\n", err)
os.Exit(1)
}
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
for i, hook := range cf.Hooks {
log.Printf("Webhook %d: '%s' [%s] \"%s\"\n", i, hook.Name, hook.Route, hook.Command)
}
awaitTerminationSignal()
var listener net.Listener
var err error
if cf.Settings.TLS.Enabled {
log.Printf("Launching tls webserver on %s", cf.Settings.BindAddress)
listener, err = CreateTLSListener(cf)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
} else {
log.Printf("Launching webserver on %s", cf.Settings.BindAddress)
listener, err = CreateListener(cf)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
}
server.Serve(listener)
// read guard, should never ever ever be called.
// If we end up here, the only safe thing we can do is terminate the program
panic("unexpected end of main loop")
}
func CreateListener(cf Config) (net.Listener, error) {
return net.Listen("tcp", cf.Settings.BindAddress)
}
func CreateTLSListener(cf Config) (net.Listener, error) {
var err error
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if cf.Settings.TLS.MinVersion != "" {
tlsConfig.MinVersion, err = ParseTLSVersion(cf.Settings.TLS.MinVersion)
if err != nil {
return nil, fmt.Errorf("invalid tls min version")
}
}
if cf.Settings.TLS.MaxVersion != "" {
tlsConfig.MaxVersion, err = ParseTLSVersion(cf.Settings.TLS.MaxVersion)
if err != nil {
return nil, fmt.Errorf("invalid tls max version")
}
}
if tlsConfig.MinVersion == tls.VersionTLS10 || tlsConfig.MinVersion == tls.VersionTLS11 {
fmt.Fprintf(os.Stderr, "warning: using of a deprecated TLS version (< 1.2) is not recommended\n")
}
// Create self-signed certificate, when no keyfile and no certificates are present
if len(cf.Settings.TLS.Keypairs) == 0 {
return nil, fmt.Errorf("no keypairs provided")
} else {
// Load key/certificates keypairs
tlsConfig.Certificates = make([]tls.Certificate, len(cf.Settings.TLS.Keypairs))
for i, keypair := range cf.Settings.TLS.Keypairs {
tlsConfig.Certificates[i], err = tls.LoadX509KeyPair(keypair.Certificate, keypair.Keyfile)
if err != nil {
return nil, fmt.Errorf("invalid tls keypair '%s,%s' - %s", keypair.Certificate, keypair.Keyfile, err)
}
}
if len(tlsConfig.Certificates) == 1 {
log.Printf("Loaded 1 tls certificate")
} else {
log.Printf("Loaded %d tls certificates", len(tlsConfig.Certificates))
}
}
return tls.Listen("tcp", cf.Settings.BindAddress, tlsConfig)
}
func CreateWebserver(cf Config) *http.Server {
server := &http.Server{
Addr: cf.Settings.BindAddress,
ReadTimeout: time.Duration(cf.Settings.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cf.Settings.WriteTimeout) * time.Second,
MaxHeaderBytes: cf.Settings.MaxHeaderBytes,
}
return server
}
func RegisterHandlers(cf Config, mux *http.ServeMux) error {
// Register default paths
mux.HandleFunc("/", createDefaultHandler())
mux.HandleFunc("/health", createHealthHandler())
mux.HandleFunc("/health.json", createHealthHandler())
mux.HandleFunc("/robots.txt", createRobotsHandler())
// Register hooks
for _, hook := range cf.Hooks {
if hook.Route == "" {
return fmt.Errorf("no route defined in hook %s", hook.Name)
}
if hook.Concurrency < 1 {
hook.Concurrency = 1
}
// allow hooks to have individual maxBodySize arguments.
if cf.Settings.MaxBodySize > 0 && hook.maxBodySize == 0 {
hook.maxBodySize = cf.Settings.MaxBodySize
}
mux.HandleFunc(hook.Route, createHandler(hook))
}
return nil
}
// create a http handler function from the given hook
func createHandler(hook Hook) Handler {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("GET %s %s", r.RemoteAddr, hook.Name)
// Check if this hook is remote-host limited
if len(hook.Hosts) > 0 {
allowed := false
// Extract the queried hostname
queriedHost := hostname(r.Host)
for _, host := range hook.Hosts {
if host == queriedHost {
allowed = true
break
}
}
if !allowed {
// Hook doesn't exist for this host.
respondNotFound(w, r)
return
}
}
// Check if adresses are allowed or blocked
allowed, err := hook.IsAddressAllowed(r.RemoteAddr)
if err != nil {
log.Printf("ERR: Error checking for address permissions for hook \"%s\": %s", hook.Name, err)
w.WriteHeader(500)
fmt.Fprintf(w, "{\"status\":\"fail\",\"reason\":\"server error\"}")
return
}
if !allowed {
log.Printf("Blocked: '%s' for not allowed remote end %s", hook.Name, r.RemoteAddr)
// Return a 403 - Forbidden
w.WriteHeader(403)
fmt.Fprintf(w, "{\"status\":\"blocked\",\"reason\":\"address not allowed\"}")
return
}
// Check for basic auth, if enabled
if hook.HttpBasicAuth.Password != "" {
username, password, ok := r.BasicAuth()
if !ok {
// Return a 403 - Forbidden
w.WriteHeader(401)
fmt.Fprintf(w, "{\"status\":\"unauthorized\",\"message\":\"webhook requires authentication\"}")
return
}
if hook.HttpBasicAuth.Password != password || (hook.HttpBasicAuth.Username != "" && hook.HttpBasicAuth.Username != username) {
// Return a 403 - Forbidden
w.WriteHeader(403)
fmt.Fprintf(w, "{\"status\":\"blocked\",\"reason\":\"not authenticated\"}")
return
}
}
// Check for available slots
if !hook.TryLock() {
log.Printf("ERR: \"%s\" max concurrency reached", hook.Name)
// Return a 503 - Service Unavailable error
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Retry-After", "120") // Suggest to retry after 2 minutes
w.WriteHeader(503)
fmt.Fprintf(w, "{\"status\":\"fail\",\"reason\":\"max concurrency reached\"}")
return
}
// Input buffer used to pass the headers to the process
var buffer bytes.Buffer
for k, v := range r.Header {
buffer.WriteString(fmt.Sprintf("%s:%s\n", k, strings.Join(v, ",")))
}
buffer.WriteString("\n")
// Receive body only if configured. By default the body is ignored.
if hook.maxBodySize > 0 {
body := make([]byte, hook.maxBodySize)
n, err := r.Body.Read(body)
if err != nil && err != io.EOF {
log.Printf("receive body failed: %s", err)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(500)
fmt.Fprintf(w, "{\"status\":\"fail\",\"reason\":\"receive failure\"}")
return
}
buffer.Write(body[:n])
}
if hook.Background { // Execute command in background
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, "{\"status\":\"ok\"}")
go func() {
defer hook.Unlock()
buffer, err := hook.Run(buffer.Bytes())
if hook.Output {
fmt.Println(string(buffer))
}
if err != nil {
log.Printf("Hook \"%s\" failed: %s", hook.Name, err)
} else {
log.Printf("Hook \"%s\" completed", hook.Name)
}
}()
} else {
defer hook.Unlock()
buffer, err := hook.Run(buffer.Bytes())
if hook.Output {
fmt.Println(string(buffer))
}
if err != nil {
log.Printf("ERR: \"%s\" exec failure: %s", hook.Name, err)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(500)
fmt.Fprintf(w, "{\"status\":\"fail\",\"reason\":\"program error\"}")
} else {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, "{\"status\":\"ok\"}")
}
}
}
}
func createHealthHandler() Handler {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, "{\"status\":\"ok\"}")
}
}
func createDefaultHandler() Handler {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.txt" {
w.WriteHeader(200)
fmt.Fprintf(w, "weblug - webhook receiver program\nhttps://codeberg.org/grisu48/weblug\n")
} else if r.URL.Path == "/index.htm" || r.URL.Path == "/index.html" {
w.WriteHeader(200)
fmt.Fprintf(w, "<!DOCTYPE html><html><head><title>weblug</title></head>\n<body><p><a href=\"https://codeberg.org/grisu48/weblug\">weblug</a> - webhook receiver program</p>\n</body></html>")
} else {
respondNotFound(w, r)
}
}
}
func createRobotsHandler() Handler {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintf(w, "User-agent: *\nDisallow: /")
}
}
func respondNotFound(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(404)
_, err := fmt.Fprintf(w, "not found\n")
return err
}
0707010000000A000081A4000000000000000000000001660FAA58000034ED000000000000000000000000000000000000002500000000weblug-0.5/cmd/weblug/weblug_test.gopackage main
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestMain(m *testing.M) {
// Run tests
ret := m.Run()
os.Exit(ret)
}
// Test the general webserver functionalities
func TestWebserver(t *testing.T) {
var cf Config
cf.Settings.BindAddress = "127.0.0.1:2088"
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Route: "/test1", Name: "test1", Command: ""})
cf.Hooks = append(cf.Hooks, Hook{Route: "/test2", Name: "test2", Command: ""})
listener, err := CreateListener(cf)
if err != nil {
t.Fatalf("error creating listener: %s", err)
return
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
t.Fatalf("error registering handlers: %s", err)
return
}
go server.Serve(listener)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
assertStatusCode := func(url string, statusCode int) {
resp, err := http.Get(url)
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != statusCode {
t.Fatalf("GET / returns status code %d != %d", resp.StatusCode, statusCode)
}
}
// Check default sites
assertStatusCode(fmt.Sprintf("http://%s/", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/health", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/health.json", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/robots.txt", cf.Settings.BindAddress), http.StatusOK)
// Check for a 404 page
assertStatusCode(fmt.Sprintf("http://%s/404", cf.Settings.BindAddress), http.StatusNotFound)
assertStatusCode(fmt.Sprintf("http://%s/test3", cf.Settings.BindAddress), http.StatusNotFound)
// Test registered hooks
assertStatusCode(fmt.Sprintf("http://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/test2", cf.Settings.BindAddress), http.StatusOK)
}
// Tests the TLS functions of the webserver
func TestTLSWebserver(t *testing.T) {
var cf Config
const TESTPORT = 2089
// Test keypairs. testkey1 belongs to the "localhost" host and testkey2 belongs to the "localhost" and "example.com" hosts
keypairs := make([]TLSKeypairs, 0)
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey1.pem", Certificate: "testcert1.pem"})
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey2.pem", Certificate: "testcert2.pem"})
// Generate test certificates
for i, keypair := range keypairs {
if fileExists(keypair.Keyfile) {
t.Fatalf("test key '%s' already exists", keypair.Keyfile)
return
}
if fileExists(keypair.Certificate) {
t.Fatalf("test certificate '%s' already exists", keypair.Certificate)
return
}
hostnames := []string{"localhost"}
if i == 1 {
hostnames = append(hostnames, "example.com")
}
if err := generateKeypair(keypair.Keyfile, keypair.Certificate, hostnames); err != nil {
t.Fatalf("keypair generation failed: %s\n", err)
return
}
defer func(keypair TLSKeypairs) {
os.Remove(keypair.Keyfile)
os.Remove(keypair.Certificate)
}(keypair)
}
cf.Settings.BindAddress = fmt.Sprintf("localhost:%d", TESTPORT)
cf.Settings.TLS.Enabled = true
cf.Settings.TLS.MinVersion = "1.3"
cf.Settings.TLS.MaxVersion = "1.3"
cf.Settings.TLS.Keypairs = keypairs
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Route: "/test1", Name: "test1", Command: "", Hosts: []string{"localhost"}})
cf.Hooks = append(cf.Hooks, Hook{Route: "/test2", Name: "test2", Command: "", Hosts: []string{"localhost", "example.com"}})
// Setup TLS webserver
listener, err := CreateTLSListener(cf)
if err != nil {
t.Fatalf("error creating tls listener: %s", err)
return
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
t.Fatalf("error registering handlers: %s", err)
return
}
go server.Serve(listener)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
// Default page without https should return a 400 error
resp, err := http.Get(fmt.Sprintf("http://%s/", cf.Settings.BindAddress))
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("GET / returns status code %d for default page (400 expected)", resp.StatusCode)
}
// Check default page with tls certificates
certs := make([]tls.Certificate, 0)
rootCAs, _ := x509.SystemCertPool()
for i, keypair := range keypairs {
x509cert, err := readCertificate(keypair.Certificate)
if err != nil {
t.Fatalf("error loading certificate %d: %s", i, err)
return
}
raw := make([][]byte, 0)
raw = append(raw, x509cert.Raw)
certs = append(certs, tls.Certificate{Certificate: raw})
rootCAs.AddCert(x509cert)
}
dialer := &net.Dialer{}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: certs,
RootCAs: rootCAs,
},
// Mock connections to example.com -> 127.0.0.1
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if strings.Contains(addr, "example.com") {
addr = strings.ReplaceAll(addr, "example.com", "127.0.0.1")
}
return dialer.DialContext(ctx, network, addr)
},
}
client := http.Client{Transport: transport, Timeout: 15 * time.Second}
assertStatusCode := func(url string, statusCode int) {
resp, err = client.Get(url)
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != statusCode {
t.Fatalf("GET / returns status code %d != %d", resp.StatusCode, statusCode)
}
}
fetchBody := func(url string) (string, error) {
resp, err = client.Get(url)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
return string(body), err
}
// Check default page and test hooks
assertStatusCode(fmt.Sprintf("https://%s/", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test2", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress), http.StatusNotFound)
// Check if connection via TLS 1.2 is not accepted (we're enforcing TLS >= 1.3)
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
transport.TLSClientConfig.MaxVersion = tls.VersionTLS12
resp, err = client.Get(fmt.Sprintf("https://%s/", cf.Settings.BindAddress))
if err == nil {
t.Fatal("tls 1.2 connection possible where it should be unsupported", err)
return
} else {
// TODO: Matching by string might be flanky.
if !strings.Contains(err.Error(), "tls: protocol version not supported") {
t.Fatalf("%s", err)
return
}
}
transport.TLSClientConfig.MaxVersion = tls.VersionTLS13
// Check if example.com resolves (second certificate)
assertStatusCode(fmt.Sprintf("https://example.com:%d/", TESTPORT), http.StatusOK)
// Only /test2 should be reachable via example.com
assertStatusCode(fmt.Sprintf("https://example.com:%d/test1", TESTPORT), http.StatusNotFound)
assertStatusCode(fmt.Sprintf("https://example.com:%d/test2", TESTPORT), http.StatusOK)
// Assert, that the host 404 page is the same as the 404 page for a route that doesn't exist.
// This check is needed, because we pretend a path to not exist, if `hosts` is configured and
// we don't want to give attackers the possibility to distinguish between the two 404 errors
if body1, err := fetchBody(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress)); err != nil {
t.Fatalf("%s", err)
return
} else if body2, err := fetchBody(fmt.Sprintf("https://example.com:%d/test1", TESTPORT)); err != nil {
t.Fatalf("%s", err)
return
} else {
if body1 != body2 {
t.Fatal("404 bodies differ between default 404 page and host-not-matched route", err)
return
}
}
}
// Tests the run hook commands
func TestRunHook(t *testing.T) {
testText := "hello Test"
hook := Hook{Name: "hook", Command: "cat"}
buffer, err := hook.Run([]byte(testText))
if err != nil {
t.Fatalf("running test hook failed: %s", err)
}
ret := string(buffer)
if ret != testText {
t.Error("returned string mismatch")
}
}
// Tests passing the request header and body
func TestHeaderAndBody(t *testing.T) {
// Create temp file
tempFile, err := os.CreateTemp("", "test_header_body_*")
if err != nil {
panic(err)
}
defer func() {
os.Remove(tempFile.Name())
}()
// Create test webserver with receive hook, that passes all headers and the body to the temp file
var cf Config
bodyIncluded := "this is the request body\nit is awesome"
bodyIgnored := "this part of the body should be ignored\nIt is hopefully not present"
bodyText := fmt.Sprintf("%s\n%s", bodyIncluded, bodyIgnored)
cf.Settings.BindAddress = "127.0.0.1:2088"
cf.Settings.MaxBodySize = int64(len(bodyIncluded))
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Name: "hook", Command: fmt.Sprintf("tee %s", tempFile.Name()), Route: "/header_and_body"})
listener, err := CreateListener(cf)
if err != nil {
t.Fatalf("error creating listener: %s", err)
return
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
t.Fatalf("error registering handlers: %s", err)
return
}
go server.Serve(listener)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
// Create http request with custom headers and a message body
client := &http.Client{}
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/header_and_body", cf.Settings.BindAddress), nil)
if err != nil {
panic(err)
}
headers := make(map[string]string, 0)
headers["Header1"] = "value1"
headers["Header2"] = "value2"
headers["Header3"] = "value3"
headers["Content-Type"] = "this is the content type"
for k, v := range headers {
req.Header.Set(k, v)
}
req.Body = io.NopCloser(bytes.NewReader([]byte(bodyText)))
res, err := client.Do(req)
if err != nil {
t.Fatalf("http request error: %s", err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("http request failed: %d != %d", res.StatusCode, http.StatusOK)
}
// Assert that the headers and the body is in the test file
buf, err := os.ReadFile(tempFile.Name())
if err != nil {
panic(err)
}
contents := string(buf)
assertHeader := func(key string, value string) {
if !strings.Contains(contents, key) {
t.Fatalf("Header %s is not present", key)
}
if !strings.Contains(contents, fmt.Sprintf("%s:%s\n", key, value)) {
t.Fatalf("Header %s has not the right value", key)
}
}
for k, v := range headers {
assertHeader(k, v)
}
// Assert the message body got passed as well
if !strings.Contains(contents, bodyIncluded) {
t.Fatal("Message body was not passed")
}
// Assert the messaeg body got cropped
if strings.Contains(contents, bodyIgnored) {
t.Fatal("Cut-off after max-body size didn't happen")
}
}
func generateKeypair(keyfile string, certfile string, hostnames []string) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
// Write key to file
var buffer []byte = x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: buffer,
}
file, err := os.Create(keyfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0400)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
// Generate certificate
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * 10 * time.Hour)
//Create certificate templet
template := x509.Certificate{
SerialNumber: big.NewInt(0),
Subject: pkix.Name{CommonName: hostnames[0]},
SignatureAlgorithm: x509.SHA256WithRSA,
DNSNames: hostnames,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
//Create certificate using templet
cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return err
}
block = &pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
}
file, err = os.Create(certfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0644)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func readCertificate(certfile string) (*x509.Certificate, error) {
buffer, err := os.ReadFile(certfile)
if err != nil {
return nil, err
}
p, _ := pem.Decode(buffer)
if p == nil {
return nil, fmt.Errorf("invalid pem file")
}
cert, err := x509.ParseCertificate(p.Bytes)
return cert, err
}
func fileExists(filename string) bool {
st, err := os.Stat(filename)
if err != nil {
return false
}
return !st.IsDir()
}
0707010000000B000041ED000000000000000000000002660FAA5800000000000000000000000000000000000000000000000F00000000weblug-0.5/doc0707010000000C000081A4000000000000000000000001660FAA580000122E000000000000000000000000000000000000001800000000weblug-0.5/doc/weblug.8." Manpage for weblug
." Write me an email <felix@feldspaten.org> if you find errors and/or typos. Thank you! :-)
.TH weblug 8 "28 May 2023" "1.0" "weblug man page"
.SH NAME
weblug - Simple webhook receiver program
.SH SYNOPSIS
weblug [OPTIONS] YAML1[,YAML2...]
.SH DESCRIPTION
weblug is is a configurable webhook receiver that allows to define custom programs and script to be executed when a webhook is triggered.
The configuration happens via yaml files. weblug supports multiple webhooks, limitations for concurrent web hooks to be executed, background execution and running webhooks as separate user (uid/gid) and basic auth.
The system daemon uses the /etc/weblug.yml file. To enable the daemon, edit /etc/weblug.yml to your needs and then simply start/enable the system service.
.SH OPTIONS
.TP
.B -h|--help
Print help message
.SH CAVEATS
1. weblug should always run behind a http reverse proxy to avoid a whole class of security issues by using the standart go webserver implementation.
2. weblug does not support transport encryption (https). To protect access credentials/tokens, it must run behind a http reverse proxy with configured transport encryption.
3. weblug should not be exposed to the public internet.
4. Custom UID/GIDs for webhook require weblug to run as root.
.SH CONFIGURATION FILES
.TP
weblug needs a configuration file with webhook definitions to run. The program needs at least one configuration file, multiple files are supported.
See the following example configuration file:
.B "---
.br
.B "## Weblug example config
.br
.B "settings:
.br
.B " #bind: "127.0.0.1:2088" # bind address for webserver
.br
.B " bind: ":2088" # bind to all addresses
.br
.B " uid: 0 # run under specified user id
.br
.B " gid: 0 # run under specified group id
.br
.B " # Enable TLS here here
.br
.B " tls:
.br
.B " enabled: true
.br
.B " # Minimum and maximum required TLS version. By default TLS1.2 is the minimum
.br
.B " minversion: '1.2'
.br
.B " maxversion: ''
.br
.B " keypairs:
.br
.B " - keyfile: 'weblug.key'
.br
.B " certificate: 'weblug1.pem'
.br
.B " - keyfile: 'weblug.key'
.br
.B " certificate: 'weblug2.pem'
.br
.br
.B "# hook definitions. A hook needs to define the HTTP endpoint ("route") and the command
.br
.B "# See the following examples for more possible options.
.br
.B "hooks:
.br
.B " - name: 'hook one'
.br
.B " route: "/webhooks/1"
.br
.B " # if hosts is present, then limit the incoming requests to the given remote host(s)
.br
.B " # Currently multiplexing the same route to different hosts does not work
.br
.B " hosts:
.br
.B " - example1.local
.br
.B " - example2.local
.br
.B " command: "sleep 5"
.br
.B " background: True # Terminate http request immediately
.br
.B " concurrency: 2 # At most 2 parallel processes are allowed
.br
.B " env: # Define environment variables
.br
.B " KEY1: "VALUE1"
.br
.B " KEY2: "VALUE2"
.br
.br
.br
.br
.B " - name: 'hook two'
.br
.B " route: "/webhooks/2"
.br
.B " command: "bash -c 'sleep 5'"
.br
.B " concurrency: 5 # At most 5 parallel processes are allowed
.br
.br
.br
.B " - name: 'hook 3'
.br
.B " route: "/webhooks/data/3"
.br
.B " command: "bash -c 'echo $UID $GID'"
.br
.B " uid: 100 # Run command as system user id (uid) 100
.br
.B " gid: 200 # Run command with system group id (gid) 200
.br
.B " concurrency: 1 # No concurrency. Returns 500 on parallel requests
.br
.B " output: True # Print program output to console
.br
.br
.br
.B " - name: 'hook 4'
.br
.B " route: "/webhooks/restricted/4"
.br
.br
.B " command: "true"
.br
.B " # Allow only requests from localhost
.br
.B " allowed: ["127.0.0.1/8", "::1/128"]
.br
.br
.br
.B " - name: 'hook 5'
.br
.B " route: "/webhooks/restricted/5"
.br
.B " command: "true"
.br
.B " # Allow everything, except those two subnets
.br
.B " blocked: ["192.168.0.0/16", "10.0.0.0/8"]
.br
.br
.B " - name: 'hook auth'
.br
.B " route: "/webhooks/restricted/auth"
.br
.B " command: "true"
.br
.B " # Require basic auth for this webhook
.br
.B " basic_auth:
.br
.B " # Username is optional. If defined, the following username must match
.br
.B " # If not defined, any user will be accepted
.br
.B " username: 'user'
.br
.B " # Password is obligatory to enable basic_auth. If defined, a request must authenticate with the given password (cleartext)
.br
.B " password: 'password'
.br
0707010000000D000081A4000000000000000000000001660FAA580000003D000000000000000000000000000000000000001200000000weblug-0.5/go.modmodule weblug/m/v2
go 1.18
require gopkg.in/yaml.v2 v2.4.0
0707010000000E000081A4000000000000000000000001660FAA5800000168000000000000000000000000000000000000001200000000weblug-0.5/go.sumgopkg.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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
0707010000000F000041ED000000000000000000000002660FAA5800000000000000000000000000000000000000000000001000000000weblug-0.5/test07070100000010000081ED000000000000000000000001660FAA580000078B000000000000000000000000000000000000001C00000000weblug-0.5/test/blackbox.sh#!/bin/bash -ex
# Blackbox tests for weblug
cleanup() {
# kill all processes whose parent is this process
pkill -P $$
}
trap cleanup EXIT
if [[ $EUID != 0 && $UID != 0 ]]; then
echo -e "(!!) WARNING: Cannot UID and GID webhook tests, because we're not running as root (!!)\n"
echo -e "Continuing in 3 seconds\n\n\n"
sleep 3
fi
# Secret environment variable, which must be removed by env sanitation.
export SECRET1="top5ecret"
rm -f testfile
../weblug test.yaml &
../weblug test_uid.yaml &
sleep 1
## Check touch webhook, which creates "testfile"
echo -e "\n\nChecking 'testfile' webhook ... "
curl --fail http://127.0.0.1:2088/webhooks/touch
if [[ ! -f testfile ]]; then
echo "Testfile doesn't exist after running webhook touch"
exit 1
fi
rm -f testfile
## Check background webhook, that sleeps for 5 seconds but returns immediately
echo -e "\n\nChecking 'background' webhook ... "
timeout 2 curl --fail http://127.0.0.1:2088/webhooks/background
## Check concurrency webhook, that allows only 2 requests at the same time (but sleeps for 5 seconds)
echo -e "\n\nChecking 'concurrency' webhook ... "
timeout 10 curl --fail http://127.0.0.1:2088/3 &
timeout 10 curl --fail http://127.0.0.1:2088/3 &
! timeout 2 curl --fail http://127.0.0.1:2088/3
## Check UID and GID webhooks, but only if we're root
echo -e "\n\nChecking 'uid/gid' webhook ... "
# Skip this test, if we're not root
if [[ $EUID == 0 || $UID == 0 ]]; then
curl --fail http://127.0.0.1:2088/webhooks/uid
curl --fail http://127.0.0.1:2088/webhooks/gid
else
echo "Cannot UID and GID webhook tests, because we're not running as root"
fi
## Check environment variables
timeout 10 curl --fail 'http://127.0.0.1:2088/env'
## Check UID/GID handling
# Ensure weblug is running with UID=65534
pgrep -u 65534 weblug
# Ensure weblug2 (test_uid) is actually running
timeout 10 curl --fail 'http://127.0.0.1:2089/uid'
echo -e "\n\nall good"
07070100000011000081ED000000000000000000000001660FAA58000002FA000000000000000000000000000000000000001900000000weblug-0.5/test/checkenv#!/bin/bash -e
# Script to test for environment variable sanitation
set -o pipefail
if [[ $PUBLIC1 != "one" ]]; then
echo "PUBLIC1 variable not valid"
exit 1
fi
if [[ $PUBLIC2 != "two" ]]; then
echo "PUBLIC2 variable not valid"
exit 1
fi
if env | grep 'SECRET1' >/dev/null; then
echo "SECRET1 variable is set but it should not be"
echo "Environment sanitation failed"
exit 1
fi
# There must never be more than 10 variables set
# Some variables will be set by bash at startup (e.g. PWD), so we are never in a
# pristine environment. However, more than 10 variables indicates something's off
# with the env sanitation
if [[ `env | wc -l` -ge 10 ]]; then
echo "More than 10 env variables detected"
exit 1
fi
echo "all good"07070100000012000081A4000000000000000000000001660FAA5800000352000000000000000000000000000000000000001A00000000weblug-0.5/test/test.yaml---
settings:
bind: "127.0.0.1:2088" # bind address for webserver
hooks:
- name: 'touch hook'
route: "/webhooks/touch"
command: "touch testfile"
- name: 'hook background'
route: "/webhooks/background"
command: "sleep 5"
background: True
- name: 'hook three'
route: "/3"
command: "sleep 5"
concurrency: 2
- name: 'hook uid'
route: "/webhooks/uid"
command: "bash -c 'echo uid=$UID gid=$GID; if [[ $UID != 10 ]]; then exit 1; fi'"
uid: 10
output: True
- name: 'hook gid'
route: "/webhooks/gid"
command: "bash -c 'GID=`id -g`; echo uid=$UID gid=$GID; if [[ $GID != 10 ]]; then exit 1; fi'"
uid: 10
gid: 10
output: True
- name: 'environment variables'
route: '/env'
command: "bash ./checkenv"
output: True
env:
PUBLIC1: "one"
PUBLIC2: "two"
07070100000013000081A4000000000000000000000001660FAA58000000B5000000000000000000000000000000000000001E00000000weblug-0.5/test/test_uid.yaml---
settings:
bind: "127.0.0.1:2089" # bind address for webserver
uid: 65534
gid: 65534
hooks:
- name: 'uid hook'
route: "/uid"
command: "id -u"
output: True07070100000014000081A4000000000000000000000001660FAA58000000DA000000000000000000000000000000000000001A00000000weblug-0.5/weblug.service[Unit]
Description=weblug - webhook Service
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/lib/empty
ExecStart=/usr/bin/weblug /etc/weblug.yml
Restart=on-failure
[Install]
WantedBy=multi-user.target07070100000015000081A4000000000000000000000001660FAA58000004B2000000000000000000000000000000000000001700000000weblug-0.5/weblug.spec#
# spec file for package weblug
#
Name: weblug
Version: 0.3
Release: 0
Summary: Simple webhook receiver program
License: MIT
URL: https://codeberg.org/grisu48/weblug
Source: weblug-%{version}.tar.gz
Source1: vendor.tar.gz
BuildRequires: golang-packaging
BuildRequires: go1.18
%{go_nostrip}
%{systemd_ordering}
%description
Simple webhook receiver program
%prep
%autosetup -D -a 1
%build
make all GOARGS="-mod vendor -buildmode pie"
%install
install -Dm 755 weblug %{buildroot}/%{_bindir}/weblug
# systemd unit
install -Dpm0644 weblug.service %{buildroot}%{_unitdir}/weblug.service
# configuration file (/etc/weblug.yml)
mkdir -p %{buildroot}%{_sysconfdir}
install -m 600 weblug.yml %{buildroot}%{_sysconfdir}/weblug.yml
# man page
install -Dm 644 doc/weblug.8 %{buildroot}/%{_mandir}/man8/weblug.8
%pre
%service_add_pre weblug.service
%preun
%service_del_preun weblug.service
%post
%service_add_post weblug.service
%postun
%service_del_postun weblug.service
%files
%doc README.md
%license LICENSE
%{_bindir}/weblug
%{_unitdir}/weblug.service
%config %{_sysconfdir}/weblug.yml
%{_mandir}/man8/weblug.8%{?ext_man}
%changelog
07070100000016000081A4000000000000000000000001660FAA5800000C9B000000000000000000000000000000000000001600000000weblug-0.5/weblug.yml---
## Weblug example config
settings:
#bind: "127.0.0.1:2088" # bind address for webserver
bind: ":2088" # bind to all addresses
# Note: Due to current limitations, weblug needs to run as root when you use custom uid,gid settings per webhook
# This is a known issue, see https://codeberg.org/grisu48/weblug/issues/9
uid: 0 # run under specified user id
gid: 0 # run under specified group id
readtimeout: 10 # if set, maximum number of seconds to receive the full request
writetimeout: 10 # if set, maximum number of seconds to send the full response
maxheadersize: 4096 # maximum header size
maxbodysize: 0 # maximum size of the body before cropping. Setting to 0 will ignore the http request body. Default: 0
# Enable TLS
# Note that it is not recommended to run weblug on the open internet without using a reverse proxy
tls:
enabled: false
# Minimum and maximum required TLS version. By default TLS1.2 is the minimum
minversion: '1.2'
maxversion: ''
keypairs:
- keyfile: 'weblug.key'
certificate: 'weblug1.pem'
- keyfile: 'weblug.key'
certificate: 'weblug2.pem'
# hook definitions. A hook needs to define the HTTP endpoint ("route") and the command
# See the following examples for more possible options.
hooks:
- name: 'hook one'
route: "/webhooks/1"
# if hosts is present, then limit the incoming requests to the given remote host(s)
# Currently multiplexing the same route to different hosts does not work
hosts:
- example1.local
- example2.local
command: "sleep 5"
background: True # Terminate http request immediately
concurrency: 2 # At most 2 parallel processes are allowed
env: # Define environment variables
KEY1: "VALUE1"
KEY2: "VALUE2"
- name: 'hook two'
route: "/webhooks/2"
command: "bash -c 'sleep 5'"
concurrency: 5 # At most 5 parallel processes are allowed
- name: 'hook 3'
route: "/webhooks/data/3"
command: "bash -c 'echo $UID $GID'"
uid: 100 # Run command as system user id (uid) 100
gid: 200 # Run command with system group id (gid) 200
concurrency: 1 # No concurrency. Returns 500 on parallel requests
output: True # Print program output to console
- name: 'hook 4'
route: "/webhooks/restricted/4"
command: "true"
# Allow only requests from localhost
allowed: ["127.0.0.1/8", "::1/128"]
- name: 'hook 5'
route: "/webhooks/restricted/5"
command: "true"
# Allow everything, except those two subnets
blocked: ["192.168.0.0/16", "10.0.0.0/8"]
- name: 'hook auth'
route: "/webhooks/restricted/auth"
command: "true"
# Require basic auth for this webhook
basic_auth:
# Username is optional. If defined, the following username must match
# If not defined, any user will be accepted
username: 'user'
# Password is obligatory to enable basic_auth. If defined, a request must authenticate with the given password (cleartext)
password: 'password'
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!108 blocks