From 0dbd2c72ebe0952a79631559eb3b971afb383326 Mon Sep 17 00:00:00 2001 From: Adrien PONSIN Date: Wed, 30 Apr 2025 22:40:03 +0200 Subject: [PATCH] add first metrics --- Dockerfile | 34 ++++++++++++++++++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++++++++++++++- command/serve.go | 46 ++++++++++++++++++++++++++++++++++++++++++---- go.mod | 10 +++++++++- go.sum | 20 ++++++++++++++++++++ metric.go | 19 +++++++++++++++++++ 6 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 Dockerfile create mode 100644 metric.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..04be546 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24.2 AS build + +WORKDIR /go/src/middleman + +COPY . . + +ARG VERSION=dev + +# echo -n "not tied to a commit" | sha1sum +ARG COMMIT=dbf242029aeedcfe71af2e5843474d8f8e2e9d63 + +RUN go build -ldflags="-s -w -B gobuildid -X main.version=$VERSION -X main.commit=$COMMIT -linkmode external -extldflags -static" -buildmode=pie -trimpath -o middleman cmd/middleman/main.go + +FROM scratch + +COPY --from=build /etc/group /etc/group + +COPY --from=build /etc/passwd /etc/passwd + +WORKDIR / + +COPY --from=build /go/src/middleman/middleman . + +EXPOSE 2375/tcp + +USER nobody:nogroup + +HEALTHCHECK --interval=5s --timeout=2s CMD ["/middleman", "healthcheck"] + +ENTRYPOINT ["/middleman"] + +CMD ["serve"] \ No newline at end of file diff --git a/README.md b/README.md index 9e72be3..328202d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ # middleman -Securely mount the Docker socket: apply fine-grained access control to Docker socket HTTP requests. \ No newline at end of file +Securely mount the Docker socket: apply fine-grained access control to Docker socket HTTP requests. + +```shell +$ openssl ecparam -check -name prime256v1 -genkey -noout -out key.pem -rand /dev/urandom +$ vim traefik.cfg +``` + +``` +[req] +distinguished_name = req_distinguished_name +prompt = no +default_md = sha256 + +[req_distinguished_name] +CN = infra.local + +[database] +basicConstraints = CA:false +authorityKeyIdentifier = keyid,issuer +subjectKeyIdentifier = hash +keyUsage = digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = infra.local +DNS.2 = *.infra.local +DNS.3 = localhost +IP.1 = 127.0.0.1 +``` + +```shell +$ openssl req -new -key key.pem -out csr.pem -rand /dev/urandom -config traefik.cfg +$ openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out cert.pem -extensions properties -extfile traefik.cfg +``` + +```shell +$ docker compose --project-name infra up --detach +$ docker compose --project-name infra down --remove-orphans --volumes +``` \ No newline at end of file diff --git a/command/serve.go b/command/serve.go index 3a7c010..426743c 100644 --- a/command/serve.go +++ b/command/serve.go @@ -7,6 +7,8 @@ import ( "gitea.illuad.fr/adrien/middleman" "gitea.illuad.fr/adrien/middleman/flag" "gitea.illuad.fr/adrien/middleman/pkg/fastrp" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "golang.org/x/sync/errgroup" @@ -16,6 +18,7 @@ import ( "net/url" "regexp" "slices" + "strconv" "strings" "time" ) @@ -70,7 +73,7 @@ const ( // defaultShutdownTimeout is the default server shutdown timeout. defaultShutdownTimeout = 5 * time.Second // defaultHealthcheckRetry is the default number of healthcheck retry - defaultHealthcheckRetry uint64 = 3 + defaultHealthcheckRetry uint = 3 // defaultHealthEndpoint is the default health endpoint. defaultHealthEndpoint = "/healthz" // defaultHealthListenAddr is the proxy health endpoint default listen address and port. @@ -130,27 +133,39 @@ func Serve(group *errgroup.Group) *cli.Command { } } +// https://www.civo.com/learn/build-your-own-prometheus-exporter-in-go func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + middleman.RequestHandled.Inc() + timer := prometheus.NewTimer(middleman.ProcessingRequest.WithLabelValues(r.URL.Path)) + defer timer.ObserveDuration() + // metric: incoming request handled log.Debug().Str("remote_addr", r.RemoteAddr).Str("method", r.Method).Str("path", r.URL.Path).Msg("incoming request") if mr, ok := containerMethodRegex["*"]; ok { + // metric: wildcard container match if code := ph.checkMethodAndRegex(mr, r, ""); code != http.StatusOK { + // metric: request failed to match the regex for wildcard container http.Error(w, http.StatusText(code), code) return } + // metric: request successfully match the regex for a wildcard container ph.frp.ServeHTTP(w, r) return } + // metric: non wildcard container match host, _, _ := net.SplitHostPort(r.RemoteAddr) for containerName, mr := range containerMethodRegex { if ph.isContainerAuthorized(containerName, host) { if code := ph.checkMethodAndRegex(mr, r, containerName); code != http.StatusOK { + // metric: request failed to match the regex for a non wildcard container http.Error(w, http.StatusText(code), code) return } + // metric: request successfully match the regex for a non wildcard container ph.frp.ServeHTTP(w, r) return } } + // metric: container/client? is not authorized to dial with the proxy logDeniedRequest(r, http.StatusUnauthorized, "this container is not on the list of authorized ones") http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) } @@ -158,13 +173,16 @@ func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (ph *ProxyHandler) isContainerAuthorized(containerName, host string) bool { resolvedIPs, err := net.LookupIP(containerName) if err != nil { + // metric: failed to resolve return false } for resolvedIP := range slices.Values(resolvedIPs) { - if resolvedIP.Equal(net.ParseIP(host)) { + if resolvedIP.Equal(net.ParseIP(host)) { // Should I check net.ParseIP do not return a nil value? + // metric: container is authorized return true } } + // metric: container is unauthorized return false } @@ -191,13 +209,16 @@ func logAuthorizedRequest(r *http.Request, containerName, message string) { func (ph *ProxyHandler) checkMethodAndRegex(mr methodRegex, r *http.Request, containerName string) int { req, ok := mr[r.Method] if !ok { + middleman.ProcessedRequest.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(http.StatusMethodNotAllowed)) logDeniedRequest(r, http.StatusMethodNotAllowed, "this HTTP method is not in the list of those authorized for this container") return http.StatusMethodNotAllowed } if !req.MatchString(r.URL.Path) { + middleman.ProcessedRequest.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(http.StatusForbidden)) logDeniedRequest(r, http.StatusForbidden, "this path does not match any regular expression for this HTTP method") return http.StatusForbidden } + middleman.ProcessedRequest.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(http.StatusOK)) logAuthorizedRequest(r, containerName, "incoming request matches a registered regular expression") return http.StatusOK } @@ -207,6 +228,20 @@ func (s serve) action(ctx context.Context, command *cli.Command) error { if err := ctx.Err(); err != nil { return err } + registry := prometheus.NewRegistry() + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + registry.MustRegister(middleman.RequestHandled, middleman.ProcessingRequest, middleman.ProcessedRequest) + metricsSrv := http.Server{ + Addr: ":8107", + Handler: metricsMux, + } + s.group.Go(func() error { + if err := metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + }) ap, err := parseAddrPorts(command.String(listenAddrFlagName), command.String(healthListenAddrFlagName)) if err != nil { return err @@ -239,7 +274,7 @@ func (s serve) action(ctx context.Context, command *cli.Command) error { if retry == 0 { return err } - log.Err(err).Uint64("retry_remaining", retry).Send() + log.Err(err).Uint("retry_remaining", retry).Send() retry-- } } @@ -283,7 +318,7 @@ func (s serve) action(ctx context.Context, command *cli.Command) error { Stringer("shutdown_timeout", command.Duration(shutdownTimeoutFlagName)). Bool("docker_socket_healthcheck_disabled", command.Bool(noDockerSocketHealthcheckFlagName)). Bool("shutdown_on_failure_disabled", command.Bool(noShutdownFlagName)). - Uint64("number_of_healthcheck_retry", command.Uint(healthcheckRetryFlagName)). + Uint("number_of_healthcheck_retry", command.Uint(healthcheckRetryFlagName)). Str("health_endpoint", command.String(healthEndpointFlagName)). Stringer("health_endpoint_listen_addr", ap.healthAddrPort). Strs("requests", command.StringSlice(addRequestsFlagName)). @@ -293,6 +328,9 @@ func (s serve) action(ctx context.Context, command *cli.Command) error { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), command.Duration(shutdownTimeoutFlagName)) defer cancel() + if err = metricsSrv.Close(); err != nil { + log.Err(err).Send() + } if err = healthSrv.Close(); err != nil { log.Err(err).Send() } diff --git a/go.mod b/go.mod index 1359ffd..59a6dbe 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,20 @@ go 1.24.2 require ( github.com/rs/zerolog v1.34.0 - github.com/urfave/cli/v3 v3.1.1 + github.com/urfave/cli/v3 v3.3.2 golang.org/x/sync v0.13.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect golang.org/x/sys v0.32.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/go.sum b/go.sum index 7a3198f..5a45588 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -9,7 +13,17 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= @@ -20,6 +34,10 @@ github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjc github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= github.com/urfave/cli/v3 v3.1.1 h1:bNnl8pFI5dxPOjeONvFCDFoECLQsceDG4ejahs4Jtxk= github.com/urfave/cli/v3 v3.1.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/urfave/cli/v3 v3.2.0 h1:m8WIXY0U9LCuUl5r+0fqLWDhNYWt6qvlW+GcF4EoXf8= +github.com/urfave/cli/v3 v3.2.0/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= +github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= @@ -36,3 +54,5 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/metric.go b/metric.go new file mode 100644 index 0000000..a1922a2 --- /dev/null +++ b/metric.go @@ -0,0 +1,19 @@ +package middleman + +import "github.com/prometheus/client_golang/prometheus" + +var ( + RequestHandled = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "http_requests_handled_total", + Help: "Total number of HTTP requests handled", + }) + ProcessingRequest = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "time_spent_processing_request_seconds", + Help: "Number of seconds spent processing a request", + }, []string{"path"}) + ProcessedRequest = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "http_requests_processed_total", + Help: "Total number of HTTP requests processed", + ConstLabels: nil, + }, []string{"method", "path", "status"}) +)