Skip to content

Commit dfc6be4

Browse files
committed
Add audit wrapper to metrics endpoints
1 parent 1b5c0eb commit dfc6be4

File tree

3 files changed

+223
-6
lines changed

3 files changed

+223
-6
lines changed

internal/metrics/audit.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
© Copyright IBM Corporation 2025
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package metrics
18+
19+
import (
20+
"encoding/json"
21+
"fmt"
22+
"net/http"
23+
"os"
24+
"time"
25+
)
26+
27+
type auditEvent struct {
28+
Timestamp string `json:"timestamp"`
29+
Event string `json:"event"`
30+
Pod string `json:"pod"`
31+
RemoteAddr string `json:"remote_addr"`
32+
Endpoint string `json:"endpoint"`
33+
Result string `json:"result"`
34+
StatusCode int `json:"status_code"`
35+
QueuemanagerName string `json:"queuemanager_name"`
36+
}
37+
38+
// passthroughHandlerFuncWrapper does not modify the base handler
39+
func passthroughHandlerFuncWrapper(base http.HandlerFunc) http.HandlerFunc {
40+
return base
41+
}
42+
43+
// newAuditingHandlerFuncWrapper generates a handlerFuncWrapper which allows creation of handlers that log audit entries for every request
44+
func newAuditingHandlerFuncWrapper(qmName string, logger logHandler) handlerFuncWrapper {
45+
return func(base http.HandlerFunc) http.HandlerFunc {
46+
return func(w http.ResponseWriter, req *http.Request) {
47+
podName, _ := os.Hostname()
48+
event := auditEvent{
49+
Timestamp: time.Now().UTC().Format(time.RFC3339),
50+
Event: "metrics",
51+
Pod: podName,
52+
Endpoint: req.URL.RequestURI(),
53+
QueuemanagerName: qmName,
54+
RemoteAddr: req.RemoteAddr,
55+
}
56+
57+
capWriter := newStatusCapturingResponseWriter(w)
58+
base(capWriter, req)
59+
statusCode := capWriter.statusCode
60+
event.StatusCode = statusCode
61+
event.Result = http.StatusText(statusCode)
62+
63+
eventBytes, err := json.Marshal(event)
64+
if err != nil {
65+
logger.Append(fmt.Sprintf("Error writing audit log; next event may contain incomplete data: %s", err.Error()), false)
66+
fmt.Printf("Error constructing audit log event: %s\n", err.Error())
67+
}
68+
logger.Append(string(eventBytes), false)
69+
}
70+
}
71+
}
72+
73+
// wrappedHandler implements http.Handler using a stored http.HandlerFunc for the ServeHTTP method
74+
type wrappedHandler struct {
75+
handlerFunc http.HandlerFunc
76+
}
77+
78+
func (wh wrappedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79+
wh.handlerFunc(w, r)
80+
}
81+
82+
// wrapHandler creates a new http.Handler with the function passed as wrapper around the base handler's ServeHTTP method, allowing augmentation of an existing http.Handler's ServeHTTP behaviour
83+
func wrapHandler(base http.Handler, wrapperFunc handlerFuncWrapper) wrappedHandler {
84+
return wrappedHandler{
85+
handlerFunc: wrapperFunc(base.ServeHTTP),
86+
}
87+
}
88+
89+
type handlerFuncWrapper func(base http.HandlerFunc) http.HandlerFunc
90+
91+
// statusCapturingResponseWriter captures the status code sent to the client
92+
type statusCapturingResponseWriter struct {
93+
http.ResponseWriter
94+
statusCode int
95+
}
96+
97+
func newStatusCapturingResponseWriter(base http.ResponseWriter) *statusCapturingResponseWriter {
98+
return &statusCapturingResponseWriter{
99+
ResponseWriter: base,
100+
}
101+
}
102+
103+
func (c *statusCapturingResponseWriter) WriteHeader(statusCode int) {
104+
c.statusCode = statusCode
105+
c.ResponseWriter.WriteHeader(statusCode)
106+
}
107+
108+
type logHandler interface {
109+
Append(messageLine string, deduplicateLine bool)
110+
}

internal/metrics/audit_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package metrics
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestAuditingHandler(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
writeStatus bool
15+
statusCode int
16+
}{
17+
{"goodpath", true, http.StatusOK},
18+
{"badrequest", true, http.StatusBadRequest},
19+
{"noresponse", false, 0},
20+
}
21+
22+
for _, test := range tests {
23+
t.Run(test.name, func(t *testing.T) {
24+
logger := &auditTestLogger{}
25+
testAuditWrapper := newAuditingHandlerFuncWrapper(test.name, logger)
26+
testBaseFunc := func(w http.ResponseWriter, req *http.Request) {
27+
if test.writeStatus {
28+
w.WriteHeader(test.statusCode)
29+
}
30+
}
31+
handler := testAuditWrapper(testBaseFunc)
32+
recorder := &httptest.ResponseRecorder{}
33+
testRequest := httptest.NewRequest(http.MethodGet, "http://localhost/metrics", nil)
34+
testRequestRemote := testRequest.RemoteAddr
35+
36+
beforeEvent := time.Now().UTC()
37+
handler.ServeHTTP(recorder, testRequest)
38+
afterEvent := time.Now().UTC()
39+
40+
if test.writeStatus && recorder.Code != test.statusCode {
41+
t.Fatalf("Unexpected status code sent (expected %d, got %d)", test.statusCode, recorder.Code)
42+
}
43+
44+
if len(logger.logs) != 1 {
45+
t.Fatalf("Incorrect number of audit events produced (expect 1, got %d)", len(logger.logs))
46+
}
47+
event := logger.logs[0]
48+
49+
t.Logf("Audit event: %s", event)
50+
51+
decoded := auditEvent{}
52+
err := json.Unmarshal([]byte(event), &decoded)
53+
if err != nil {
54+
t.Fatalf("Failed to unmarshal audit event: %s", err.Error())
55+
}
56+
57+
if decoded.QueuemanagerName != test.name {
58+
t.Fatalf("Incorrect queuemanager name recorded in audit event (expected '%s', got '%s')", test.name, decoded.QueuemanagerName)
59+
}
60+
61+
if decoded.RemoteAddr != testRequestRemote {
62+
t.Fatalf("Incorrect remote address recorded in audit event (expected '%s', got '%s')", testRequestRemote, decoded.RemoteAddr)
63+
}
64+
65+
if test.writeStatus && decoded.StatusCode != test.statusCode {
66+
t.Fatalf("Unexpected status code recorded in audit event (expected %d, got %d)", test.statusCode, decoded.StatusCode)
67+
} else if !test.writeStatus && decoded.StatusCode != 0 {
68+
t.Fatalf("Unexpected status code recorded in audit event (expected 0, got %d)", decoded.StatusCode)
69+
}
70+
71+
ts, err := time.Parse(time.RFC3339, decoded.Timestamp)
72+
if err != nil {
73+
t.Fatalf("Failed to parse audit event timestamp: %s", err.Error())
74+
}
75+
if ts.Before(beforeEvent.Truncate(time.Second)) || ts.After(afterEvent) {
76+
t.Fatalf("Audit timestamp outside expected range (expected between '%s' and '%s', got '%s')", beforeEvent.Format(time.RFC3339), afterEvent.Format(time.RFC3339), decoded.Timestamp)
77+
}
78+
})
79+
}
80+
}
81+
82+
type auditTestLogger struct {
83+
logs []string
84+
}
85+
86+
func (a *auditTestLogger) Append(messageLine string, deduplicateLine bool) {
87+
a.logs = append(a.logs, messageLine)
88+
}

internal/metrics/metrics.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
"github.com/ibm-messaging/mq-container/internal/ready"
3030
"github.com/ibm-messaging/mq-container/pkg/logger"
31+
"github.com/ibm-messaging/mq-container/pkg/logrotation"
3132
"github.com/prometheus/client_golang/prometheus"
3233
"github.com/prometheus/client_golang/prometheus/promhttp"
3334
)
@@ -37,6 +38,11 @@ const (
3738

3839
// keyDirMetrics is the location of the TLS keys to use for HTTPS metrics
3940
keyDirMetrics = "/etc/mqm/metrics/pki/keys"
41+
42+
auditLogDirectory = "/var/mqm/errors"
43+
auditLogFilenameFormat = "metricaudit%02d.json"
44+
auditLogMaxBytes = 4 * 1024 * 1024
45+
auditLogNumFiles = 3
4046
)
4147

4248
var (
@@ -82,6 +88,17 @@ func startMetricsGathering(qmName string, log *logger.Logger) error {
8288
return fmt.Errorf("Failed to validate HTTPS metrics configuration: %v", err)
8389
}
8490

91+
// Generate appropriate audit log wrapper based on configuration
92+
auditWrapper := passthroughHandlerFuncWrapper
93+
if os.Getenv("MQ_LOGGING_METRICS_AUDIT_ENABLED") == "true" {
94+
auditLog := logrotation.NewRotatingLogger(auditLogDirectory, auditLogFilenameFormat, auditLogMaxBytes, auditLogNumFiles)
95+
err := auditLog.Init()
96+
if err != nil {
97+
return fmt.Errorf("Failed to set up metric audit log: %v", err)
98+
}
99+
auditWrapper = newAuditingHandlerFuncWrapper(qmName, auditLog)
100+
}
101+
85102
if httpsMetricsEnabled {
86103
log.Println("Starting HTTPS metrics gathering")
87104
} else {
@@ -121,12 +138,14 @@ func startMetricsGathering(qmName string, log *logger.Logger) error {
121138
}
122139

123140
// Setup HTTP server to handle requests from Prometheus
124-
http.Handle("/metrics", promhttp.Handler())
125-
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
126-
w.WriteHeader(200)
127-
// #nosec G104
128-
w.Write([]byte("Status: METRICS ACTIVE"))
129-
})
141+
http.Handle("/metrics", wrapHandler(promhttp.Handler(), auditWrapper))
142+
http.HandleFunc("/", auditWrapper(
143+
func(w http.ResponseWriter, r *http.Request) {
144+
w.WriteHeader(200)
145+
// #nosec G104
146+
w.Write([]byte("Status: METRICS ACTIVE"))
147+
},
148+
))
130149

131150
go func() {
132151
var err error

0 commit comments

Comments
 (0)