feat(): use htpasswd to manage basic auth

This commit is contained in:
Nicolas Carlier 2018-12-18 19:52:03 +00:00
parent 0a096c2763
commit aab844cee7
22 changed files with 420 additions and 270 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
release/ release/
.vscode/ .vscode/
.htpasswd

View File

@ -1,5 +1,7 @@
.SILENT : .SILENT :
export GO111MODULE=on
# Author # Author
AUTHOR=github.com/ncarlier AUTHOR=github.com/ncarlier

View File

@ -46,6 +46,7 @@ You can configure the daemon by:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `APP_LISTEN_ADDR` | `:8080` | HTTP service address | | `APP_LISTEN_ADDR` | `:8080` | HTTP service address |
| `APP_PASSWD_FILE` | `.htpasswd` | Password file for HTTP basic authentication |
| `APP_NB_WORKERS` | `2` | The number of workers to start | | `APP_NB_WORKERS` | `2` | The number of workers to start |
| `APP_HOOK_TIMEOUT` | `10` | Hook maximum delay before timeout (in second) | | `APP_HOOK_TIMEOUT` | `10` | Hook maximum delay before timeout (in second) |
| `APP_SCRIPTS_DIR` | `./scripts` | Scripts directory | | `APP_SCRIPTS_DIR` | `./scripts` | Scripts directory |
@ -64,6 +65,7 @@ You can configure the daemon by:
| Parameter | Default | Description | | Parameter | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `-l <address> or --listen <address>` | `:8080` | HTTP service address | | `-l <address> or --listen <address>` | `:8080` | HTTP service address |
| `-p or --passwd <htpasswd file>` | `.htpasswd` | Password file for HTTP basic authentication
| `-d or --debug` | false | Output debug logs | | `-d or --debug` | false | Output debug logs |
| `--nb-workers <workers>` | `2` | The number of workers to start | | `--nb-workers <workers>` | `2` | The number of workers to start |
| `--scripts <dir>` | `./scripts` | Scripts directory | | `--scripts <dir>` | `./scripts` | Scripts directory |
@ -199,6 +201,34 @@ SMTP notification configuration:
The log file will be sent as an GZIP attachment. The log file will be sent as an GZIP attachment.
### Authentication
You can restrict access to webhooks using HTTP basic authentication.
To activate basic authentication, you have to create a `htpasswd` file:
```bash
$ # create passwd file the user 'api'
$ htpasswd -B -c .htpasswd api
```
This command will ask for a password and store it in the htpawsswd file.
Please note that by default, the daemon will try to load the `.htpasswd` file.
But you can override this behavior by specifying the location of the file:
```bash
$ APP_PASSWD_FILE=/etc/webhookd/users.htpasswd
$ # or
$ webhookd -p /etc/webhookd/users.htpasswd
```
Once configured, you must call webhooks using basic authentication:
```bash
$ curl -u api:test -XPOST "http://localhost:8080/echo?msg=hello"
```
--- ---

View File

