Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
home:ph03nix
weblug
weblug-0.5.obscpio
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
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
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor