diff --git a/Makefile b/Makefile index 31aa05d..d3f6830 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GitCommit := $(shell git rev-parse HEAD) LDFLAGS := "-s -w -X github.com/tschaefer/rpinfo/version.Version=$(Version) -X github.com/tschaefer/rpinfo/version.GitCommit=$(GitCommit)" .PHONY: all -all: fmt lint dist +all: fmt lint test dist .PHONY: fmt fmt: @@ -29,6 +29,10 @@ checksum: done && \ cd .. +.PHONY: test +test: + test -z $(shell go test ./... 2>&1 >/dev/null || echo 1) || (echo "[WARN] Fix test issues" && exit 1) + .PHONY: clean clean: rm -rf bin diff --git a/README.md b/README.md index 03e7e22..7b54bbe 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,17 @@ Start the server on `localhost:8080` by default. ``` For further configuration, see the command-line options below. -| Flag | Description | Default | -|-------------------|--------------------------------------|-------------| -| `-H`, `--host` | Host to bind the server to | `localhost` | -| `-p`, `--port` | Port to run the server on | `8080` | -| `-a`, `--auth` | Enable bearer token authentication | `false` | -| `-t`, `--token` | Bearer token used for authentication | | -| `-m`, `--metrics` | Enable Prometheus metrics endpoint | `false` | -| `-r`, `--redoc` | Enable ReDoc API documentation | `false` | -| `-h`, `--help` | Show help for the server command | | +| Flag | Description | Default | +|----------------------|-------------------------------------------------|-------------| +| `-H`, `--host` | Host to bind the server to | `localhost` | +| `-p`, `--port` | Port to run the server on | `8080` | +| `-a`, `--auth` | Enable bearer token authentication | `false` | +| `-t`, `--token` | Bearer token used for authentication | | +| `-m`, `--metrics` | Enable Prometheus metrics endpoint | `false` | +| `-r`, `--redoc` | Enable ReDoc API documentation | `false` | +| `-f`, `--log-format` | Set log format: `text`, `structured`, `json` | `text` | +| `-l`, `--log-level` | Set log level: `debug`, `info`, `warn`, `error` | `info` | +| `-h`, `--help` | Show help for the server command | | Additional a systemd service file and environment file are provided in the [contrib directory](https://github.com/tschaefer/rpinfo/tree/main/contrib) for automatic startup on boot and management of the diff --git a/cmd/server.go b/cmd/server.go index bcd8728..de29794 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -23,6 +23,8 @@ func init() { serverCmd.Flags().StringP("token", "t", "", "Bearer Token for authentication") serverCmd.Flags().BoolP("metrics", "m", false, "Enable Prometheus metrics") serverCmd.Flags().BoolP("redoc", "r", false, "Enable ReDoc API documentation") + serverCmd.Flags().StringP("log-format", "f", "text", "Log format (text, structured, json)") + serverCmd.Flags().StringP("log-level", "l", "info", "Log level (debug, info, warn, error)") rootCmd.AddCommand(serverCmd) } @@ -36,6 +38,8 @@ func RunServerCmd(cmd *cobra.Command, args []string) error { config.Token, _ = cmd.Flags().GetString("token") config.Metrics, _ = cmd.Flags().GetBool("metrics") config.Redoc, _ = cmd.Flags().GetBool("redoc") + config.LogFormat, _ = cmd.Flags().GetString("log-format") + config.LogLevel, _ = cmd.Flags().GetString("log-level") server.Run(config) diff --git a/go.mod b/go.mod index 67f7bd8..2bc12ad 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,19 @@ module github.com/tschaefer/rpinfo go 1.24.2 require ( + github.com/VictoriaMetrics/metrics v1.38.0 github.com/gorilla/mux v1.8.1 github.com/spf13/cobra v1.9.1 - github.com/VictoriaMetrics/metrics v1.38.0 + github.com/stretchr/testify v1.11.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect golang.org/x/sys v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9dcc3d5..180da11 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,28 @@ github.com/VictoriaMetrics/metrics v1.38.0 h1:1d0dRgVH8Nnu8dKMfisKefPC3q7gqf3/odyO0quAvyA= github.com/VictoriaMetrics/metrics v1.38.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/handler/error.go b/server/handler/error.go index 580bbb9..73a1ecc 100644 --- a/server/handler/error.go +++ b/server/handler/error.go @@ -6,8 +6,10 @@ package handler import ( "encoding/json" + "log/slog" "net/http" + "github.com/tschaefer/rpinfo/server/log" "github.com/tschaefer/rpinfo/version" ) @@ -20,9 +22,11 @@ func JSONError(w http.ResponseWriter, status int, message string) { } func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + go log.Request(r, http.StatusInternalServerError, slog.LevelError, "not found") JSONError(w, http.StatusNotFound, "not found") } func MethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) { + go log.Request(r, http.StatusMethodNotAllowed, slog.LevelWarn, "method not allowed") JSONError(w, http.StatusMethodNotAllowed, "method not allowed") } diff --git a/server/handler/handler.go b/server/handler/handler.go index 1d0b66d..a70d503 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -11,6 +11,7 @@ import ( "net/http" "strings" + "github.com/tschaefer/rpinfo/server/log" "github.com/tschaefer/rpinfo/vcgencmd" ) @@ -18,11 +19,13 @@ type Handle struct { Cmd vcgencmd.Exec } -func runCmd(h Handle, w http.ResponseWriter, args ...string) map[string]string { - out := h.Cmd.Run(args...) - if out == nil { +func runCmd(h Handle, w http.ResponseWriter, r *http.Request, args ...string) map[string]string { + out, err := h.Cmd.Run(args...) + if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) + + go log.RequestError(r, http.StatusInternalServerError, err.Error()) json.NewEncoder(w).Encode(map[string]string{"detail": "internal server error"}) return nil } @@ -31,11 +34,12 @@ func runCmd(h Handle, w http.ResponseWriter, args ...string) map[string]string { } func (h Handle) Temperature(w http.ResponseWriter, r *http.Request) { - temp := runCmd(h, w, "measure_temp") + temp := runCmd(h, w, r, "measure_temp") if temp == nil { return } + go log.RequestInfo(r, http.StatusOK, "Fetched temperature") json.NewEncoder(w).Encode(temp) } @@ -43,7 +47,7 @@ func (h Handle) Configuration(w http.ResponseWriter, r *http.Request) { options := []string{"int", "str"} config := make(map[string]string) for _, opt := range options { - out := runCmd(h, w, "get_config", opt) + out := runCmd(h, w, r, "get_config", opt) if out == nil { return } @@ -51,6 +55,7 @@ func (h Handle) Configuration(w http.ResponseWriter, r *http.Request) { maps.Copy(config, out) } + go log.RequestInfo(r, http.StatusOK, "Fetched configuration") json.NewEncoder(w).Encode(config) } @@ -58,7 +63,7 @@ func (h Handle) Voltages(w http.ResponseWriter, r *http.Request) { options := []string{"core", "sdram_c", "sdram_i", "sdram_p"} voltages := make(map[string]string) for _, opt := range options { - out := runCmd(h, w, "measure_volts", opt) + out := runCmd(h, w, r, "measure_volts", opt) if out == nil { return } @@ -66,11 +71,12 @@ func (h Handle) Voltages(w http.ResponseWriter, r *http.Request) { voltages[opt] = out["volt"] } + go log.RequestInfo(r, http.StatusOK, "Fetched voltages") json.NewEncoder(w).Encode(voltages) } func (h Handle) Throttled(w http.ResponseWriter, r *http.Request) { - throttled := runCmd(h, w, "get_throttled") + throttled := runCmd(h, w, r, "get_throttled") if throttled == nil { return } @@ -84,6 +90,7 @@ func (h Handle) Throttled(w http.ResponseWriter, r *http.Request) { throttled["throttled"] = message } + go log.RequestInfo(r, http.StatusOK, "Fetched throttled status") json.NewEncoder(w).Encode(throttled) } @@ -95,7 +102,7 @@ func (h Handle) Clock(w http.ResponseWriter, r *http.Request) { } clock := make(map[string]string) for _, opt := range options { - out := runCmd(h, w, "measure_clock", opt) + out := runCmd(h, w, r, "measure_clock", opt) if out == nil { return } @@ -111,5 +118,6 @@ func (h Handle) Clock(w http.ResponseWriter, r *http.Request) { clock[opt] = value() } + go log.RequestInfo(r, http.StatusOK, "Fetched clock rates") json.NewEncoder(w).Encode(clock) } diff --git a/server/handler/handler_test.go b/server/handler/handler_test.go index b2225a8..ccd9dd3 100644 --- a/server/handler/handler_test.go +++ b/server/handler/handler_test.go @@ -5,6 +5,7 @@ Licensed under the MIT license, see LICENSE in the project root for details. package handler import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -15,45 +16,45 @@ import ( type mockRunnerSuccess struct{} -func (m mockRunnerSuccess) Run(args ...string) map[string]string { +func (m mockRunnerSuccess) Run(args ...string) (map[string]string, error) { switch args[0] { case "measure_temp": - return map[string]string{"temp": "45.0'C"} + return map[string]string{"temp": "45.0'C"}, nil case "measure_volts": switch args[1] { case "core": - return map[string]string{"volt": "1.3500V"} + return map[string]string{"volt": "1.3500V"}, nil case "sdram_c": - return map[string]string{"volt": "1.2000V"} + return map[string]string{"volt": "1.2000V"}, nil case "sdram_i": - return map[string]string{"volt": "1.2000V"} + return map[string]string{"volt": "1.2000V"}, nil case "sdram_p": - return map[string]string{"volt": "1.2250V"} + return map[string]string{"volt": "1.2250V"}, nil default: - return nil + return nil, nil } case "get_config": - return map[string]string{"init_uart_clock": "0x2dc6c00", "overlay_prefix": "overlays/", "total_mem": "512"} + return map[string]string{"init_uart_clock": "0x2dc6c00", "overlay_prefix": "overlays/", "total_mem": "512"}, nil case "get_throttled": - return map[string]string{"throttled": "0x50000"} + return map[string]string{"throttled": "0x50000"}, nil case "measure_clock": switch args[1] { case "arm": - return map[string]string{"freq": "600000000"} + return map[string]string{"freq": "600000000"}, nil case "core": - return map[string]string{"freq": "250000000"} + return map[string]string{"freq": "250000000"}, nil default: - return map[string]string{"freq": "0"} + return map[string]string{"freq": "0"}, nil } default: - return nil + return nil, nil } } type mockRunnerError struct{} -func (m mockRunnerError) Run(args ...string) map[string]string { - return nil +func (m mockRunnerError) Run(args ...string) (map[string]string, error) { + return nil, fmt.Errorf("command failed") } func Test_TemperatureReturnsJSON(t *testing.T) { diff --git a/server/handler/metrics.go b/server/handler/metrics.go index 4eceea6..d86ee66 100644 --- a/server/handler/metrics.go +++ b/server/handler/metrics.go @@ -14,6 +14,7 @@ import ( "strconv" "github.com/VictoriaMetrics/metrics" + "github.com/tschaefer/rpinfo/server/log" "github.com/tschaefer/rpinfo/vcgencmd" "github.com/tschaefer/rpinfo/version" ) @@ -48,9 +49,12 @@ func Metrics(w http.ResponseWriter, r *http.Request) { var buffer bytes.Buffer rpi.WritePrometheus(&buffer) if _, err := w.Write(buffer.Bytes()); err != nil { + + go log.RequestError(r, http.StatusInternalServerError, fmt.Sprintf("Failed to write metrics: %v", err)) http.Error(w, "Failed to write metrics", http.StatusInternalServerError) return } + go log.RequestInfo(r, http.StatusOK, "Served metrics") } func clock(kind string) float64 { @@ -102,5 +106,10 @@ func voltage(kind string) float64 { func exec(args ...string) map[string]string { h := Handle{Cmd: vcgencmd.Cmd{}} - return h.Cmd.Run(args...) + out, err := h.Cmd.Run(args...) + if err != nil { + return nil + } + + return out } diff --git a/server/log/log.go b/server/log/log.go new file mode 100644 index 0000000..afdd825 --- /dev/null +++ b/server/log/log.go @@ -0,0 +1,99 @@ +/* +Copyright (c) 2025 Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package log + +import ( + "fmt" + "log/slog" + "net/http" + "os" +) + +func Logger(level, format string) error { + var leveler slog.Leveler + switch level { + case "debug": + leveler = slog.LevelDebug + case "info": + leveler = slog.LevelInfo + case "warn": + leveler = slog.LevelWarn + case "error": + leveler = slog.LevelError + default: + return fmt.Errorf("unknown log level: %s", level) + } + + opts := &slog.HandlerOptions{ + Level: leveler, + } + + var logger *slog.Logger + switch format { + case "structured": + logger = slog.New(slog.NewTextHandler(os.Stdout, opts)) + case "json": + logger = slog.New(slog.NewJSONHandler(os.Stdout, opts)) + case "text": + // Use default logger, print info level only. + return nil + default: + return fmt.Errorf("unknown log format: %s", format) + } + slog.SetDefault(logger) + + return nil +} + +func Request(r *http.Request, status int, level slog.Level, msg string) { + forwardedHeaders := []string{ + "X-Forwarded-For", + "X-Real-IP", + } + remoteAddr := r.RemoteAddr + for _, header := range forwardedHeaders { + if ip := r.Header.Get(header); ip != "" { + remoteAddr = ip + break + } + } + + args := []any{ + slog.String("RemoteAddr", remoteAddr), + slog.String("UserAgent", r.UserAgent()), + slog.Int("Status", status), + slog.String("RequestMethod", r.Method), + slog.String("RequestPath", r.RequestURI), + } + + switch level { + case slog.LevelInfo: + slog.Info(msg, args...) + case slog.LevelWarn: + slog.Warn(msg, args...) + case slog.LevelError: + slog.Error(msg, args...) + case slog.LevelDebug: + slog.Debug(msg, args...) + default: + slog.Info(msg, args...) + } +} + +func RequestDebug(r *http.Request, status int, msg string) { + Request(r, status, slog.LevelDebug, msg) +} + +func RequestInfo(r *http.Request, status int, msg string) { + Request(r, status, slog.LevelInfo, msg) +} + +func RequestWarn(r *http.Request, status int, msg string) { + Request(r, status, slog.LevelWarn, msg) +} + +func RequestError(r *http.Request, status int, msg string) { + Request(r, status, slog.LevelError, msg) +} diff --git a/server/log/log_test.go b/server/log/log_test.go new file mode 100644 index 0000000..5d306fb --- /dev/null +++ b/server/log/log_test.go @@ -0,0 +1,117 @@ +/* +Copyright (c) 2025 Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package log + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_LoggerReturnsErrorIfLevelIsUnknown(t *testing.T) { + err := Logger("fatal", "structured") + assert.NotNil(t, err) + assert.Equal(t, fmt.Errorf("unknown log level: fatal"), err) +} + +func Test_LoggerReturnsErrorIfFormatIsUnknown(t *testing.T) { + err := Logger("info", "xml") + assert.NotNil(t, err) + assert.Equal(t, fmt.Errorf("unknown log format: xml"), err) +} + +func Test_LoggerReturnsNoErrorIfLevelAndFormatAreKnown(t *testing.T) { + levels := []string{"debug", "info", "warn", "error"} + formats := []string{"structured", "json", "text"} + + for _, level := range levels { + for _, format := range formats { + err := Logger(level, format) + assert.Nil(t, err) + } + } +} + +func Test_RequestWritesLogMessage(t *testing.T) { + var b strings.Builder + w := io.Writer(&b) + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}) + l := slog.New(h) + slog.SetDefault(l) + + r := httptest.NewRequest("GET", "/temperature", nil) + r.Header.Set("User-Agent", "rpinfo/1.0") + Request(r, http.StatusOK, slog.LevelInfo, "This is a message") + + assert.Contains(t, b.String(), `"level":"INFO"`) + assert.Contains(t, b.String(), `"msg":"This is a message"`) + assert.Contains(t, b.String(), `"RemoteAddr":"`+r.RemoteAddr+`"`) + assert.Contains(t, b.String(), `"UserAgent":"rpinfo/1.0"`) + assert.Contains(t, b.String(), `"Status":200`) + assert.Contains(t, b.String(), `"RequestMethod":"GET"`) + assert.Contains(t, b.String(), `"RequestPath":"/temperature"`) +} + +func Test_RequestDebugWritesDebugMessage(t *testing.T) { + var b strings.Builder + w := io.Writer(&b) + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug}) + l := slog.New(h) + slog.SetDefault(l) + + r := httptest.NewRequest("GET", "/temperature", nil) + RequestDebug(r, http.StatusOK, "This is a debug message") + + assert.Contains(t, b.String(), `"level":"DEBUG"`) + assert.Contains(t, b.String(), `"msg":"This is a debug message"`) +} + +func Test_RequestErrorWritesErrorMessage(t *testing.T) { + var b strings.Builder + w := io.Writer(&b) + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelError}) + l := slog.New(h) + slog.SetDefault(l) + + r := httptest.NewRequest("GET", "/temperature", nil) + RequestError(r, http.StatusInternalServerError, "This is a error message") + + assert.Contains(t, b.String(), `"level":"ERROR"`) + assert.Contains(t, b.String(), `"msg":"This is a error message"`) +} + +func Test_RequestWarnWritesWarnMessage(t *testing.T) { + var b strings.Builder + w := io.Writer(&b) + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelWarn}) + l := slog.New(h) + slog.SetDefault(l) + + r := httptest.NewRequest("GET", "/temperature", nil) + RequestWarn(r, http.StatusInternalServerError, "This is a warning message") + + assert.Contains(t, b.String(), `"level":"WARN"`) + assert.Contains(t, b.String(), `"msg":"This is a warning message"`) +} + +func Test_RequestInfoWritesInfoMessage(t *testing.T) { + var b strings.Builder + w := io.Writer(&b) + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}) + l := slog.New(h) + slog.SetDefault(l) + + r := httptest.NewRequest("GET", "/temperature", nil) + RequestInfo(r, http.StatusOK, "This is a info message") + + assert.Contains(t, b.String(), `"level":"INFO"`) + assert.Contains(t, b.String(), `"msg":"This is a info message"`) +} diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go index 535d9c7..99c1c28 100644 --- a/server/middleware/middleware.go +++ b/server/middleware/middleware.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" + "github.com/tschaefer/rpinfo/server/log" "github.com/tschaefer/rpinfo/version" ) @@ -31,6 +32,7 @@ func RequestHeaders(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if accept == "" || (accept != "application/json" && accept != "*/*") { + go log.RequestWarn(r, http.StatusNotAcceptable, "not acceptable") JSONError(w, http.StatusNotAcceptable, "not acceptable") return } @@ -47,12 +49,14 @@ func Authorization(auth bool, token string, next http.HandlerFunc) http.HandlerF bearer := r.Header.Get("Authorization") if bearer == "" { + go log.RequestWarn(r, http.StatusUnauthorized, "unauthorized") JSONError(w, http.StatusUnauthorized, "unauthorized") return } parts := strings.SplitN(bearer, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" || parts[1] != token { + go log.RequestWarn(r, http.StatusForbidden, "forbidden") JSONError(w, http.StatusForbidden, "forbidden") return } diff --git a/server/server.go b/server/server.go index 83c5578..2d47eb5 100644 --- a/server/server.go +++ b/server/server.go @@ -6,25 +6,29 @@ package server import ( "fmt" - "log" + "log/slog" "net/http" + "os" "time" "github.com/gorilla/mux" "github.com/tschaefer/rpinfo/server/assets" "github.com/tschaefer/rpinfo/server/handler" + "github.com/tschaefer/rpinfo/server/log" "github.com/tschaefer/rpinfo/server/middleware" "github.com/tschaefer/rpinfo/vcgencmd" "github.com/tschaefer/rpinfo/version" ) type Config struct { - Port string - Host string - Auth bool - Token string - Metrics bool - Redoc bool + Port string + Host string + Auth bool + Token string + Metrics bool + Redoc bool + LogFormat string + LogLevel string } func Run(config Config) { @@ -48,6 +52,11 @@ func Run(config Config) { router.NotFoundHandler = http.HandlerFunc(handler.NotFoundHandler) router.MethodNotAllowedHandler = http.HandlerFunc(handler.MethodNotAllowedHandler) + if err := log.Logger(config.LogLevel, config.LogFormat); err != nil { + slog.Error(fmt.Sprintf("Failed to set logger: %v", err)) + os.Exit(1) + } + server := &http.Server{ Addr: fmt.Sprintf("%s:%s", config.Host, config.Port), ReadTimeout: 5 * time.Second, @@ -56,7 +65,10 @@ func Run(config Config) { Handler: router, } - log.Printf("Starting rpinfo server. Version: %s - %s", version.Release(), version.Commit()) - log.Printf("Listening on %s:%s, auth: %t, metrics: %t, redoc: %t", config.Host, config.Port, config.Auth, config.Metrics, config.Redoc) - log.Fatal(server.ListenAndServe()) + slog.Info(fmt.Sprintf("Starting rpinfo server. Version: %s - %s", version.Release(), version.Commit())) + slog.Info(fmt.Sprintf("Listening on %s:%s, auth: %t, metrics: %t, redoc: %t", config.Host, config.Port, config.Auth, config.Metrics, config.Redoc)) + if err := server.ListenAndServe(); err != nil { + slog.Error(fmt.Sprintf("Failed to start server: %v", err)) + os.Exit(1) + } } diff --git a/vcgencmd/vcgencmd.go b/vcgencmd/vcgencmd.go index d81b444..c51dc42 100644 --- a/vcgencmd/vcgencmd.go +++ b/vcgencmd/vcgencmd.go @@ -5,27 +5,28 @@ Licensed under the MIT license, see LICENSE in the project root for details. package vcgencmd import ( - "log" + "fmt" + "log/slog" "os/exec" "strings" ) type Exec interface { - Run(args ...string) map[string]string + Run(args ...string) (map[string]string, error) } type Cmd struct{} -func (r Cmd) Run(args ...string) map[string]string { +func (r Cmd) Run(args ...string) (map[string]string, error) { execCommand := exec.Command("vcgencmd", args...) out, err := execCommand.Output() if err != nil { if out != nil { - log.Printf("vcgencmd error: %s", strings.TrimSpace(string(out))) + err = fmt.Errorf("vcgencmd error: %s - %v", strings.TrimSpace(string(out)), err) } else { - log.Printf("vcgencmd error: %s", err) + err = fmt.Errorf("vcgencmd error: %v", err) } - return nil + return nil, err } output := strings.TrimSpace(string(out)) @@ -34,7 +35,7 @@ func (r Cmd) Run(args ...string) map[string]string { for line := range strings.SplitSeq(output, "\n") { parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { - log.Printf("vcgencmd warn: skipping data: %s", line) + slog.Warn(fmt.Sprintf("vcgencmd warn: skipping data: %s", line)) continue } @@ -43,5 +44,5 @@ func (r Cmd) Run(args ...string) map[string]string { outputMap[key] = value } - return outputMap + return outputMap, nil }