@ -1,70 +0,0 @@
package main
import (
"bytes"
"flag"
"os"
"strconv"
"github.com/ncarlier/webhookd/pkg/auth"
)
// Config contain global configuration
type Config struct {
ListenAddr *string
NbWorkers *int
Debug *bool
Timeout *int
ScriptDir *string
Authentication *string
AuthenticationParam *string
}
var config = &Config{
ListenAddr: flag.String("listen", getEnv("LISTEN_ADDR", ":8080"), "HTTP service address (e.g.address, ':8080')"),
NbWorkers: flag.Int("nb-workers", getIntEnv("NB_WORKERS", 2), "The number of workers to start"),
Debug: flag.Bool("debug", getBoolEnv("DEBUG", false), "Output debug logs"),
Timeout: flag.Int("timeout", getIntEnv("HOOK_TIMEOUT", 10), "Hook maximum delay before timeout (in second)"),
ScriptDir: flag.String("scripts", getEnv("SCRIPTS_DIR", "scripts"), "Scripts directory"),
Authentication: flag.String("auth", getEnv("AUTH", "none"), ""),
AuthenticationParam: flag.String("auth-param", getEnv("AUTH_PARAM", ""), func() string {
authdocwriter := bytes.NewBufferString("Authentication method. Available methods: ")
for key, method := range auth.AvailableMethods {
authdocwriter.WriteRune('\n')
authdocwriter.WriteString(key)
authdocwriter.WriteRune(':')
authdocwriter.WriteString(method.Usage())
}
return authdocwriter.String()
}()),
}
func init() {
flag.StringVar(config.ListenAddr, "l", *config.ListenAddr, "HTTP service (e.g address: ':8080')")
flag.BoolVar(config.Debug, "d", *config.Debug, "Output debug logs")
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv("APP_" + key); ok {
return value
}
return fallback
}
func getIntEnv(key string, fallback int) int {
strValue := getEnv(key, strconv.Itoa(fallback))
if value, err := strconv.Atoi(strValue); err == nil {
return value
}
return fallback
}
func getBoolEnv(key string, fallback bool) bool {
strValue := getEnv(key, strconv.FormatBool(fallback))
if value, err := strconv.ParseBool(strValue); err == nil {
return value
}
return fallback
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/ncarlier/webhookd
require golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=

96
main.go
View File

@ -3,30 +3,17 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"sync/atomic"
"time" "time"
"github.com/ncarlier/webhookd/pkg/api" "github.com/ncarlier/webhookd/pkg/api"
"github.com/ncarlier/webhookd/pkg/auth" "github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/logger" "github.com/ncarlier/webhookd/pkg/logger"
"github.com/ncarlier/webhookd/pkg/worker" "github.com/ncarlier/webhookd/pkg/worker"
) )
type key int
const (
requestIDKey key = 0
)
var (
healthy int32
)
func main() { func main() {
flag.Parse() flag.Parse()
@ -35,47 +22,25 @@ func main() {
return return
} }
var authmethod auth.Method conf := config.Get()
name := *config.Authentication
if _, ok := auth.AvailableMethods[name]; ok {
authmethod = auth.AvailableMethods[name]
if err := authmethod.ParseParam(*config.AuthenticationParam); err != nil {
fmt.Println("Authentication parameter is not valid:", err.Error())
fmt.Println(authmethod.Usage())
os.Exit(2)
}
} else {
fmt.Println("Authentication name is not valid:", name)
os.Exit(2)
}
level := "info" level := "info"
if *config.Debug { if *conf.Debug {
level = "debug" level = "debug"
} }
logger.Init(level) logger.Init(level)
logger.Debug.Println("Starting webhookd server...") logger.Debug.Println("Starting webhookd server...")
logger.Debug.Println("Using Authentication:", name)
authmethod.Init(*config.Debug)
router := http.NewServeMux()
router.Handle("/", api.Index(*config.Timeout, *config.ScriptDir))
router.Handle("/healthz", healthz())
nextRequestID := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
server := &http.Server{ server := &http.Server{
Addr: *config.ListenAddr, Addr: *conf.ListenAddr,
Handler: authmethod.Middleware()(tracing(nextRequestID)(logging(logger.Debug)(router))), Handler: api.NewRouter(config.Get()),
ErrorLog: logger.Error, ErrorLog: logger.Error,
} }
// Start the dispatcher. // Start the dispatcher.
logger.Debug.Printf("Starting the dispatcher (%d workers)...\n", *config.NbWorkers) logger.Debug.Printf("Starting the dispatcher (%d workers)...\n", *conf.NbWorkers)
worker.StartDispatcher(*config.NbWorkers) worker.StartDispatcher(*conf.NbWorkers)
done := make(chan bool) done := make(chan bool)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
@ -84,7 +49,7 @@ func main() {
go func() { go func() {
<-quit <-quit
logger.Debug.Println("Server is shutting down...") logger.Debug.Println("Server is shutting down...")
atomic.StoreInt32(&healthy, 0) api.Shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@ -96,51 +61,12 @@ func main() {
close(done) close(done)
}() }()
logger.Info.Println("Server is ready to handle requests at", *config.ListenAddr) logger.Info.Println("Server is ready to handle requests at", *conf.ListenAddr)
atomic.StoreInt32(&healthy, 1) api.Start()
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error.Fatalf("Could not listen on %s: %v\n", *config.ListenAddr, err) logger.Error.Fatalf("Could not listen on %s: %v\n", *conf.ListenAddr, err)
} }
<-done <-done
logger.Debug.Println("Server stopped") logger.Debug.Println("Server stopped")
} }
func healthz() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.LoadInt32(&healthy) == 1 {
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
})
}
func logging(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
requestID, ok := r.Context().Value(requestIDKey).(string)
if !ok {
requestID = "unknown"
}
logger.Println(requestID, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent())
}()
next.ServeHTTP(w, r)
})
}
}
func tracing(nextRequestID func() string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = nextRequestID()
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
w.Header().Set("X-Request-Id", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

32
pkg/api/healthz.go Normal file
View File

@ -0,0 +1,32 @@
package api
import (
"net/http"
"sync/atomic"
"github.com/ncarlier/webhookd/pkg/config"
)
var (
healthy int32
)
// Shutdown set API as stopped
func Shutdown() {
atomic.StoreInt32(&healthy, 0)
}
// Start set API as started
func Start() {
atomic.StoreInt32(&healthy, 1)
}
func healthz(conf *config.Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.LoadInt32(&healthy) == 1 {
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
})
}

View File

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/logger" "github.com/ncarlier/webhookd/pkg/logger"
"github.com/ncarlier/webhookd/pkg/tools" "github.com/ncarlier/webhookd/pkg/tools"
"github.com/ncarlier/webhookd/pkg/worker" "github.com/ncarlier/webhookd/pkg/worker"
@ -24,10 +25,10 @@ func atoiFallback(str string, fallback int) int {
return fallback return fallback
} }
// Index is the main handler of the API. // index is the main handler of the API.
func Index(timeout int, scrDir string) http.Handler { func index(conf *config.Config) http.Handler {
defaultTimeout = timeout defaultTimeout = *conf.Timeout
scriptDir = scrDir scriptDir = *conf.ScriptDir
return http.HandlerFunc(webhookHandler) return http.HandlerFunc(webhookHandler)
} }

