From b8fe9100792c74dd4099e3c377df240627a9d5e6 Mon Sep 17 00:00:00 2001 From: Philipp Hossner Date: Sun, 5 Jun 2022 00:39:25 +0200 Subject: [PATCH] Add option to pseudonymize the common name --- .github/workflows/default.yml | 6 +- .github/workflows/release.yml | 2 +- Makefile | 5 +- README.md | 1 + go.mod | 22 +- go.sum | 7 +- pkg/collector/openvpn.go | 13 +- pkg/command/command.go | 62 ++++-- pkg/command/command_test.go | 327 ++++++++++++++++++++++++++++++ pkg/config/config.go | 6 +- pkg/openvpn/parser_decorator.go | 5 + pkg/openvpn/pseudonymizer.go | 91 +++++++++ pkg/openvpn/pseudonymizer_test.go | 94 +++++++++ 13 files changed, 613 insertions(+), 28 deletions(-) create mode 100644 pkg/command/command_test.go create mode 100644 pkg/openvpn/parser_decorator.go create mode 100644 pkg/openvpn/pseudonymizer.go create mode 100644 pkg/openvpn/pseudonymizer_test.go diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 94bbe5a..4803637 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '^1.14.1' + go-version: '^1.17.6' - name: Tests run: | make fmt @@ -18,8 +18,8 @@ jobs: make test make build - name: golangci-lint - uses: golangci/golangci-lint-action@v2.3.0 + uses: golangci/golangci-lint-action@v3.2.0 with: - version: v1.33 + version: v1.46.2 - name: Build release type artifacts run: make release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7940c5..8d368a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '^1.14.1' + go-version: '^1.17.6' - name: Set VERSION env run: echo VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> $GITHUB_ENV - name: Build artifacts diff --git a/Makefile b/Makefile index ac9d003..b80f8bc 100644 --- a/Makefile +++ b/Makefile @@ -66,10 +66,7 @@ lint: .PHONY: test test: - @which goverage > /dev/null; if [ $$? -ne 0 ]; then \ - GO111MODULE=off $(GO) get -u github.com/haya14busa/goverage; \ - fi - goverage -v -coverprofile coverage.out $(PACKAGES) + go test $(PACKAGES) -v -covermode=atomic -cover -coverprofile coverage.out -coverpkg ./... .PHONY: build build: $(BIN)/$(EXECUTABLE) diff --git a/README.md b/README.md index 45b3ccd..ce5f568 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ GLOBAL OPTIONS: --web.root value Root path to exporter endpoints (default: "/") [$OPENVPN_EXPORTER_WEB_ROOT] --status-file value The OpenVPN status file(s) to export (example test:./example/version1.status ) [$OPENVPN_EXPORTER_STATUS_FILE] --disable-client-metrics Disables per client (bytes_received, bytes_sent, connected_since) metrics (default: false) [$OPENVPN_EXPORTER_DISABLE_CLIENT_METRICS] + --pseudonymize-client-metrics Replaces common name in per client (bytes_received, bytes_sent, connected_since) metrics with a pseudonym - will not persist across restarts (default: false) [$OPENVPN_EXPORTER_PSEUDONYMIZE_CLIENT_METRICS] --enable-golang-metrics Enables golang and process metrics for the exporter) (default: false) [$OPENVPN_EXPORTER_ENABLE_GOLANG_METRICS] --log.level value Only log messages with given severity (default: "info") [$OPENVPN_EXPORTER_LOG_LEVEL] --help, -h Show help (default: false) diff --git a/go.mod b/go.mod index 48718bd..a0d3e95 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,29 @@ module github.com/patrickjahns/openvpn_exporter -go 1.14 +go 1.17 require ( github.com/go-kit/kit v0.9.0 github.com/prometheus/client_golang v1.5.1 + github.com/stretchr/testify v1.4.0 github.com/urfave/cli/v2 v2.2.0 ) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logfmt/logfmt v0.4.0 // indirect + github.com/golang/protobuf v1.3.2 // indirect + github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.9.1 // indirect + github.com/prometheus/procfs v0.0.8 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 // indirect + gopkg.in/yaml.v2 v2.2.5 // indirect +) diff --git a/go.sum b/go.sum index 2c312f8..b5de2dd 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= @@ -36,8 +37,10 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -75,6 +78,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= @@ -92,12 +96,13 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/collector/openvpn.go b/pkg/collector/openvpn.go index fc54972..990bc28 100644 --- a/pkg/collector/openvpn.go +++ b/pkg/collector/openvpn.go @@ -12,6 +12,7 @@ import ( type OpenVPNCollector struct { logger log.Logger collectClientMetrics bool + parserFunction func(statusfile string) (*openvpn.Status, error) OpenVPNServer []OpenVPNServer LastUpdated *prometheus.Desc ConnectedClients *prometheus.Desc @@ -31,10 +32,18 @@ type OpenVPNServer struct { } // NewOpenVPNCollector returns a new OpenVPNCollector -func NewOpenVPNCollector(logger log.Logger, openVPNServer []OpenVPNServer, collectClientMetrics bool) *OpenVPNCollector { +func NewOpenVPNCollector(logger log.Logger, openVPNServer []OpenVPNServer, + parserDecorators []openvpn.ParserDecorator, collectClientMetrics bool) *OpenVPNCollector { + + parserFunc := openvpn.ParseFile + for _, parserFuncDecorator := range parserDecorators { + parserFunc = parserFuncDecorator.DecorateParseFile(parserFunc) + } + return &OpenVPNCollector{ logger: logger, OpenVPNServer: openVPNServer, + parserFunction: parserFunc, collectClientMetrics: collectClientMetrics, LastUpdated: prometheus.NewDesc( @@ -115,7 +124,7 @@ func (c *OpenVPNCollector) collect(ovpn OpenVPNServer, ch chan<- prometheus.Metr "statusFile", ovpn.StatusFile, "name", ovpn.Name, ) - status, err := openvpn.ParseFile(ovpn.StatusFile) + status, err := c.parserFunction(ovpn.StatusFile) if err != nil { level.Warn(c.logger).Log( "msg", "error parsing statusfile", diff --git a/pkg/command/command.go b/pkg/command/command.go index ae3d9ca..f8eb615 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "github.com/patrickjahns/openvpn_exporter/pkg/openvpn" + "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" "github.com/prometheus/client_golang/prometheus" @@ -18,7 +20,21 @@ import ( // Run parses the command line arguments and executes the program. func Run() error { + app, cfg := initApp() + + app.Action = func(c *cli.Context) error { + server, logger := run(cfg) + if err := server.ListenAndServe(); err != nil { + level.Error(logger).Log("msg", "http listenandserve error", "err", err) + return err + } + return nil + } + return app.Run(os.Args) +} + +func initApp() (*cli.App, *config.Config) { app := &cli.App{ Name: "openvpn_exporter", Version: version.Info(), @@ -78,6 +94,18 @@ func Run() error { Usage: "Disables per client (bytes_received, bytes_sent, connected_since) metrics", EnvVars: []string{"OPENVPN_EXPORTER_DISABLE_CLIENT_METRICS"}, }, + &cli.BoolFlag{ + Name: "pseudonymize-client-metrics", + Usage: "Pseudonymized per client (bytes_received, bytes_sent, connected_since) metrics by replacing " + + "usernames with a random string", + EnvVars: []string{"OPENVPN_EXPORTER_PSEUDONYMIZE_CLIENT_METRICS"}, + }, + &cli.IntFlag{ + Name: "pseudonymize-client-metrics-length", + Value: 8, + Usage: "Length of the client pseudonym string", + EnvVars: []string{"OPENVPN_EXPORTER_PSEUDONYMIZE_CLIENT_METRICS_LENGTH"}, + }, &cli.BoolFlag{ Name: "enable-golang-metrics", Value: false, @@ -97,17 +125,14 @@ func Run() error { app.Before = func(c *cli.Context) error { cfg.StatusCollector.StatusFile = c.StringSlice("status-file") cfg.StatusCollector.ExportClientMetrics = !c.Bool("disable-client-metrics") + cfg.StatusCollector.PseudonymizeClientMetrics = c.Bool("pseudonymize-client-metrics") + cfg.StatusCollector.PseudonymizeClientMetricsLength = c.Int("pseudonymize-client-metrics-length") return nil } - - app.Action = func(c *cli.Context) error { - return run(cfg) - } - - return app.Run(os.Args) + return app, cfg } -func run(cfg *config.Config) error { +func run(cfg *config.Config) (*http.Server, log.Logger) { // setup logging logger := setupLogging(cfg) level.Info(logger).Log( @@ -141,16 +166,28 @@ func run(cfg *config.Config) error { ) openVPServers = append(openVPServers, collector.OpenVPNServer{Name: serverName, StatusFile: statusFile, ParseError: 0}) } + + var parserDecorators []openvpn.ParserDecorator + if cfg.StatusCollector.PseudonymizeClientMetrics { + parserDecorators = append( + parserDecorators, + openvpn.NewOpenVPNPseudonymizingDecorator( + cfg.StatusCollector.PseudonymizeClientMetricsLength, + ), + ) + } r.MustRegister(collector.NewOpenVPNCollector( logger, openVPServers, + parserDecorators, cfg.StatusCollector.ExportClientMetrics, )) - http.Handle(cfg.Server.Path, + mux := http.NewServeMux() + mux.Handle(cfg.Server.Path, promhttp.HandlerFor(r, promhttp.HandlerOpts{}), ) - http.HandleFunc(cfg.Server.Root, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(cfg.Server.Root, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(` OpenVPN Exporter @@ -161,11 +198,8 @@ func run(cfg *config.Config) error { }) level.Info(logger).Log("msg", "Listening on", "addr", cfg.Server.Addr) - if err := http.ListenAndServe(cfg.Server.Addr, nil); err != nil { - level.Error(logger).Log("msg", "http listenandserve error", "err", err) - return err - } - return nil + server := &http.Server{Addr: cfg.Server.Addr, Handler: mux} + return server, logger } func parseStatusFileSlice(statusFile string) (string, string) { diff --git a/pkg/command/command_test.go b/pkg/command/command_test.go new file mode 100644 index 0000000..71f8f4f --- /dev/null +++ b/pkg/command/command_test.go @@ -0,0 +1,327 @@ +package command + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +// constants + +// the regexes below describe the expected /metrics page +// things that are not deterministic/easily predictable like start time or random pseudonym are approximated via patterns + +const expectedMetricsRegexV1 = `# HELP openvpn_build_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_build_info gauge +openvpn_build_info\{date="",go="go\d+\.\d+\.\d+",revision="",version="\d+\.\d+\.\d+"\} 1 +# HELP openvpn_bytes_received Amount of data received via the connection +# TYPE openvpn_bytes_received gauge +openvpn_bytes_received\{common_name="user1",server="test"\} 7\.883858e\+06 +openvpn_bytes_received\{common_name="user2",server="test"\} 1\.6732e\+06 +openvpn_bytes_received\{common_name="user3@test\.de",server="test"\} 1\.9602844e\+07 +openvpn_bytes_received\{common_name="user4",server="test"\} 582207 +# HELP openvpn_bytes_sent Amount of data sent via the connection +# TYPE openvpn_bytes_sent gauge +openvpn_bytes_sent\{common_name="user1",server="test"\} 7\.76234e\+06 +openvpn_bytes_sent\{common_name="user2",server="test"\} 2\.065632e\+06 +openvpn_bytes_sent\{common_name="user3@test\.de",server="test"\} 2\.3599532e\+07 +openvpn_bytes_sent\{common_name="user4",server="test"\} 575193 +# HELP openvpn_connected_since Unixtimestamp when the connection was established +# TYPE openvpn_connected_since gauge +openvpn_connected_since\{common_name="user1",server="test"\} 1\.587559002e\+09 +openvpn_connected_since\{common_name="user2",server="test"\} 1\.587559012e\+09 +openvpn_connected_since\{common_name="user3@test\.de",server="test"\} 1\.587559365e\+09 +openvpn_connected_since\{common_name="user4",server="test"\} 1\.587559014e\+09 +# HELP openvpn_connections Amount of currently connected clients +# TYPE openvpn_connections gauge +openvpn_connections\{server="test"\} 4 +# HELP openvpn_last_updated Unix timestamp when the last time the status was updated +# TYPE openvpn_last_updated gauge +openvpn_last_updated\{server="test"\} 1\.587672871e\+09 +# HELP openvpn_max_bcast_mcast_queue_len MaxBcastMcastQueueLen of the server +# TYPE openvpn_max_bcast_mcast_queue_len gauge +openvpn_max_bcast_mcast_queue_len\{server="test"\} 5 +# HELP openvpn_server_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_server_info gauge +openvpn_server_info\{arch="unknown",server="test",version="unknown"\} 1 +# HELP openvpn_start_time Unix timestamp of the start time of the exporter +# TYPE openvpn_start_time gauge +openvpn_start_time \d+\.\d+e\+\d+ +` + +const expectedMetricsRegexV2 = `# HELP openvpn_build_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_build_info gauge +openvpn_build_info\{date="",go="go\d+\.\d+\.\d+",revision="",version="\d+\.\d+\.\d+"\} 1 +# HELP openvpn_bytes_received Amount of data received via the connection +# TYPE openvpn_bytes_received gauge +openvpn_bytes_received\{common_name="test1@localhost",server="test"\} 3871 +openvpn_bytes_received\{common_name="test@localhost",server="test"\} 3860 +# HELP openvpn_bytes_sent Amount of data sent via the connection +# TYPE openvpn_bytes_sent gauge +openvpn_bytes_sent\{common_name="test1@localhost",server="test"\} 3924 +openvpn_bytes_sent\{common_name="test@localhost",server="test"\} 3688 +# HELP openvpn_connected_since Unixtimestamp when the connection was established +# TYPE openvpn_connected_since gauge +openvpn_connected_since\{common_name="test1@localhost",server="test"\} 1\.58825494e\+09 +openvpn_connected_since\{common_name="test@localhost",server="test"\} 1\.588254938e\+09 +# HELP openvpn_connections Amount of currently connected clients +# TYPE openvpn_connections gauge +openvpn_connections\{server="test"\} 2 +# HELP openvpn_last_updated Unix timestamp when the last time the status was updated +# TYPE openvpn_last_updated gauge +openvpn_last_updated\{server="test"\} 1\.588254944e\+09 +# HELP openvpn_max_bcast_mcast_queue_len MaxBcastMcastQueueLen of the server +# TYPE openvpn_max_bcast_mcast_queue_len gauge +openvpn_max_bcast_mcast_queue_len\{server="test"\} 0 +# HELP openvpn_server_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_server_info gauge +openvpn_server_info\{arch="x86_64-pc-linux-gnu",server="test",version="2\.4\.4"\} 1 +# HELP openvpn_start_time Unix timestamp of the start time of the exporter +# TYPE openvpn_start_time gauge +openvpn_start_time \d+\.\d+e\+\d+ +` + +const expectedMetricsRegexV3 = expectedMetricsRegexV2 // the expected output is currently equal in v2 and v3 + +const expectedPseudonymizedMetricsRegexV1 = `# HELP openvpn_build_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_build_info gauge +openvpn_build_info\{date="",go="go\d+\.\d+\.\d+",revision="",version="\d+\.\d+\.\d+"\} 1 +# HELP openvpn_bytes_received Amount of data received via the connection +# TYPE openvpn_bytes_received gauge +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +# HELP openvpn_bytes_sent Amount of data sent via the connection +# TYPE openvpn_bytes_sent gauge +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +# HELP openvpn_connected_since Unixtimestamp when the connection was established +# TYPE openvpn_connected_since gauge +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} \d+(\.\d+e\+\d+){0,1} +# HELP openvpn_connections Amount of currently connected clients +# TYPE openvpn_connections gauge +openvpn_connections\{server="test"\} 4 +# HELP openvpn_last_updated Unix timestamp when the last time the status was updated +# TYPE openvpn_last_updated gauge +openvpn_last_updated\{server="test"\} 1\.587672871e\+09 +# HELP openvpn_max_bcast_mcast_queue_len MaxBcastMcastQueueLen of the server +# TYPE openvpn_max_bcast_mcast_queue_len gauge +openvpn_max_bcast_mcast_queue_len\{server="test"\} 5 +# HELP openvpn_server_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_server_info gauge +openvpn_server_info\{arch="unknown",server="test",version="unknown"\} 1 +# HELP openvpn_start_time Unix timestamp of the start time of the exporter +# TYPE openvpn_start_time gauge +openvpn_start_time \d+\.\d+e\+\d+ +` + +const expectedPseudonymizedMetricsRegexV2 = `# HELP openvpn_build_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_build_info gauge +openvpn_build_info\{date="",go="go\d+\.\d+\.\d+",revision="",version="\d+\.\d+\.\d+"\} 1 +# HELP openvpn_bytes_received Amount of data received via the connection +# TYPE openvpn_bytes_received gauge +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+ +openvpn_bytes_received\{common_name="([A-Za-z]+)",server="test"\} \d+ +# HELP openvpn_bytes_sent Amount of data sent via the connection +# TYPE openvpn_bytes_sent gauge +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} (\d+) +openvpn_bytes_sent\{common_name="([A-Za-z]+)",server="test"\} (\d+) +# HELP openvpn_connected_since Unixtimestamp when the connection was established +# TYPE openvpn_connected_since gauge +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} 1\.\d+e\+09 +openvpn_connected_since\{common_name="([A-Za-z]+)",server="test"\} 1\.\d+e\+09 +# HELP openvpn_connections Amount of currently connected clients +# TYPE openvpn_connections gauge +openvpn_connections\{server="test"\} 2 +# HELP openvpn_last_updated Unix timestamp when the last time the status was updated +# TYPE openvpn_last_updated gauge +openvpn_last_updated\{server="test"\} 1\.588254944e\+09 +# HELP openvpn_max_bcast_mcast_queue_len MaxBcastMcastQueueLen of the server +# TYPE openvpn_max_bcast_mcast_queue_len gauge +openvpn_max_bcast_mcast_queue_len\{server="test"\} 0 +# HELP openvpn_server_info A metric with a constant '1' value labeled by version information +# TYPE openvpn_server_info gauge +openvpn_server_info\{arch="x86_64-pc-linux-gnu",server="test",version="2\.4\.4"\} 1 +# HELP openvpn_start_time Unix timestamp of the start time of the exporter +# TYPE openvpn_start_time gauge +openvpn_start_time \d+\.\d+e\+\d+ +` + +const expectedPseudonymizedMetricsRegexV3 = expectedPseudonymizedMetricsRegexV2 // the expected output is currently equal in v2 and v3 + +// helper functions +func version1StatusFile() string { + statusFileDir := filepath.Join(getCurrentPath(), "..", "..", "example") + statusFile := filepath.Join(statusFileDir, "version1.status") + return statusFile +} + +func version2StatusFile() string { + statusFileDir := filepath.Join(getCurrentPath(), "..", "..", "example") + statusFile := filepath.Join(statusFileDir, "version2.status") + return statusFile +} + +func version3StatusFile() string { + statusFileDir := filepath.Join(getCurrentPath(), "..", "..", "example") + statusFile := filepath.Join(statusFileDir, "version3.status") + return statusFile +} + +func getFreeTCPPort() int { + listener, err := net.Listen("tcp", ":0") + if err != nil { + panic(err) + } + + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + return port +} + +func waitUntilReachable(addr string, retry int) error { + for i := 0; i < retry; i++ { + conn, _ := net.DialTimeout("tcp", addr, time.Second) + if conn != nil { + conn.Close() + return nil + } + } + return fmt.Errorf("connection to %s could not be established", addr) +} + +func getCurrentPath() string { + _, filename, _, _ := runtime.Caller(1) + return path.Dir(filename) +} + +func runServer(server *http.Server) { + _ = server.ListenAndServe() +} + +func startCli(t *testing.T) *http.Server { + app, cfg := initApp() + var server *http.Server + app.Action = func(c *cli.Context) error { + server, _ = run(cfg) + go runServer(server) + return nil + } + + err := app.Run(os.Args) + if err != nil { + t.Fatal(err) + } + return server +} + +func startWithArgs(args []string, t *testing.T) (string, error) { + // normalize time zone + err := os.Setenv("TZ", "UTC") + if err != nil { + t.Fatal(err) + } + + // restore os args after each test to avoid potential issues with the test runner + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + + listenAddress := fmt.Sprintf("127.0.0.1:%d", getFreeTCPPort()) + + os.Args = append([]string{"openvpn_exporter", "--web.address", listenAddress}, args...) + + server := startCli(t) + t.Cleanup(func() { server.Close() }) + + err = waitUntilReachable(listenAddress, 5) + if err != nil { + t.Fatal(err) + } + return listenAddress, err +} + +func fetchMetrics(t *testing.T, listenAddress string) string { + client := http.Client{ + Timeout: time.Second, + } + resp, err := client.Get(fmt.Sprintf("http://%s/metrics", listenAddress)) + if err != nil { + t.Fatal(err) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func checkMetrics(t *testing.T, statusFile string, extraArgs []string) string { + args := append([]string{"--status-file", fmt.Sprintf("test:%s", statusFile)}, extraArgs...) + listenAddress, err := startWithArgs(args, t) + if err != nil { + t.Fatal(err) + } + metrics := fetchMetrics(t, listenAddress) + return metrics +} + +func checkMetricsRegex(t *testing.T, statusFile string, expectedMetricsRegex string, extraArgs []string) { + metrics := checkMetrics(t, statusFile, extraArgs) + + metricsLines := strings.Split(metrics, "\n") + expectedMetricsRegexLines := strings.Split(expectedMetricsRegex, "\n") + + if len(metricsLines) != len(expectedMetricsRegexLines) { + t.Fatalf("expectedMetricsRegex must have as many lines as the resulting metrics page: %d - %d", len(metricsLines), len(expectedMetricsRegexLines)) + } + + // check regex line for line to get readable error output + for idx, regex := range expectedMetricsRegexLines { + regex = fmt.Sprintf("^%s$", regex) + assert.Regexp(t, regex, metricsLines[idx]) + } + +} + +// test functions + +func TestRunWithV1Status(t *testing.T) { + checkMetricsRegex(t, version1StatusFile(), expectedMetricsRegexV1, []string{}) +} + +func TestRunWithV2Status(t *testing.T) { + checkMetricsRegex(t, version2StatusFile(), expectedMetricsRegexV2, []string{}) +} + +func TestRunWithV3Status(t *testing.T) { + checkMetricsRegex(t, version3StatusFile(), expectedMetricsRegexV3, []string{}) +} + +func TestRunWithV1StatusAndPseudonymization(t *testing.T) { + checkMetricsRegex(t, version1StatusFile(), expectedPseudonymizedMetricsRegexV1, []string{"--pseudonymize-client-metrics"}) +} + +func TestRunWithV2StatusAndPseudonymization(t *testing.T) { + checkMetricsRegex(t, version2StatusFile(), expectedPseudonymizedMetricsRegexV2, []string{"--pseudonymize-client-metrics"}) +} + +func TestRunWithV3StatusAndPseudonymization(t *testing.T) { + checkMetricsRegex(t, version3StatusFile(), expectedPseudonymizedMetricsRegexV3, []string{"--pseudonymize-client-metrics"}) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index dfdb62d..f292e2f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,8 +22,10 @@ type Config struct { // StatusCollector contains configuration for the OpenVPN status collector type StatusCollector struct { - ExportClientMetrics bool - StatusFile []string + ExportClientMetrics bool + PseudonymizeClientMetrics bool + PseudonymizeClientMetricsLength int + StatusFile []string } // Load initializes a default configuration struct. diff --git a/pkg/openvpn/parser_decorator.go b/pkg/openvpn/parser_decorator.go new file mode 100644 index 0000000..645b76e --- /dev/null +++ b/pkg/openvpn/parser_decorator.go @@ -0,0 +1,5 @@ +package openvpn + +type ParserDecorator interface { + DecorateParseFile(f func(statusfile string) (*Status, error)) func(statusfile string) (*Status, error) +} diff --git a/pkg/openvpn/pseudonymizer.go b/pkg/openvpn/pseudonymizer.go new file mode 100644 index 0000000..941e352 --- /dev/null +++ b/pkg/openvpn/pseudonymizer.go @@ -0,0 +1,91 @@ +package openvpn + +import ( + "math/rand" + "strings" + "time" +) + +type PseudonymizingDecorator struct { + pseudonymizeClientMetricsLength int + pseudonymizeClientMetricsMap map[string]string + reversePseudonymizeClientMetricsMap map[string]string +} + +func NewOpenVPNPseudonymizingDecorator( + pseudonymizeClientMetricsLength int, +) PseudonymizingDecorator { + return PseudonymizingDecorator{ + pseudonymizeClientMetricsLength: pseudonymizeClientMetricsLength, + pseudonymizeClientMetricsMap: make(map[string]string), + reversePseudonymizeClientMetricsMap: make(map[string]string), + } +} + +func reverseMap(m map[string]string) map[string]string { + n := make(map[string]string, len(m)) + for k, v := range m { + n[v] = k + } + return n +} + +// see https://stackoverflow.com/a/22892986/18529703 +func (t PseudonymizingDecorator) generatePseudonym(n int) string { + src := rand.NewSource(time.Now().UnixNano()) + const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + const ( + letterIdxBits = 6 + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = src.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(letterBytes) { + sb.WriteByte(letterBytes[idx]) + i-- + } + cache >>= letterIdxBits + remain-- + } + + return sb.String() +} + +func (t PseudonymizingDecorator) DecorateParseFile(f func(statusfile string) (*Status, error)) func(statusfile string) (*Status, error) { + return func(statusfile string) (*Status, error) { + status, err := f(statusfile) + if err != nil { + return nil, err + } + + // use index for iteration to work on the actual reference instead of a value + for idx := range status.ClientList { + client := &status.ClientList[idx] + commonName := client.CommonName + pseudonym, ok := t.pseudonymizeClientMetricsMap[commonName] + if !ok { + for { + pseudonym = t.generatePseudonym(t.pseudonymizeClientMetricsLength) + if _, ok := t.reversePseudonymizeClientMetricsMap[pseudonym]; !ok { + break + } + } + } + + t.pseudonymizeClientMetricsMap[commonName] = pseudonym + // update the reverse map for quick lookup of existing pseudonyms + t.reversePseudonymizeClientMetricsMap = reverseMap(t.pseudonymizeClientMetricsMap) + + client.CommonName = pseudonym + } + + return status, nil + } +} diff --git a/pkg/openvpn/pseudonymizer_test.go b/pkg/openvpn/pseudonymizer_test.go new file mode 100644 index 0000000..39a687e --- /dev/null +++ b/pkg/openvpn/pseudonymizer_test.go @@ -0,0 +1,94 @@ +package openvpn + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const someCommonName = "real_name" +const someOtherCommonName = "other_real_name" +const someStatusFilePath = "/does/not/exist" + +func generateSomeStatus(commonName string) Status { + return Status{ + ClientList: []Client{{ + CommonName: commonName, + RealAddress: "", + BytesReceived: 0, + BytesSent: 0, + ConnectedSince: time.Time{}, + }}, + GlobalStats: GlobalStats{}, + ServerInfo: ServerInfo{}, + UpdatedAt: time.Time{}, + } +} + +func getPseudonymizer(pseudonymLength int) PseudonymizingDecorator { + return NewOpenVPNPseudonymizingDecorator(pseudonymLength) +} + +func getParseFileFunc(status *Status, err error) func(statusfile string) (*Status, error) { + return func(statusfile string) (*Status, error) { + return status, err + } +} + +func getCommonName(status *Status) string { + return status.ClientList[0].CommonName +} + +func pseudonymizeStatus(pseudonymizer PseudonymizingDecorator, status Status) (*Status, error) { + parseFileFuncWithSuccess := getParseFileFunc(&status, nil) + pseudonymizingParseFileFunc := pseudonymizer.DecorateParseFile(parseFileFuncWithSuccess) + pseudonymizedStatus, err := pseudonymizingParseFileFunc(someStatusFilePath) + return pseudonymizedStatus, err +} + +func TestCommonNamePseudonymized(t *testing.T) { + pseudonymizer := getPseudonymizer(5) + pseudonymizedStatus, err := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someCommonName)) + assert.Nil(t, err) + assert.NotEqual(t, someCommonName, getCommonName(pseudonymizedStatus)) +} + +func TestCommonNamePseudonymizedWithCorrectLength(t *testing.T) { + pseudonymLength := 10 + pseudonymizer := getPseudonymizer(pseudonymLength) + pseudonymizedStatus, _ := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someCommonName)) + assert.Len(t, getCommonName(pseudonymizedStatus), pseudonymLength) +} + +func TestSamePseudonymUsedForSameCommonName(t *testing.T) { + pseudonymizer := getPseudonymizer(5) + pseudonymizedStatus1, _ := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someCommonName)) + pseudonymizedStatus2, _ := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someCommonName)) + assert.Equal(t, getCommonName(pseudonymizedStatus1), getCommonName(pseudonymizedStatus2)) +} + +func TestDifferentPseudonymUsedForDifferentCommonName(t *testing.T) { + pseudonymizer := getPseudonymizer(5) + pseudonymizedStatus1, _ := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someCommonName)) + pseudonymizedStatus2, _ := pseudonymizeStatus(pseudonymizer, generateSomeStatus(someOtherCommonName)) + assert.NotEqual(t, getCommonName(pseudonymizedStatus1), getCommonName(pseudonymizedStatus2)) +} + +func TestPseudonymsNotPersistentAcrossInstances(t *testing.T) { + pseudonymizer1 := getPseudonymizer(5) + pseudonymizer2 := getPseudonymizer(5) + pseudonymizedStatus1, _ := pseudonymizeStatus(pseudonymizer1, generateSomeStatus(someCommonName)) + pseudonymizedStatus2, _ := pseudonymizeStatus(pseudonymizer2, generateSomeStatus(someCommonName)) + assert.NotEqual(t, getCommonName(pseudonymizedStatus1), getCommonName(pseudonymizedStatus2)) +} + +func TestErrorIsPassedDown(t *testing.T) { + testError := errors.New("test error") + pseudonymizer := getPseudonymizer(5) + parseFileFuncWithFailure := getParseFileFunc(nil, testError) + pseudonymizingParseFileFunc := pseudonymizer.DecorateParseFile(parseFileFuncWithFailure) + _, err := pseudonymizingParseFileFunc(someStatusFilePath) + assert.Equal(t, err, testError) +}