package main // Copyright 2016 Nils Decker // // 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. import ( "bytes" "encoding/json" "fmt" "io/ioutil" "log" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/namsral/flag" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" lua "github.com/sberk42/fritzbox_exporter/fritzbox_lua" upnp "github.com/sberk42/fritzbox_exporter/fritzbox_upnp" ) const serviceLoadRetryTime = 1 * time.Minute // minimum TTL for cached results in seconds const minCacheTTL = 30 var ( flagTest = flag.Bool("test", false, "print all available metrics to stdout") flagLuaTest = flag.Bool("testLua", false, "read luaTest.json file make all contained calls and dump results") flagCollect = flag.Bool("collect", false, "print configured metrics to stdout and exit") flagJSONOut = flag.String("json-out", "", "store metrics also to JSON file when running test") flagAddr = flag.String("listen-address", "127.0.0.1:9042", "The address to listen on for HTTP requests.") flagMetricsFile = flag.String("metrics-file", "metrics.json", "The JSON file with the metric definitions.") flagDisableLua = flag.Bool("nolua", false, "disable collecting lua metrics") flagLuaMetricsFile = flag.String("lua-metrics-file", "metrics-lua.json", "The JSON file with the lua metric definitions.") flagGatewayURL = flag.String("gateway-url", "http://fritz.box:49000", "The URL of the FRITZ!Box") flagGatewayLuaURL = flag.String("gateway-luaurl", "http://fritz.box", "The URL of the FRITZ!Box UI") flagUsername = flag.String("username", "", "The user for the FRITZ!Box UPnP service") flagPassword = flag.String("password", "", "The password for the FRITZ!Box UPnP service") ) var ( collectErrors = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_collectErrors", Help: "Number of collection errors.", }) ) var ( luaCollectErrors = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_luaCollectErrors", Help: "Number of lua collection errors.", }) ) var collectLuaResultsCached = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_results_cached", Help: "Number of results taken from cache.", ConstLabels: prometheus.Labels{"Cache": "LUA"}, }) var collectUpnpResultsCached = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_results_cached", Help: "Number of results taken from cache.", ConstLabels: prometheus.Labels{"Cache": "UPNP"}, }) var collectLuaResultsLoaded = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_results_loaded", Help: "Number of results loaded from fritzbox.", ConstLabels: prometheus.Labels{"Cache": "LUA"}, }) var collectUpnpResultsLoaded = prometheus.NewCounter(prometheus.CounterOpts{ Name: "fritzbox_exporter_results_loaded", Help: "Number of results loaded from fritzbox.", ConstLabels: prometheus.Labels{"Cache": "UPNP"}, }) // JSONPromDesc metric description loaded from JSON type JSONPromDesc struct { FqName string `json:"fqName"` Help string `json:"help"` VarLabels []string `json:"varLabels"` FixedLabels map[string]string `json:"fixedLabels"` fixedLabelValues string // neeeded to create uniq lookup key when reporting } // ActionArg argument for upnp action type ActionArg struct { Name string `json:"Name"` IsIndex bool `json:"IsIndex"` ProviderAction string `json:"ProviderAction"` Value string `json:"Value"` } // Metric upnp metric type Metric struct { // initialized loading JSON Service string `json:"service"` Action string `json:"action"` ActionArgument *ActionArg `json:"actionArgument"` Result string `json:"result"` OkValue string `json:"okValue"` PromDesc JSONPromDesc `json:"promDesc"` PromType string `json:"promType"` CacheEntryTTL int64 `json:"cacheEntryTTL"` // initialized at startup Desc *prometheus.Desc MetricType prometheus.ValueType } // LuaTest JSON struct for API tests type LuaTest struct { Path string `json:"path"` Params string `json:"params"` } // LuaLabelRename struct type LuaLabelRename struct { MatchRegex string `json:"matchRegex"` RenameLabel string `json:"renameLabel"` } // LuaMetric struct type LuaMetric struct { // initialized loading JSON Path string `json:"path"` Params string `json:"params"` ResultPath string `json:"resultPath"` ResultKey string `json:"resultKey"` OkValue string `json:"okValue"` PromDesc JSONPromDesc `json:"promDesc"` PromType string `json:"promType"` CacheEntryTTL int64 `json:"cacheEntryTTL"` // initialized at startup Desc *prometheus.Desc MetricType prometheus.ValueType LuaPage lua.LuaPage LuaMetricDef lua.LuaMetricValueDefinition } // LuaMetricsFile json struct type LuaMetricsFile struct { LabelRenames []LuaLabelRename `json:"labelRenames"` Metrics []*LuaMetric `json:"metrics"` } type upnpCacheEntry struct { Timestamp int64 Result *upnp.Result } type luaCacheEntry struct { Timestamp int64 Result *map[string]interface{} } var metrics []*Metric var luaMetrics []*LuaMetric var upnpCache map[string]*upnpCacheEntry var luaCache map[string]*luaCacheEntry // FritzboxCollector main struct type FritzboxCollector struct { URL string Gateway string Username string Password string // support for lua collector LuaSession *lua.LuaSession LabelRenames *[]lua.LabelRename sync.Mutex // protects Root Root *upnp.Root } // simple ResponseWriter to collect output type testResponseWriter struct { header http.Header statusCode int body bytes.Buffer } func (w *testResponseWriter) Header() http.Header { return w.header } func (w *testResponseWriter) Write(b []byte) (int, error) { return w.body.Write(b) } func (w *testResponseWriter) WriteHeader(statusCode int) { w.statusCode = statusCode } func (w *testResponseWriter) String() string { return w.body.String() } // LoadServices tries to load the service information. Retries until success. func (fc *FritzboxCollector) LoadServices() { for { root, err := upnp.LoadServices(fc.URL, fc.Username, fc.Password) if err != nil { fmt.Printf("cannot load services: %s\n", err) time.Sleep(serviceLoadRetryTime) continue } fmt.Printf("services loaded\n") fc.Lock() fc.Root = root fc.Unlock() return } } // Describe describe metric func (fc *FritzboxCollector) Describe(ch chan<- *prometheus.Desc) { for _, m := range metrics { ch <- m.Desc } } func (fc *FritzboxCollector) reportMetric(ch chan<- prometheus.Metric, m *Metric, result upnp.Result, dupCache map[string]bool) { val, ok := result[m.Result] if !ok { fmt.Printf("%s.%s has no result %s", m.Service, m.Action, m.Result) collectErrors.Inc() return } var floatval float64 switch tval := val.(type) { case uint64: floatval = float64(tval) case bool: if tval { floatval = 1 } else { floatval = 0 } case string: if tval == m.OkValue { floatval = 1 } else { floatval = 0 } default: fmt.Println("unknown type", val) collectErrors.Inc() return } labels := make([]string, len(m.PromDesc.VarLabels)) for i, l := range m.PromDesc.VarLabels { if l == "gateway" { labels[i] = fc.Gateway } else { lval, ok := result[l] if !ok { fmt.Printf("%s.%s has no resul for label %s", m.Service, m.Action, l) lval = "" } // convert hostname and MAC tolower to avoid problems with labels if l == "HostName" || l == "MACAddress" { labels[i] = strings.ToLower(fmt.Sprintf("%v", lval)) } else { labels[i] = fmt.Sprintf("%v", lval) } } } // check for duplicate labels to prevent collection failure key := m.PromDesc.FqName + ":" + m.PromDesc.fixedLabelValues + strings.Join(labels, ",") if dupCache[key] { fmt.Printf("%s.%s reported before as: %s\n", m.Service, m.Action, key) collectErrors.Inc() return } dupCache[key] = true metric, err := prometheus.NewConstMetric(m.Desc, m.MetricType, floatval, labels...) if err != nil { fmt.Printf("Error creating metric %s.%s: %s", m.Service, m.Action, err.Error()) } else { ch <- metric } } func (fc *FritzboxCollector) getActionResult(metric *Metric, actionName string, actionArg *upnp.ActionArgument) (upnp.Result, error) { key := metric.Service + "|" + actionName // for calls with argument also add arguement name and value to key if actionArg != nil { key += "|" + actionArg.Name + "|" + fmt.Sprintf("%v", actionArg.Value) } now := time.Now().Unix() cacheEntry := upnpCache[key] if cacheEntry == nil { cacheEntry = &upnpCacheEntry{} upnpCache[key] = cacheEntry } else if now-cacheEntry.Timestamp > metric.CacheEntryTTL { cacheEntry.Result = nil } if cacheEntry.Result == nil { service, ok := fc.Root.Services[metric.Service] if !ok { return nil, fmt.Errorf("service %s not found", metric.Service) } action, ok := service.Actions[actionName] if !ok { return nil, fmt.Errorf("action %s not found in service %s", actionName, metric.Service) } data, err := action.Call(actionArg) if err != nil { return nil, err } cacheEntry.Timestamp = now cacheEntry.Result = &data collectUpnpResultsCached.Inc() } else { collectUpnpResultsLoaded.Inc() } return *cacheEntry.Result, nil } // Collect collect upnp metrics func (fc *FritzboxCollector) Collect(ch chan<- prometheus.Metric) { fc.Lock() root := fc.Root fc.Unlock() if root == nil { // Services not loaded yet return } // create cache for duplicate lookup, to prevent collection errors var dupCache = make(map[string]bool) for _, m := range metrics { var actArg *upnp.ActionArgument if m.ActionArgument != nil { aa := m.ActionArgument var value interface{} value = aa.Value if aa.ProviderAction != "" { provRes, err := fc.getActionResult(m, aa.ProviderAction, nil) if err != nil { fmt.Printf("Error getting provider action %s result for %s.%s: %s\n", aa.ProviderAction, m.Service, m.Action, err.Error()) collectErrors.Inc() continue } var ok bool value, ok = provRes[aa.Value] // Value contains the result name for provider actions if !ok { fmt.Printf("provider action %s for %s.%s has no result", m.Service, m.Action, aa.Value) collectErrors.Inc() continue } } if aa.IsIndex { sval := fmt.Sprintf("%v", value) count, err := strconv.Atoi(sval) if err != nil { fmt.Println(err.Error()) collectErrors.Inc() continue } for i := 0; i < count; i++ { actArg = &upnp.ActionArgument{Name: aa.Name, Value: i} result, err := fc.getActionResult(m, m.Action, actArg) if err != nil { fmt.Println(err.Error()) collectErrors.Inc() continue } fc.reportMetric(ch, m, result, dupCache) } continue } else { actArg = &upnp.ActionArgument{Name: aa.Name, Value: value} } } result, err := fc.getActionResult(m, m.Action, actArg) if err != nil { fmt.Println(err.Error()) collectErrors.Inc() continue } fc.reportMetric(ch, m, result, dupCache) } // if lua is enabled now also collect metrics if fc.LuaSession != nil { fc.collectLua(ch, dupCache) } } func (fc *FritzboxCollector) collectLua(ch chan<- prometheus.Metric, dupCache map[string]bool) { // create a map for caching results now := time.Now().Unix() for _, lm := range luaMetrics { key := lm.Path + "_" + lm.Params cacheEntry := luaCache[key] if cacheEntry == nil { cacheEntry = &luaCacheEntry{} luaCache[key] = cacheEntry } else if now-cacheEntry.Timestamp > lm.CacheEntryTTL { cacheEntry.Result = nil } if cacheEntry.Result == nil { pageData, err := fc.LuaSession.LoadData(lm.LuaPage) if err != nil { fmt.Printf("Error loading %s for %s.%s: %s\n", lm.Path, lm.ResultPath, lm.ResultKey, err.Error()) luaCollectErrors.Inc() fc.LuaSession.SID = "" // clear SID in case of error, so force reauthentication continue } var data map[string]interface{} data, err = lua.ParseJSON(pageData) if err != nil { fmt.Printf("Error parsing JSON from %s for %s.%s: %s\n", lm.Path, lm.ResultPath, lm.ResultKey, err.Error()) luaCollectErrors.Inc() continue } cacheEntry.Result = &data cacheEntry.Timestamp = now collectLuaResultsLoaded.Inc() } else { collectLuaResultsCached.Inc() } metricVals, err := lua.GetMetrics(fc.LabelRenames, *cacheEntry.Result, lm.LuaMetricDef) if err != nil { fmt.Printf("Error getting metric values for %s.%s: %s\n", lm.ResultPath, lm.ResultKey, err.Error()) luaCollectErrors.Inc() cacheEntry.Result = nil // don't use invalid results for cache continue } for _, mv := range metricVals { fc.reportLuaMetric(ch, lm, mv, dupCache) } } } func (fc *FritzboxCollector) reportLuaMetric(ch chan<- prometheus.Metric, lm *LuaMetric, value lua.LuaMetricValue, dupCache map[string]bool) { labels := make([]string, len(lm.PromDesc.VarLabels)) for i, l := range lm.PromDesc.VarLabels { if l == "gateway" { labels[i] = fc.Gateway } else { lval, ok := value.Labels[l] if !ok { fmt.Printf("%s.%s from %s?%s has no resul for label %s", lm.ResultPath, lm.ResultKey, lm.Path, lm.Params, l) lval = "" } // convert hostname and MAC tolower to avoid problems with labels if l == "HostName" || l == "MACAddress" { labels[i] = strings.ToLower(fmt.Sprintf("%v", lval)) } else { labels[i] = fmt.Sprintf("%v", lval) } } } // check for duplicate labels to prevent collection failure key := lm.PromDesc.FqName + ":" + lm.PromDesc.fixedLabelValues + strings.Join(labels, ",") if dupCache[key] { fmt.Printf("%s.%s reported before as: %s\n", lm.ResultPath, lm.ResultPath, key) luaCollectErrors.Inc() return } dupCache[key] = true metric, err := prometheus.NewConstMetric(lm.Desc, lm.MetricType, value.Value, labels...) if err != nil { fmt.Printf("Error creating metric %s.%s: %s", lm.ResultPath, lm.ResultPath, err.Error()) } else { ch <- metric } } func test() { root, err := upnp.LoadServices(*flagGatewayURL, *flagUsername, *flagPassword) if err != nil { panic(err) } var newEntry bool = false var json bytes.Buffer json.WriteString("[\n") serviceKeys := []string{} for k := range root.Services { serviceKeys = append(serviceKeys, k) } sort.Strings(serviceKeys) for _, k := range serviceKeys { s := root.Services[k] fmt.Printf("Service: %s (Url: %s)\n", k, s.ControlURL) actionKeys := []string{} for l := range s.Actions { actionKeys = append(actionKeys, l) } sort.Strings(actionKeys) for _, l := range actionKeys { a := s.Actions[l] fmt.Printf(" %s - arguments: variable [direction] (soap name, soap type)\n", a.Name) for _, arg := range a.Arguments { sv := arg.StateVariable fmt.Printf(" %s [%s] (%s, %s)\n", arg.RelatedStateVariable, arg.Direction, arg.Name, sv.DataType) } if !a.IsGetOnly() { fmt.Printf(" %s - not calling, since arguments required or no output\n", a.Name) continue } // only create JSON for Get // TODO also create JSON templates for input actionParams for _, arg := range a.Arguments { // create new json entry if newEntry { json.WriteString(",\n") } else { newEntry = true } json.WriteString("\t{\n\t\t\"service\": \"") json.WriteString(k) json.WriteString("\",\n\t\t\"action\": \"") json.WriteString(a.Name) json.WriteString("\",\n\t\t\"result\": \"") json.WriteString(arg.RelatedStateVariable) json.WriteString("\"\n\t}") } fmt.Printf(" %s - calling - results: variable: value\n", a.Name) res, err := a.Call(nil) if err != nil { fmt.Printf(" FAILED:%s\n", err.Error()) continue } for _, arg := range a.Arguments { fmt.Printf(" %s: %v\n", arg.RelatedStateVariable, res[arg.StateVariable.Name]) } } } json.WriteString("\n]") if *flagJSONOut != "" { err := ioutil.WriteFile(*flagJSONOut, json.Bytes(), 0644) if err != nil { fmt.Printf("Failed writing JSON file '%s': %s\n", *flagJSONOut, err.Error()) } } } func testLua() { jsonData, err := ioutil.ReadFile("luaTest.json") if err != nil { fmt.Println("error reading luaTest.json:", err) return } var luaTests []LuaTest err = json.Unmarshal(jsonData, &luaTests) if err != nil { fmt.Println("error parsing luaTest JSON:", err) return } // create session struct and init params luaSession := lua.LuaSession{BaseURL: *flagGatewayLuaURL, Username: *flagUsername, Password: *flagPassword} for _, test := range luaTests { fmt.Printf("TESTING: %s (%s)\n", test.Path, test.Params) page := lua.LuaPage{Path: test.Path, Params: test.Params} pageData, err := luaSession.LoadData(page) if err != nil { fmt.Println(err.Error()) } else { fmt.Println(string(pageData)) } fmt.Println() fmt.Println() } } func getValueType(vt string) prometheus.ValueType { switch vt { case "CounterValue": return prometheus.CounterValue case "GaugeValue": return prometheus.GaugeValue case "UntypedValue": return prometheus.UntypedValue } return prometheus.UntypedValue } func main() { flag.Parse() u, err := url.Parse(*flagGatewayURL) if err != nil { fmt.Println("invalid URL:", err) return } if *flagTest { test() return } if *flagLuaTest { testLua() return } // read metrics jsonData, err := ioutil.ReadFile(*flagMetricsFile) if err != nil { fmt.Println("error reading metric file:", err) return } err = json.Unmarshal(jsonData, &metrics) if err != nil { fmt.Println("error parsing JSON:", err) return } // create a map for caching results upnpCache = make(map[string]*upnpCacheEntry) var luaSession *lua.LuaSession var luaLabelRenames *[]lua.LabelRename if !*flagDisableLua { jsonData, err := ioutil.ReadFile(*flagLuaMetricsFile) if err != nil { fmt.Println("error reading lua metric file:", err) return } var lmf *LuaMetricsFile err = json.Unmarshal(jsonData, &lmf) if err != nil { fmt.Println("error parsing lua JSON:", err) return } // create a map for caching results luaCache = make(map[string]*luaCacheEntry) // init label renames lblRen := make([]lua.LabelRename, 0) for _, ren := range lmf.LabelRenames { regex, err := regexp.Compile(ren.MatchRegex) if err != nil { fmt.Println("error compiling lua rename regex:", err) return } lblRen = append(lblRen, lua.LabelRename{Pattern: *regex, Name: ren.RenameLabel}) } luaLabelRenames = &lblRen // init metrics luaMetrics = lmf.Metrics for _, lm := range luaMetrics { pd := &lm.PromDesc // make labels lower case labels := make([]string, len(pd.VarLabels)) for i, l := range pd.VarLabels { labels[i] = strings.ToLower(l) } // create fixed labels values pd.fixedLabelValues = "" for _, flv := range pd.FixedLabels { pd.fixedLabelValues += flv + "," } lm.Desc = prometheus.NewDesc(pd.FqName, pd.Help, labels, pd.FixedLabels) lm.MetricType = getValueType(lm.PromType) lm.LuaPage = lua.LuaPage{ Path: lm.Path, Params: lm.Params, } lm.LuaMetricDef = lua.LuaMetricValueDefinition{ Path: lm.ResultPath, Key: lm.ResultKey, OkValue: lm.OkValue, Labels: pd.VarLabels, } // init TTL if lm.CacheEntryTTL < minCacheTTL { lm.CacheEntryTTL = minCacheTTL } } luaSession = &lua.LuaSession{ BaseURL: *flagGatewayLuaURL, Username: *flagUsername, Password: *flagPassword, } } // init metrics for _, m := range metrics { pd := &m.PromDesc // make labels lower case labels := make([]string, len(pd.VarLabels)) for i, l := range pd.VarLabels { labels[i] = strings.ToLower(l) } // create fixed labels values pd.fixedLabelValues = "" for _, flv := range pd.FixedLabels { pd.fixedLabelValues += flv + "," } m.Desc = prometheus.NewDesc(pd.FqName, pd.Help, labels, pd.FixedLabels) m.MetricType = getValueType(m.PromType) // init TTL if m.CacheEntryTTL < minCacheTTL { m.CacheEntryTTL = minCacheTTL } } collector := &FritzboxCollector{ URL: *flagGatewayURL, Gateway: u.Hostname(), Username: *flagUsername, Password: *flagPassword, LuaSession: luaSession, LabelRenames: luaLabelRenames, } if *flagCollect { collector.LoadServices() prometheus.MustRegister(collector) prometheus.MustRegister(collectErrors) if luaSession != nil { prometheus.MustRegister(luaCollectErrors) } fmt.Println("collecting metrics via http") // simulate HTTP request without starting actual http server writer := testResponseWriter{header: http.Header{}} request := http.Request{} promhttp.Handler().ServeHTTP(&writer, &request) fmt.Println(writer.String()) return } go collector.LoadServices() prometheus.MustRegister(collector) prometheus.MustRegister(collectErrors) prometheus.MustRegister(collectUpnpResultsCached) prometheus.MustRegister(collectUpnpResultsLoaded) if luaSession != nil { prometheus.MustRegister(luaCollectErrors) prometheus.MustRegister(collectLuaResultsCached) prometheus.MustRegister(collectLuaResultsLoaded) } http.Handle("/metrics", promhttp.Handler()) fmt.Printf("metrics available at http://%s/metrics\n", *flagAddr) log.Fatal(http.ListenAndServe(*flagAddr, nil)) }