36
pkg/api/router.go Normal file
View File

@ -0,0 +1,36 @@
package api
import (
"fmt"
"net/http"
"time"
"github.com/ncarlier/webhookd/pkg/auth"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/middleware"
)
// NewRouter creates router with declared routes
func NewRouter(conf *config.Config) *http.ServeMux {
router := http.NewServeMux()
authenticator := auth.NewAuthenticator(conf)
nextRequestID := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
for _, route := range routes {
var handler http.Handler
handler = route.HandlerFunc(conf)
handler = middleware.Logger(handler)
handler = middleware.Tracing(nextRequestID)(handler)
if authenticator != nil {
handler = middleware.Auth(handler, authenticator)
}
router.Handle(route.Path, handler)
}
return router
}

33
pkg/api/routes.go Normal file
View File

@ -0,0 +1,33 @@
package api
import (
"net/http"
"github.com/ncarlier/webhookd/pkg/config"
)
// HandlerFunc custom function handler
type HandlerFunc func(conf *config.Config) http.Handler
// Route is the structure of an HTTP route definition
type Route struct {
Method string
Path string
HandlerFunc HandlerFunc
}
// Routes is a list of Route
type Routes []Route
var routes = Routes{
Route{
"GET",
"/",
index,
},
Route{
"GET",
"/healtz",
healthz,
},
}

23
pkg/auth/auth.go Normal file
View File

@ -0,0 +1,23 @@
package auth
import (
"net/http"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/logger"
)
// Authenticator is a generic interface to validate an HTTP request
type Authenticator interface {
Validate(r *http.Request) bool
}
// NewAuthenticator creates new authenticator form the configuration
func NewAuthenticator(conf *config.Config) Authenticator {
authenticator, err := NewHtpasswdFromFile(*conf.PasswdFile)
if err != nil {
logger.Debug.Printf("unable to load htpasswd file: \"%s\" (%s)\n", *conf.PasswdFile, err)
return nil
}
return authenticator
}

View File

