refactor(config): small config refactoring

- split config structure
- improve config logic
- improve test lib and fix typos
This commit is contained in:
Nicolas Carlier 2024-03-04 09:13:59 +01:00 committed by GitHub
parent 53f10283c3
commit 39ab72bb30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 241 additions and 147 deletions

View File

@ -63,7 +63,7 @@ All configuration variables are described in [etc/default/webhookd.env](./etc/de
Webhooks are simple scripts within a directory structure. Webhooks are simple scripts within a directory structure.
By default inside the `./scripts` directory. By default inside the `./scripts` directory.
You can change the default directory using the `WHD_SCRIPTS` environment variable or `-script` parameter. You can change the default directory using the `WHD_HOOK_SCRIPTS` environment variable or `-hook-scripts` parameter.
*Example:* *Example:*
@ -89,7 +89,7 @@ The directory structure define the webhook URL.
You can omit the script extension. If you do, webhookd will search by default for a `.sh` file. You can omit the script extension. If you do, webhookd will search by default for a `.sh` file.
You can change the default extension using the `WHD_HOOK_DEFAULT_EXT` environment variable or `-hook-default-ext` parameter. You can change the default extension using the `WHD_HOOK_DEFAULT_EXT` environment variable or `-hook-default-ext` parameter.
If the script exists, the output the will be streamed to the HTTP response. If the script exists, the output will be streamed to the HTTP response.
The streaming technology depends on the HTTP request: The streaming technology depends on the HTTP request:
@ -218,7 +218,7 @@ $ # Retrieve logs afterwards
$ curl http://localhost:8080/echo/2 $ curl http://localhost:8080/echo/2
``` ```
If needed, you can also redirect hook logs to the server output (configured by the `WHD_LOG_HOOK_OUTPUT` environment variable). If needed, you can also redirect hook logs to the server output (configured by the `WHD_LOG_MODULES=hook` environment variable).
### Post hook notifications ### Post hook notifications
@ -330,9 +330,9 @@ Webhookd supports 2 signature methods:
To activate request signature verification, you have to configure the truststore: To activate request signature verification, you have to configure the truststore:
```bash ```bash
$ export WHD_TRUST_STORE_FILE=/etc/webhookd/pubkey.pem $ export WHD_TRUSTSTORE_FILE=/etc/webhookd/pubkey.pem
$ # or $ # or
$ webhookd --trust-store-file /etc/webhookd/pubkey.pem $ webhookd --truststore-file /etc/webhookd/pubkey.pem
``` ```
Public key is stored in PEM format. Public key is stored in PEM format.
@ -361,9 +361,9 @@ You can find a small HTTP client in the ["tooling" directory](./tooling/httpsig/
You can activate TLS to secure communications: You can activate TLS to secure communications:
```bash ```bash
$ export WHD_TLS=true $ export WHD_TLS_ENABLED=true
$ # or $ # or
$ webhookd --tls $ webhookd --tls-enabled
``` ```
By default webhookd is expecting a certificate and key file (`./server.pem` and `./server.key`). By default webhookd is expecting a certificate and key file (`./server.pem` and `./server.key`).
@ -373,10 +373,10 @@ Webhookd also support [ACME](https://ietf-wg-acme.github.io/acme/) protocol.
You can activate ACME by setting a fully qualified domain name: You can activate ACME by setting a fully qualified domain name:
```bash ```bash
$ export WHD_TLS=true $ export WHD_TLS_ENABLED=true
$ export WHD_TLS_DOMAIN=hook.example.com $ export WHD_TLS_DOMAIN=hook.example.com
$ # or $ # or
$ webhookd --tls --tls-domain=hook.example.com $ webhookd --tls-enabled --tls-domain=hook.example.com
``` ```
**Note:** **Note:**

View File

@ -9,6 +9,6 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- WHD_SCRIPTS=/scripts - WHD_HOOK_SCRIPTS=/scripts
volumes: volumes:
- ./scripts:/scripts - ./scripts:/scripts

View File

@ -7,13 +7,13 @@ if [ ! -z "$WHD_SCRIPTS_GIT_URL" ]
then then
[ ! -f "$WHD_SCRIPTS_GIT_KEY" ] && die "Git clone key not found." [ ! -f "$WHD_SCRIPTS_GIT_KEY" ] && die "Git clone key not found."
export WHD_SCRIPTS=${WHD_SCRIPTS:-/opt/scripts-git} export WHD_HOOK_SCRIPTS=${WHD_HOOK_SCRIPTS:-/opt/scripts-git}
export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
mkdir -p $WHD_SCRIPTS mkdir -p $WHD_HOOK_SCRIPTS
echo "Cloning $WHD_SCRIPTS_GIT_URL into $WHD_SCRIPTS ..." echo "Cloning $WHD_SCRIPTS_GIT_URL into $WHD_HOOK_SCRIPTS ..."
ssh-agent sh -c 'ssh-add ${WHD_SCRIPTS_GIT_KEY}; git clone --depth 1 --single-branch ${WHD_SCRIPTS_GIT_URL} ${WHD_SCRIPTS}' ssh-agent sh -c 'ssh-add ${WHD_SCRIPTS_GIT_KEY}; git clone --depth 1 --single-branch ${WHD_SCRIPTS_GIT_URL} ${WHD_HOOK_SCRIPTS}'
[ $? != 0 ] && die "Unable to clone repository" [ $? != 0 ] && die "Unable to clone repository"
fi fi

View File

@ -2,30 +2,37 @@
# Webhookd configuration # Webhookd configuration
### ###
# Hook execution logs location, default is OS temporary directory
#WHD_HOOK_LOG_DIR="/tmp"
# Maximum hook execution time in second, default is 10
#WHD_HOOK_TIMEOUT=10
# HTTP listen address, default is ":8080" # HTTP listen address, default is ":8080"
# Example: `localhost:8080` or `:8080` for all interfaces # Example: `localhost:8080` or `:8080` for all interfaces
#WHD_LISTEN_ADDR=":8080" #WHD_LISTEN_ADDR=":8080"
# Log level (debug, info, warn or error), default is "info" # Log level (debug, info, warn or error), default is "info"
#WHD_LOG_LEVEL=info #WHD_LOG_LEVEL=info
# Log format (text or json), default is "text" # Log format (text or json), default is "text"
#WHD_LOG_FORMAT=text #WHD_LOG_FORMAT=text
# Logging modules to activate (http, hook)
# - `http`: HTTP access logs
# - `hook`: Hook execution logs
# Example: `http` or `http,hook`
#WHD_LOG_MODULES=
# Log HTTP request, default is false
#WHD_LOG_HTTP_REQUEST=false
# Log hook execution output, default is false
#WHD_LOG_HOOK_OUTPUT=false
# Default extension for hook scripts, default is "sh"
#WHD_HOOK_DEFAULT_EXT=sh
# Maximum hook execution time in second, default is 10
#WHD_HOOK_TIMEOUT=10
# Scripts location, default is "scripts"
#WHD_HOOK_SCRIPTS="scripts"
# Hook execution logs location, default is OS temporary directory
#WHD_HOOK_LOG_DIR="/tmp"
# Number of workers to start, default is 2 # Number of workers to start, default is 2
#WHD_NB_WORKERS=2 #WHD_HOOK_WORKERS=2
# Static file directory to serve on /static path, disabled by default
# Example: `./var/www`
#WHD_STATIC_DIR=
# Path to serve static file directory, default is "/static"
#WHD_STATIC_PATH=/static
# Notification URI, disabled by default # Notification URI, disabled by default
# Example: `http://requestb.in/v9b229v9` or `mailto:foo@bar.com?smtp=smtp-relay-localnet:25` # Example: `http://requestb.in/v9b229v9` or `mailto:foo@bar.com?smtp=smtp-relay-localnet:25`
@ -34,37 +41,26 @@
# Password file for HTTP basic authentication, default is ".htpasswd" # Password file for HTTP basic authentication, default is ".htpasswd"
#WHD_PASSWD_FILE=".htpasswd" #WHD_PASSWD_FILE=".htpasswd"
# Scripts location, default is "scripts" # Truststore URI, disabled by default
#WHD_SCRIPTS="scripts" # Enable HTTP signature verification if set.
# Example: `/etc/webhookd/pubkey.pem`
#WHD_TRUSTSTORE_FILE=
# Activate TLS, default is false
#WHD_TLS_ENABLED=false
# TLS key file, default is "./server.key"
#WHD_TLS_KEY_FILE="./server.key"
# TLS certificate file, default is "./server.crt"
#WHD_TLS_CERT_FILE="./server.pem"
# TLS domain name used by ACME, key and cert files are ignored if set
# Example: `hook.example.org`
#WHD_TLS_DOMAIN=
# GIT repository that contains scripts # GIT repository that contains scripts
# Note: this is only used by the Docker image or by using the Docker entrypoint script # Note: this is only used by the Docker image or by using the Docker entrypoint script
# Example: `git@github.com:ncarlier/webhookd.git` # Example: `git@github.com:ncarlier/webhookd.git`
#WHD_SCRIPTS_GIT_URL= #WHD_SCRIPTS_GIT_URL=
# GIT SSH private key used to clone the repository # GIT SSH private key used to clone the repository
# Note: this is only used by the Docker image or by using the Docker entrypoint script # Note: this is only used by the Docker image or by using the Docker entrypoint script
# Example: `/etc/webhookd/github_deploy_key.pem` # Example: `/etc/webhookd/github_deploy_key.pem`
#WHD_SCRIPTS_GIT_KEY= #WHD_SCRIPTS_GIT_KEY=
# Static file directory to serve on /static path, disabled by default
# Example: `./var/www`
#WHD_STATIC_DIR=
# Trust store URI, disabled by default
# Enable HTTP signature verification if set.
# Example: `/etc/webhookd/pubkey.pem`
#WHD_TRUST_STORE_FILE=
# Activate TLS, default is false
#WHD_TLS=false
# TLS key file, default is "./server.key"
#WHD_TLS_KEY_FILE="./server.key"
# TLS certificate file, default is "./server.crt"
#WHD_TLS_CERT_FILE="./server.pem"
# TLS domain name used by ACME, key and cert files are ignored if set
# Example: `hook.example.org`
#WHD_TLS_DOMAIN=

23
main.go
View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"slices"
"syscall" "syscall"
"time" "time"
@ -22,9 +23,11 @@ import (
"github.com/ncarlier/webhookd/pkg/worker" "github.com/ncarlier/webhookd/pkg/worker"
) )
const envPrefix = "WHD"
func main() { func main() {
conf := &config.Config{} conf := &config.Config{}
configflag.Bind(conf, "WHD") configflag.Bind(conf, envPrefix)
flag.Parse() flag.Parse()
@ -33,30 +36,32 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if conf.HookLogDir == "" { if conf.Hook.LogDir == "" {
conf.HookLogDir = os.TempDir() conf.Hook.LogDir = os.TempDir()
} }
if err := conf.Validate(); err != nil { if err := conf.Validate(); err != nil {
log.Fatal("invalid configuration:", err) log.Fatal("invalid configuration:", err)
} }
logger.Configure(conf.LogFormat, conf.LogLevel) logger.Configure(conf.Log.Format, conf.Log.Level)
logger.HookOutputEnabled = conf.LogHookOutput logger.HookOutputEnabled = slices.Contains(conf.Log.Modules, "hook")
logger.RequestOutputEnabled = conf.LogHTTPRequest logger.RequestOutputEnabled = slices.Contains(conf.Log.Modules, "http")
conf.ManageDeprecatedFlags(envPrefix)
slog.Debug("starting webhookd server...") slog.Debug("starting webhookd server...")
srv := server.NewServer(conf) srv := server.NewServer(conf)
// Configure notification // Configure notification
if err := notification.Init(conf.NotificationURI); err != nil { if err := notification.Init(conf.Notification.URI); err != nil {
slog.Error("unable to create notification channel", "err", err) slog.Error("unable to create notification channel", "err", err)
} }
// Start the dispatcher. // Start the dispatcher.
slog.Debug("starting the dispatcher...", "workers", conf.NbWorkers) slog.Debug("starting the dispatcher...", "workers", conf.Hook.Workers)
worker.StartDispatcher(conf.NbWorkers) worker.StartDispatcher(conf.Hook.Workers)
done := make(chan bool) done := make(chan bool)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)

View File

@ -32,10 +32,10 @@ func atoiFallback(str string, fallback int) int {
// index is the main handler of the API. // index is the main handler of the API.
func index(conf *config.Config) http.Handler { func index(conf *config.Config) http.Handler {
defaultTimeout = conf.HookTimeout defaultTimeout = conf.Hook.Timeout
defaultExt = conf.HookDefaultExt defaultExt = conf.Hook.DefaultExt
scriptDir = conf.ScriptDir scriptDir = conf.Hook.ScriptsDir
outputDir = conf.HookLogDir outputDir = conf.Hook.LogDir
return http.HandlerFunc(webhookHandler) return http.HandlerFunc(webhookHandler)
} }
@ -65,14 +65,16 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) {
} }
script, err := hook.ResolveScript(scriptDir, hookName, defaultExt) script, err := hook.ResolveScript(scriptDir, hookName, defaultExt)
if err != nil { if err != nil {
slog.Error("hooke not found", "err", err.Error()) msg := "hook not found"
http.Error(w, "hook not found", http.StatusNotFound) slog.Error(msg, "err", err.Error())
http.Error(w, msg, http.StatusNotFound)
return return
} }
if err = r.ParseForm(); err != nil { if err = r.ParseForm(); err != nil {
slog.Error("error reading from-data", "err", err) msg := "unable to parse form-data"
http.Error(w, "unable to parse request form", http.StatusBadRequest) slog.Error(msg, "err", err)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
@ -84,8 +86,9 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(mediatype, "text/") || mediatype == "application/json" { if strings.HasPrefix(mediatype, "text/") || mediatype == "application/json" {
body, err = io.ReadAll(r.Body) body, err = io.ReadAll(r.Body)
if err != nil { if err != nil {
slog.Error("error reading body", "err", err) msg := "unable to read request body"
http.Error(w, "unable to read request body", http.StatusBadRequest) slog.Error(msg, "err", err)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
} }
@ -106,8 +109,9 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) {
OutputDir: outputDir, OutputDir: outputDir,
}) })
if err != nil { if err != nil {
slog.Error("error creating hook job", "err", err) msg := "unable to create hook execution job"
http.Error(w, "unable to create hook job", http.StatusInternalServerError) slog.Error(msg, "err", err)
http.Error(w, msg, http.StatusInternalServerError)
return return
} }
@ -163,7 +167,7 @@ func getWebhookLog(w http.ResponseWriter, r *http.Request) {
return return
} }
if logFile == nil { if logFile == nil {
http.Error(w, "job not found", http.StatusNotFound) http.Error(w, "hook execution log not found", http.StatusNotFound)
return return
} }
defer logFile.Close() defer logFile.Close()

View File

@ -18,14 +18,14 @@ var commonMiddlewares = middleware.Middlewares{
func buildMiddlewares(conf *config.Config) middleware.Middlewares { func buildMiddlewares(conf *config.Config) middleware.Middlewares {
var middlewares = commonMiddlewares var middlewares = commonMiddlewares
if conf.TLS { if conf.TLS.Enabled {
middlewares = middlewares.UseAfter(middleware.HSTS) middlewares = middlewares.UseAfter(middleware.HSTS)
} }
// Load trust store... // Load trust store...
ts, err := truststore.New(conf.TrustStoreFile) ts, err := truststore.New(conf.TruststoreFile)
if err != nil { if err != nil {
slog.Warn("unable to load trust store", "filename", conf.TrustStoreFile, "err", err) slog.Warn("unable to load trust store", "filename", conf.TruststoreFile, "err", err)
} }
if ts != nil { if ts != nil {
middlewares = middlewares.UseAfter(middleware.Signature(ts)) middlewares = middlewares.UseAfter(middleware.Signature(ts))
@ -44,7 +44,7 @@ func buildMiddlewares(conf *config.Config) middleware.Middlewares {
func routes(conf *config.Config) Routes { func routes(conf *config.Config) Routes {
middlewares := buildMiddlewares(conf) middlewares := buildMiddlewares(conf)
staticPath := conf.StaticPath + "/" staticPath := conf.Static.Path + "/"
return Routes{ return Routes{
route( route(
"/", "/",

View File

@ -8,8 +8,8 @@ import (
func static(prefix string) HandlerFunc { func static(prefix string) HandlerFunc {
return func(conf *config.Config) http.Handler { return func(conf *config.Config) http.Handler {
if conf.StaticDir != "" { if conf.Static.Dir != "" {
fs := http.FileServer(http.Dir(conf.StaticDir)) fs := http.FileServer(http.Dir(conf.Static.Dir))
return http.StripPrefix(prefix, fs) return http.StripPrefix(prefix, fs)
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -15,8 +15,8 @@ func TestQueryParamsToShellVars(t *testing.T) {
"list": []string{"foo", "bar"}, "list": []string{"foo", "bar"},
} }
values := api.HTTPParamsToShellVars(tc) values := api.HTTPParamsToShellVars(tc)
assert.ContainsStr(t, "string=foo", values, "") assert.Contains(t, "string=foo", values, "")
assert.ContainsStr(t, "list=foo,bar", values, "") assert.Contains(t, "list=foo,bar", values, "")
} }
func TestHTTPHeadersToShellVars(t *testing.T) { func TestHTTPHeadersToShellVars(t *testing.T) {
@ -25,6 +25,6 @@ func TestHTTPHeadersToShellVars(t *testing.T) {
"X-Foo-Bar": []string{"foo", "bar"}, "X-Foo-Bar": []string{"foo", "bar"},
} }
values := api.HTTPParamsToShellVars(tc) values := api.HTTPParamsToShellVars(tc)
assert.ContainsStr(t, "content_type=text/plain", values, "") assert.Contains(t, "content_type=text/plain", values, "")
assert.ContainsStr(t, "x_foo_bar=foo,bar", values, "") assert.Contains(t, "x_foo_bar=foo,bar", values, "")
} }

View File

@ -25,27 +25,27 @@ func NotNil(t *testing.T, actual interface{}, message string) {
} }
// Equal assert that an object is equal to an expected value // Equal assert that an object is equal to an expected value
func Equal(t *testing.T, expected, actual interface{}, message string) { func Equal[K comparable](t *testing.T, expected, actual K, message string) {
if message == "" { if message == "" {
message = "Equal assertion failed" message = "Equal assertion failed"
} }
if actual != expected { if actual != expected {
t.Fatalf("%s - expected: %s, actual: %s", message, expected, actual) t.Fatalf("%s - expected: %v, actual: %v", message, expected, actual)
} }
} }
// NotEqual assert that an object is not equal to an expected value // NotEqual assert that an object is not equal to an expected value
func NotEqual(t *testing.T, expected, actual interface{}, message string) { func NotEqual[K comparable](t *testing.T, expected, actual K, message string) {
if message == "" { if message == "" {
message = "Not equal assertion failed" message = "Not equal assertion failed"
} }
if actual == expected { if actual == expected {
t.Fatalf("%s - unexpected: %s, actual: %s", message, expected, actual) t.Fatalf("%s - unexpected: %v, actual: %v", message, expected, actual)
} }
} }
// ContainsStr assert that an array contains an expected value // ContainsStr assert that an array contains an expected value
func ContainsStr(t *testing.T, expected string, array []string, message string) { func Contains[K comparable](t *testing.T, expected K, array []K, message string) {
if message == "" { if message == "" {
message = "Array don't contains expected value" message = "Array don't contains expected value"
} }
@ -54,7 +54,7 @@ func ContainsStr(t *testing.T, expected string, array []string, message string)
return return
} }
} }
t.Fatalf("%s - array: %v, expected value: %s", message, array, expected) t.Fatalf("%s - array: %v, expected value: %v", message, array, expected)
} }
// True assert that an expression is true // True assert that an expression is true

View File

@ -5,33 +5,58 @@ import (
"regexp" "regexp"
) )
// Config contain global configuration // Config store root configuration
type Config struct { type Config struct {
ListenAddr string `flag:"listen-addr" desc:"HTTP listen address" default:":8080"` ListenAddr string `flag:"listen-addr" desc:"HTTP listen address" default:":8080"`
TLS bool `flag:"tls" desc:"Activate TLS" default:"false"`
TLSCertFile string `flag:"tls-cert-file" desc:"TLS certificate file" default:"server.pem"`
TLSKeyFile string `flag:"tls-key-file" desc:"TLS key file" default:"server.key"`
TLSDomain string `flag:"tls-domain" desc:"TLS domain name used by ACME"`
NbWorkers int `flag:"nb-workers" desc:"Number of workers to start" default:"2"`
HookDefaultExt string `flag:"hook-default-ext" desc:"Default extension for hook scripts" default:"sh"`
HookTimeout int `flag:"hook-timeout" desc:"Maximum hook execution time in second" default:"10"`
HookLogDir string `flag:"hook-log-dir" desc:"Hook execution logs location" default:""`
ScriptDir string `flag:"scripts" desc:"Scripts location" default:"scripts"`
PasswdFile string `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"` PasswdFile string `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"`
LogLevel string `flag:"log-level" desc:"Log level (debug, info, warn, error)" default:"info"` TruststoreFile string `flag:"truststore-file" desc:"Truststore used by HTTP signature verifier (.pem or .p12)"`
LogFormat string `flag:"log-format" desc:"Log format (json, text)" default:"text"` Hook HookConfig `flag:"hook"`
LogHookOutput bool `flag:"log-hook-output" desc:"Log hook execution output" default:"false"` Log LogConfig `flag:"log"`
LogHTTPRequest bool `flag:"log-http-request" desc:"Log HTTP request" default:"false"` Notification NotificationConfig `flag:"notification"`
StaticDir string `flag:"static-dir" desc:"Static file directory to serve on /static path" default:""` Static StaticConfig `flag:"static"`
StaticPath string `flag:"static-path" desc:"Path to serve static file directory" default:"/static"` TLS TLSConfig `flag:"tls"`
NotificationURI string `flag:"notification-uri" desc:"Notification URI"` OldConfig `flag:""`
TrustStoreFile string `flag:"trust-store-file" desc:"Trust store used by HTTP signature verifier (.pem or .p12)"`
} }
// Validate configuration // HookConfig store Hook execution configuration
type HookConfig struct {
DefaultExt string `flag:"default-ext" desc:"Default extension for hook scripts" default:"sh"`
Timeout int `flag:"timeout" desc:"Maximum hook execution time in second" default:"10"`
ScriptsDir string `flag:"scripts" desc:"Scripts location" default:"scripts"`
LogDir string `flag:"log-dir" desc:"Hook execution logs location" default:""`
Workers int `flag:"workers" desc:"Number of workers to start" default:"2"`
}
// LogConfig store logger configuration
type LogConfig struct {
Level string `flag:"level" desc:"Log level (debug, info, warn or error)" default:"info"`
Format string `flag:"format" desc:"Log format (json or text)" default:"text"`
Modules []string `flag:"modules" desc:"Logging modules to activate (http,hook)" default:""`
}
// NotificationConfig store notification configuration
type NotificationConfig struct {
URI string `flag:"uri" desc:"Notification URI"`
}
// StaticConfig store static assets configuration
type StaticConfig struct {
Dir string `flag:"dir" desc:"Static file directory to serve on /static path" default:""`
Path string `flag:"path" desc:"Path to serve static file directory" default:"/static"`
}
// TLSConfig store TLS configuration
type TLSConfig struct {
Enabled bool `flag:"enabled" desc:"Enable TLS" default:"false"`
CertFile string `flag:"cert-file" desc:"TLS certificate file (unused if ACME used)" default:"server.pem"`
KeyFile string `flag:"key-file" desc:"TLS key file (unused if ACME used)" default:"server.key"`
Domain string `flag:"domain" desc:"TLS domain name used by ACME"`
}
// Validate the configuration
func (c *Config) Validate() error { func (c *Config) Validate() error {
if matched, _ := regexp.MatchString(`^/\w+$`, c.StaticPath); !matched { if matched, _ := regexp.MatchString(`^/\w+$`, c.Static.Path); !matched {
return fmt.Errorf("invalid static path: %s", c.StaticPath) return fmt.Errorf("invalid static path: %s", c.Static.Path)
} }
return nil return nil
} }

54
pkg/config/deprecated.go Normal file
View File

@ -0,0 +1,54 @@
package config
import (
"flag"
"log/slog"
"os"
"github.com/ncarlier/webhookd/pkg/helper"
)
// OldConfig contain global configuration
type OldConfig struct {
NbWorkers int `flag:"nb-workers" desc:"Number of workers to start [DEPRECATED]" default:"2"`
Scripts string `flag:"scripts" desc:"Scripts location [DEPRECATED]" default:"scripts"`
}
// ManageDeprecatedFlags manage legacy configuration
func (c *Config) ManageDeprecatedFlags(prefix string) {
if isUsingDeprecatedConfigParam(prefix, "nb-workers") {
c.Hook.Workers = c.NbWorkers
}
if isUsingDeprecatedConfigParam(prefix, "scripts") {
c.Hook.ScriptsDir = c.Scripts
}
}
func isUsingDeprecatedConfigParam(prefix, flagName string) bool {
envVar := helper.ToScreamingSnake(prefix + "_" + flagName)
switch {
case isFlagPassed(flagName):
slog.Warn("using deprecated configuration flag", "flag", flagName)
return true
case isEnvExists(envVar):
slog.Warn("using deprecated configuration environment variable", "variable", envVar)
return true
default:
return false
}
}
func isEnvExists(name string) bool {
_, exists := os.LookupEnv(name)
return exists
}
func isFlagPassed(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}

View File

@ -13,7 +13,11 @@ import (
) )
// Bind conf struct tags with flags // Bind conf struct tags with flags
func Bind(conf interface{}, prefix string) error { func Bind(conf interface{}, envPrefix string) error {
return bind(conf, envPrefix, "")
}
func bind(conf interface{}, envPrefix, keyPrefix string) error {
rv := reflect.ValueOf(conf) rv := reflect.ValueOf(conf)
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
rv = rv.Elem() rv = rv.Elem()
@ -40,15 +44,14 @@ func Bind(conf interface{}, prefix string) error {
val = tag val = tag
} }
// Get field value and description from environment variable if keyPrefix != "" {
if fieldType.Type.Kind() == reflect.Slice { key = keyPrefix + "-" + key
val = getEnvValue(prefix, key+"s", val)
desc = getEnvDesc(prefix, key+"s", desc)
} else {
val = getEnvValue(prefix, key, val)
desc = getEnvDesc(prefix, key, desc)
} }
// Get field value and description from environment variable
val = getEnvValue(envPrefix, key, val)
desc = getEnvDesc(envPrefix, key, desc)
// Get field value by reflection from struct definition // Get field value by reflection from struct definition
// And bind value to command line flag // And bind value to command line flag
switch fieldType.Type.Kind() { switch fieldType.Type.Kind() {
@ -82,10 +85,13 @@ func Bind(conf interface{}, prefix string) error {
ptr, _ := field.Addr().Interface().(*int) ptr, _ := field.Addr().Interface().(*int)
flag.IntVar(ptr, key, int(i64Val), desc) flag.IntVar(ptr, key, int(i64Val), desc)
} }
case reflect.Struct:
if err := bind(field.Addr().Interface(), envPrefix, key); err != nil {
return fmt.Errorf("invalid struct value for %s: %v", key, err)
}
case reflect.Slice: case reflect.Slice:
sliceType := field.Type().Elem() sliceType := field.Type().Elem()
if sliceType.Kind() == reflect.String { if sliceType.Kind() == reflect.String {
if strings.TrimSpace(val) != "" {
vals := strings.Split(val, ",") vals := strings.Split(val, ",")
sl := make([]string, len(vals)) sl := make([]string, len(vals))
copy(sl, vals) copy(sl, vals)
@ -96,7 +102,6 @@ func Bind(conf interface{}, prefix string) error {
} }
} }
} }
}
return nil return nil
} }

View File

@ -17,12 +17,17 @@ type sampleConfig struct {
Timer time.Duration `flag:"timer" desc:"Duration parameter" default:"30s"` Timer time.Duration `flag:"timer" desc:"Duration parameter" default:"30s"`
Array []string `flag:"array" desc:"Array parameter" default:"foo,bar"` Array []string `flag:"array" desc:"Array parameter" default:"foo,bar"`
OverrideArray []string `flag:"override-array" desc:"Array parameter to override" default:"foo"` OverrideArray []string `flag:"override-array" desc:"Array parameter to override" default:"foo"`
Obj objConfig `flag:"obj"`
}
type objConfig struct {
Name string `flag:"name" desc:"Object name" default:"none"`
} }
func TestFlagBinding(t *testing.T) { func TestFlagBinding(t *testing.T) {
conf := &sampleConfig{} conf := &sampleConfig{}
err := configflag.Bind(conf, "FOO") err := configflag.Bind(conf, "FOO")
flag.CommandLine.Parse([]string{"-override", "test", "-override-array", "a", "-override-array", "b"}) flag.CommandLine.Parse([]string{"-override", "test", "-override-array", "a", "-override-array", "b", "-obj-name", "foo"})
assert.Nil(t, err, "error should be nil") assert.Nil(t, err, "error should be nil")
assert.Equal(t, "foo", conf.Label, "") assert.Equal(t, "foo", conf.Label, "")
assert.Equal(t, "test", conf.Override, "") assert.Equal(t, "test", conf.Override, "")
@ -33,4 +38,5 @@ func TestFlagBinding(t *testing.T) {
assert.Equal(t, "foo", conf.Array[0], "") assert.Equal(t, "foo", conf.Array[0], "")
assert.Equal(t, 2, len(conf.OverrideArray), "") assert.Equal(t, 2, len(conf.OverrideArray), "")
assert.Equal(t, "a", conf.OverrideArray[0], "") assert.Equal(t, "a", conf.OverrideArray[0], "")
assert.Equal(t, "foo", conf.Obj.Name, "")
} }

View File

@ -68,17 +68,18 @@ func ToScreamingDelimited(s string, del uint8, screaming bool) string {
} }
} }
if i > 0 && n[len(n)-1] != del && nextCaseIsChanged { switch {
case i > 0 && n[len(n)-1] != del && nextCaseIsChanged:
// add underscore if next letter case type is changed // add underscore if next letter case type is changed
if v >= 'A' && v <= 'Z' { if v >= 'A' && v <= 'Z' {
n += string(del) + string(v) n += string(del) + string(v)
} else if v >= 'a' && v <= 'z' { } else if v >= 'a' && v <= 'z' {
n += string(v) + string(del) n += string(v) + string(del)
} }
} else if v == ' ' || v == '_' || v == '-' || v == '/' { case v == ' ' || v == '_' || v == '-' || v == '/':
// replace spaces/underscores with delimiters // replace spaces/underscores with delimiters
n += string(del) n += string(del)
} else { default:
n += string(v) n += string(v)
} }
} }

