// Copyright 2017 Kumina, https://kumina.nl/ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "bufio" "bytes" "flag" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" "github.com/prometheus/client_golang/prometheus" ) type OpenvpnServerHeader struct { LabelColumns []string Metrics []OpenvpnServerHeaderField } type OpenvpnServerHeaderField struct { Column string Desc *prometheus.Desc ValueType prometheus.ValueType } var ( // Metrics exported both for client and server statistics. openvpnUpDesc = prometheus.NewDesc( prometheus.BuildFQName("openvpn", "", "up"), "Whether scraping OpenVPN's metrics was successful.", []string{"status_path"}, nil) openvpnStatusUpdateTimeDesc = prometheus.NewDesc( prometheus.BuildFQName("openvpn", "", "status_update_time_seconds"), "UNIX timestamp at which the OpenVPN statistics were updated.", []string{"status_path"}, nil) // Metrics specific to OpenVPN servers. openvpnServerHeaders = map[string]OpenvpnServerHeader{ "CLIENT_LIST": OpenvpnServerHeader{ LabelColumns: []string{ "Common Name", "Connected Since (time_t)", "Real Address", "Virtual Address", "Username", }, Metrics: []OpenvpnServerHeaderField{ OpenvpnServerHeaderField{ Column: "Bytes Received", Desc: prometheus.NewDesc( prometheus.BuildFQName("openvpn", "server", "client_received_bytes_total"), "Amount of data received over a connection on the VPN server, in bytes.", []string{"status_path", "common_name", "connection_time", "real_address", "virtual_address", "username"}, nil), ValueType: prometheus.CounterValue, }, OpenvpnServerHeaderField{ Column: "Bytes Sent", Desc: prometheus.NewDesc( prometheus.BuildFQName("openvpn", "server", "client_sent_bytes_total"), "Amount of data sent over a connection on the VPN server, in bytes.", []string{"status_path", "common_name", "connection_time", "real_address", "virtual_address", "username"}, nil), ValueType: prometheus.CounterValue, }, }, }, "ROUTING_TABLE": OpenvpnServerHeader{ LabelColumns: []string{ "Common Name", "Real Address", "Virtual Address", }, Metrics: []OpenvpnServerHeaderField{ OpenvpnServerHeaderField{ Column: "Last Ref (time_t)", Desc: prometheus.NewDesc( prometheus.BuildFQName("openvpn", "server", "route_last_reference_time_seconds"), "Time at which a route was last referenced, in seconds.", []string{"status_path", "common_name", "real_address", "virtual_address"}, nil), ValueType: prometheus.GaugeValue, }, }, }, } // Metrics specific to OpenVPN clients. openvpnClientDescs = map[string]*prometheus.Desc{ "TUN/TAP read bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "tun_tap_read_bytes_total"), "Total amount of TUN/TAP traffic read, in bytes.", []string{"status_path"}, nil), "TUN/TAP write bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "tun_tap_write_bytes_total"), "Total amount of TUN/TAP traffic written, in bytes.", []string{"status_path"}, nil), "TCP/UDP read bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "tcp_udp_read_bytes_total"), "Total amount of TCP/UDP traffic read, in bytes.", []string{"status_path"}, nil), "TCP/UDP write bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "tcp_udp_write_bytes_total"), "Total amount of TCP/UDP traffic written, in bytes.", []string{"status_path"}, nil), "Auth read bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "auth_read_bytes_total"), "Total amount of authentication traffic read, in bytes.", []string{"status_path"}, nil), "pre-compress bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "pre_compress_bytes_total"), "Total amount of data before compression, in bytes.", []string{"status_path"}, nil), "post-compress bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "post_compress_bytes_total"), "Total amount of data after compression, in bytes.", []string{"status_path"}, nil), "pre-decompress bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "pre_decompress_bytes_total"), "Total amount of data before decompression, in bytes.", []string{"status_path"}, nil), "post-decompress bytes": prometheus.NewDesc( prometheus.BuildFQName("openvpn", "client", "post_decompress_bytes_total"), "Total amount of data after decompression, in bytes.", []string{"status_path"}, nil), } ) // Converts OpenVPN status information into Prometheus metrics. This // function automatically detects whether the file contains server or // client metrics. For server metrics, it also distinguishes between the // version 2 and 3 file formats. func CollectStatusFromReader(statusPath string, file io.Reader, ch chan<- prometheus.Metric) error { reader := bufio.NewReader(file) buf, _ := reader.Peek(18) if bytes.HasPrefix(buf, []byte("TITLE,")) { // Server statistics, using format version 2. return CollectServerStatusFromReader(statusPath, reader, ch, ",") } else if bytes.HasPrefix(buf, []byte("TITLE\t")) { // Server statistics, using format version 3. The only // difference compared to version 2 is that it uses tabs // instead of spaces. return CollectServerStatusFromReader(statusPath, reader, ch, "\t") } else if bytes.HasPrefix(buf, []byte("OpenVPN STATISTICS")) { // Client statistics. return CollectClientStatusFromReader(statusPath, reader, ch) } else { return fmt.Errorf("Unexpected file contents: %q", buf) } } // Converts OpenVPN server status information into Prometheus metrics. func CollectServerStatusFromReader(statusPath string, file io.Reader, ch chan<- prometheus.Metric, separator string) error { scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanLines) headersFound := map[string][]string{} for scanner.Scan() { fields := strings.Split(scanner.Text(), separator) if fields[0] == "END" && len(fields) == 1 { // Stats footer. } else if fields[0] == "GLOBAL_STATS" { // Global server statistics. } else if fields[0] == "HEADER" && len(fields) > 2 { // Column names for CLIENT_LIST and ROUTING_TABLE. headersFound[fields[1]] = fields[2:] } else if fields[0] == "TIME" && len(fields) == 3 { // Time at which the statistics were updated. time, err := strconv.ParseFloat(fields[2], 64) if err != nil { return err } ch <- prometheus.MustNewConstMetric( openvpnStatusUpdateTimeDesc, prometheus.GaugeValue, time, statusPath) } else if fields[0] == "TITLE" && len(fields) == 2 { // OpenVPN version number. } else if header, ok := openvpnServerHeaders[fields[0]]; ok { // Entry that depends on a preceding HEADERS directive. columnNames, ok := headersFound[fields[0]] if !ok { return fmt.Errorf("%s should be preceded by HEADERS", fields[0]) } if len(fields) != len(columnNames)+1 { return fmt.Errorf("HEADER for %s describes a different number of columns", fields[0]) } // Store entry values in a map indexed by column name. columnValues := map[string]string{} for _, column := range header.LabelColumns { columnValues[column] = "" } for i, column := range columnNames { columnValues[column] = fields[i+1] } // Extract columns that should act as entry labels. labels := []string{statusPath} for _, column := range header.LabelColumns { labels = append(labels, columnValues[column]) } // Export relevant columns as individual metrics. for _, metric := range header.Metrics { if columnValue, ok := columnValues[metric.Column]; ok { value, err := strconv.ParseFloat(columnValue, 64) if err != nil { return err } ch <- prometheus.MustNewConstMetric( metric.Desc, metric.ValueType, value, labels...) } } } else { return fmt.Errorf("Unsupported key: %q", fields[0]) } } return scanner.Err() } // Converts OpenVPN client status information into Prometheus metrics. func CollectClientStatusFromReader(statusPath string, file io.Reader, ch chan<- prometheus.Metric) error { scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanLines) for scanner.Scan() { fields := strings.Split(scanner.Text(), ",") if fields[0] == "END" && len(fields) == 1 { // Stats footer. } else if fields[0] == "OpenVPN STATISTICS" && len(fields) == 1 { // Stats header. } else if fields[0] == "Updated" && len(fields) == 2 { // Time at which the statistics were updated. location, _ := time.LoadLocation("Local") time, err := time.ParseInLocation("Mon Jan 2 15:04:05 2006", fields[1], location) if err != nil { return err } ch <- prometheus.MustNewConstMetric( openvpnStatusUpdateTimeDesc, prometheus.GaugeValue, float64(time.Unix()), statusPath) } else if desc, ok := openvpnClientDescs[fields[0]]; ok && len(fields) == 2 { // Traffic counters. value, err := strconv.ParseFloat(fields[1], 64) if err != nil { return err } ch <- prometheus.MustNewConstMetric( desc, prometheus.CounterValue, value, statusPath) } else { return fmt.Errorf("Unsupported key: %q", fields[0]) } } return scanner.Err() } func CollectStatusFromFile(statusPath string, ch chan<- prometheus.Metric) error { conn, err := os.Open(statusPath) if err != nil { return err } return CollectStatusFromReader(statusPath, conn, ch) } type OpenVPNExporter struct { statusPaths []string } func NewOpenVPNExporter(statusPaths []string) (*OpenVPNExporter, error) { return &OpenVPNExporter{ statusPaths: statusPaths, }, nil } func (e *OpenVPNExporter) Describe(ch chan<- *prometheus.Desc) { ch <- openvpnUpDesc } func (e *OpenVPNExporter) Collect(ch chan<- prometheus.Metric) { for _, statusPath := range e.statusPaths { err := CollectStatusFromFile(statusPath, ch) if err == nil { ch <- prometheus.MustNewConstMetric( openvpnUpDesc, prometheus.GaugeValue, 1.0, statusPath) } else { log.Printf("Failed to scrape showq socket: %s", err) ch <- prometheus.MustNewConstMetric( openvpnUpDesc, prometheus.GaugeValue, 0.0, statusPath) } } } func main() { var ( listenAddress = flag.String("web.listen-address", ":9176", "Address to listen on for web interface and telemetry.") metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose metrics.") openvpnStatusPaths = flag.String("openvpn.status_paths", "examples/client.status,examples/server2.status,examples/server3.status", "Paths at which OpenVPN places its status files.") ) flag.Parse() exporter, err := NewOpenVPNExporter(strings.Split(*openvpnStatusPaths, ",")) if err != nil { panic(err) } prometheus.MustRegister(exporter) http.Handle(*metricsPath, prometheus.Handler()) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`