@ -1,27 +0,0 @@
package auth
import "net/http"
// Method an interface describing an authentication method
type Method interface {
// Called after ParseParam method.
// auth.Method should initialize itself here and get ready to receive requests.
// Logger has been initialized so it is safe to call logger methods here.
Init(debug bool)
// Return Method Usage Info
Usage() string
// Parse the parameter passed through the -authparam flag
// Logger is not initialized at this state so do NOT call logger methods
// If the parameter is unacceptable, return an error and main should exit
ParseParam(string) error
// Return a middleware to handle connections.
Middleware() func(http.Handler) http.Handler
}
var (
// AvailableMethods Returns a map of available auth methods
AvailableMethods = map[string]Method{
"none": new(noAuth),
"basic": new(basicAuth),
}
)

View File

@ -1,59 +0,0 @@
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/ncarlier/webhookd/pkg/logger"
)
type basicAuth struct {
username string
password string
authheader string
}
func (c *basicAuth) Init(_ bool) {}
func (c *basicAuth) Usage() string {
return "HTTP Basic Auth. Usage: -auth basic -authparam <username>:<password>[:<realm>] (example: -auth basic -auth-param foo:bar)"
}
func (c *basicAuth) ParseParam(param string) error {
res := strings.Split(param, ":")
realm := "Authentication required."
switch len(res) {
case 3:
realm = res[2]
fallthrough
case 2:
c.username, c.password = res[0], res[1]
c.authheader = fmt.Sprintf("Basic realm=\"%s\"", realm)
return nil
}
return errors.New("Invalid Auth param")
}
// BasicAuth HTTP Basic Auth implementation
func (c *basicAuth) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if username, password, ok := r.BasicAuth(); ok && username == c.username && password == c.password {
logger.Info.Printf("HTTP Basic Auth: %s PASSED\n", username)
next.ServeHTTP(w, r)
} else if !ok {
logger.Debug.Println("HTTP Basic Auth: Auth header not present.")
w.Header().Add("WWW-Authenticate", c.authheader)
w.WriteHeader(401)
w.Write([]byte("Authentication required."))
} else {
logger.Warning.Printf("HTTP Basic Auth: Invalid credentials for username %s\n", username)
w.WriteHeader(403)
w.Write([]byte("Forbidden."))
}
})
}
}

95
pkg/auth/htpasswd-file.go Normal file
View File

@ -0,0 +1,95 @@
package auth
import (
"crypto/sha1"
"encoding/base64"
"encoding/csv"
"net/http"
"os"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
)
var (
shaRe = regexp.MustCompile(`^{SHA}`)
bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
)
// HtpasswdFile is a map for usernames to passwords.
type HtpasswdFile struct {
path string
users map[string]string
}
// NewHtpasswdFromFile reads the users and passwords from a htpasswd file and returns them.
func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) {
r, err := os.Open(path)
if err != nil {
return nil, err
}
defer r.Close()
cr := csv.NewReader(r)
cr.Comma = ':'
cr.Comment = '#'
cr.TrimLeadingSpace = true
records, err := cr.ReadAll()
if err != nil {
return nil, err
}
users := make(map[string]string)
for _, record := range records {
users[record[0]] = record[1]
}
return &HtpasswdFile{
path: path,
users: users,
}, nil
}
// Validate HTTP request credentials
func (h *HtpasswdFile) Validate(r *http.Request) bool {
s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(s) != 2 {
return false
}
b, err := base64.StdEncoding.DecodeString(s[1])
if err != nil {
return false
}
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
return false
}
return h.validateCredentials(pair[0], pair[1])
}
func (h *HtpasswdFile) validateCredentials(user string, password string) bool {
pwd, exists := h.users[user]
if !exists {
return false
}
switch {
case shaRe.MatchString(pwd):
d := sha1.New()
_, _ = d.Write([]byte(password))
if pwd[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
return true
}
case bcrRe.MatchString(pwd):
err := bcrypt.CompareHashAndPassword([]byte(pwd), []byte(password))
if err == nil {
return true
}
}
return false
}

View File

@ -0,0 +1,15 @@
package auth
import (
"testing"
"github.com/ncarlier/webhookd/pkg/assert"
)
func TestValidateCredentials(t *testing.T) {
htpasswdFile, err := NewHtpasswdFromFile("test.htpasswd")
assert.Nil(t, err, ".htpasswd file should be loaded")
assert.NotNil(t, htpasswdFile, ".htpasswd file should be loaded")
assert.Equal(t, true, htpasswdFile.validateCredentials("foo", "bar"), "credentials should be valid")
assert.Equal(t, false, htpasswdFile.validateCredentials("foo", "bir"), "credentials should not be valid")
}