View File

@ -94,7 +94,7 @@ func (job *Job) Terminate(err error) error {
"id", job.ID(), "id", job.ID(),
"status", "error", "status", "error",
"err", err, "err", err,
"took", time.Since(job.start).Microseconds(), "took", time.Since(job.start).Milliseconds(),
) )
return err return err
} }
@ -103,7 +103,7 @@ func (job *Job) Terminate(err error) error {
"hook", job.Name(), "hook", job.Name(),
"id", job.ID(), "id", job.ID(),
"status", "success", "status", "success",
"took", time.Since(job.start).Microseconds(), "took", time.Since(job.start).Milliseconds(),
) )
return nil return nil
} }

View File

@ -27,7 +27,7 @@ type httpNotifier struct {
} }
func newHTTPNotifier(uri *url.URL) (notification.Notifier, error) { func newHTTPNotifier(uri *url.URL) (notification.Notifier, error) {
slog.Info("using HTTP notification system ", "üri", uri.Opaque) slog.Info("using HTTP notification system ", "uri", uri.Opaque)
return &httpNotifier{ return &httpNotifier{
URL: uri, URL: uri,
PrefixFilter: helper.GetValueOrAlt(uri.Query(), "prefix", "notify:"), PrefixFilter: helper.GetValueOrAlt(uri.Query(), "prefix", "notify:"),

View File

@ -50,7 +50,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
func NewServer(cfg *config.Config) *Server { func NewServer(cfg *config.Config) *Server {
logger := slog.NewLogLogger(slog.Default().Handler(), slog.LevelError) logger := slog.NewLogLogger(slog.Default().Handler(), slog.LevelError)
server := &Server{ server := &Server{
tls: cfg.TLS, tls: cfg.TLS.Enabled,
self: &http.Server{ self: &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: api.NewRouter(cfg), Handler: api.NewRouter(cfg),
@ -59,14 +59,14 @@ func NewServer(cfg *config.Config) *Server {
} }
if server.tls { if server.tls {
// HTTPs server // HTTPs server
if cfg.TLSDomain == "" { if cfg.TLS.Domain == "" {
server.certFile = cfg.TLSCertFile server.certFile = cfg.TLS.CertFile
server.keyFile = cfg.TLSKeyFile server.keyFile = cfg.TLS.KeyFile
} else { } else {
m := &autocert.Manager{ m := &autocert.Manager{
Cache: autocert.DirCache(cacheDir()), Cache: autocert.DirCache(cacheDir()),
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(cfg.TLSDomain), HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain),
} }
server.self.TLSConfig = m.TLSConfig() server.self.TLSConfig = m.TLSConfig()
server.certFile = "" server.certFile = ""

View File

@ -30,7 +30,6 @@ func StartDispatcher(nworkers int) {
slog.Debug("dispatching hook request", "hook", work.Name(), "id", work.ID()) slog.Debug("dispatching hook request", "hook", work.Name(), "id", work.ID())
worker <- work worker <- work
}() }()
} }
}() }()
} }

View File

@ -5,7 +5,6 @@ import (
"log/slog" "log/slog"
"github.com/ncarlier/webhookd/pkg/metric" "github.com/ncarlier/webhookd/pkg/metric"
"github.com/ncarlier/webhookd/pkg/notification" "github.com/ncarlier/webhookd/pkg/notification"
) )