View File

@ -1,25 +0,0 @@
package auth
import (
"net/http"
)
type noAuth struct {
}
func (c *noAuth) Usage() string {
return "No Auth. Usage: -auth none"
}
func (c *noAuth) Init(_ bool) {}
func (c *noAuth) ParseParam(_ string) error {
return nil
}
// NoAuth A Nop Auth middleware
func (c *noAuth) Middleware() func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return h
}
}

2
pkg/auth/test.htpasswd Normal file
View File

@ -0,0 +1,2 @@
# htpasswd -B -c test.htpasswd foo
foo:$2y$05$068L1J0kA3FEh8jHSlnluut4gYleWd47Ig/AWztz8/8bQS6tHvtd.

60
pkg/config/config.go Normal file
View File

@ -0,0 +1,60 @@
package config
import (
"flag"
"os"
"strconv"
)
// Config contain global configuration
type Config struct {
ListenAddr *string
NbWorkers *int
Debug *bool
Timeout *int
ScriptDir *string
PasswdFile *string
}
var config = &Config{
ListenAddr: flag.String("listen", getEnv("LISTEN_ADDR", ":8080"), "HTTP service address (e.g.address, ':8080')"),
NbWorkers: flag.Int("nb-workers", getIntEnv("NB_WORKERS", 2), "The number of workers to start"),
Debug: flag.Bool("debug", getBoolEnv("DEBUG", false), "Output debug logs"),
Timeout: flag.Int("timeout", getIntEnv("HOOK_TIMEOUT", 10), "Hook maximum delay before timeout (in second)"),
ScriptDir: flag.String("scripts", getEnv("SCRIPTS_DIR", "scripts"), "Scripts directory"),
PasswdFile: flag.String("passwd", getEnv("PASSWD_FILE", ".htpasswd"), "Password file (encoded with htpasswd)"),
}
func init() {
flag.StringVar(config.ListenAddr, "l", *config.ListenAddr, "HTTP service (e.g address: ':8080')")
flag.BoolVar(config.Debug, "d", *config.Debug, "Output debug logs")
flag.StringVar(config.PasswdFile, "p", *config.PasswdFile, "Password file (encoded with htpasswd)")
}
// Get global configuration
func Get() *Config {
return config
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv("APP_" + key); ok {
return value
}
return fallback
}
func getIntEnv(key string, fallback int) int {
strValue := getEnv(key, strconv.Itoa(fallback))
if value, err := strconv.Atoi(strValue); err == nil {
return value
}
return fallback
}
func getBoolEnv(key string, fallback bool) bool {
strValue := getEnv(key, strconv.FormatBool(fallback))
if value, err := strconv.ParseBool(strValue); err == nil {
return value
}
return fallback
}

20
pkg/middleware/auth.go Normal file
View File

@ -0,0 +1,20 @@
package middleware
import (
"net/http"
"github.com/ncarlier/webhookd/pkg/auth"
)
// Auth is a middleware to checks HTTP request credentials
func Auth(inner http.Handler, authn auth.Authenticator) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if authn.Validate(r) {
inner.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Ah ah ah, you didn't say the magic word"`)
w.WriteHeader(401)
w.Write([]byte("401 Unauthorized\n"))
})
}

29
pkg/middleware/logger.go Normal file
View File

@ -0,0 +1,29 @@
package middleware
import (
"net/http"
"time"
"github.com/ncarlier/webhookd/pkg/logger"
)
type key int
const (
requestIDKey key = 0
)
// Logger is a middleware to log HTTP request
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
requestID, ok := r.Context().Value(requestIDKey).(string)
if !ok {
requestID = "unknown"
}
logger.Info.Println(requestID, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), time.Since(start))
}()
next.ServeHTTP(w, r)
})
}

21
pkg/middleware/tracing.go Normal file
View File

@ -0,0 +1,21 @@
package middleware
import (
"context"
"net/http"
)
// Tracing is a middleware to trace HTTP request
func Tracing(nextRequestID func() string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = nextRequestID()
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
w.Header().Set("X-Request-Id", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}