You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
422 lines
10 KiB
422 lines
10 KiB
// Package lua_client implementes client for fritzbox lua UI API
|
|
package lua_client
|
|
|
|
// Copyright 2020 Andreas Krebs
|
|
//
|
|
// 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"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/text/encoding/unicode"
|
|
"golang.org/x/text/transform"
|
|
)
|
|
|
|
// SessionInfo XML from login_sid.lua
|
|
type SessionInfo struct {
|
|
SID string `xml:"SID"`
|
|
Challenge string `xml:"Challenge"`
|
|
BlockTime int `xml:"BlockTime"`
|
|
Rights string `xml:"Rights"`
|
|
}
|
|
|
|
// LuaSession for storing connection data and SID
|
|
type LuaSession struct {
|
|
BaseURL string
|
|
Username string
|
|
Password string
|
|
SID string
|
|
SessionInfo SessionInfo
|
|
}
|
|
|
|
// LuaPage identified by path and params
|
|
type LuaPage struct {
|
|
Path string
|
|
Params string
|
|
}
|
|
|
|
// LuaMetricValueDefinition definition for a single metric
|
|
type LuaMetricValueDefinition struct {
|
|
Path string
|
|
Key string
|
|
OkValue string
|
|
Labels []string
|
|
}
|
|
|
|
// LuaMetricValue single value retrieved from lua page
|
|
type LuaMetricValue struct {
|
|
Name string
|
|
Value float64
|
|
Labels map[string]string
|
|
}
|
|
|
|
// LabelRename regex to replace labels to get rid of translations
|
|
type LabelRename struct {
|
|
Pattern regexp.Regexp
|
|
Name string
|
|
}
|
|
|
|
func (lua *LuaSession) doLogin(response string) error {
|
|
urlParams := ""
|
|
if response != "" {
|
|
urlParams = fmt.Sprintf("?response=%s&user=%s", response, lua.Username)
|
|
}
|
|
|
|
resp, err := http.Get(fmt.Sprintf("%s/login_sid.lua%s", lua.BaseURL, urlParams))
|
|
if err != nil {
|
|
return fmt.Errorf("Error calling login_sid.lua: %s", err.Error())
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
dec := xml.NewDecoder(resp.Body)
|
|
|
|
err = dec.Decode(&lua.SessionInfo)
|
|
if err != nil {
|
|
return fmt.Errorf("Error decoding SessionInfo: %s", err.Error())
|
|
}
|
|
|
|
if lua.SessionInfo.BlockTime > 0 {
|
|
return fmt.Errorf("To many failed logins, login blocked for %d seconds", lua.SessionInfo.BlockTime)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (lmvDef *LuaMetricValueDefinition) createValue(name string, value float64) LuaMetricValue {
|
|
lmv := LuaMetricValue{
|
|
Name: name,
|
|
Value: value,
|
|
Labels: make(map[string]string),
|
|
}
|
|
|
|
return lmv
|
|
}
|
|
|
|
// Login perform loing and get SID
|
|
func (lua *LuaSession) Login() error {
|
|
|
|
err := lua.doLogin("")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
challenge := lua.SessionInfo.Challenge
|
|
if lua.SessionInfo.SID == "0000000000000000" && challenge != "" {
|
|
// no SID, but challenge so calc response
|
|
hash := utf16leMd5(fmt.Sprintf("%s-%s", challenge, lua.Password))
|
|
response := fmt.Sprintf("%s-%x", challenge, hash)
|
|
err := lua.doLogin(response)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
sid := lua.SessionInfo.SID
|
|
if sid == "0000000000000000" || sid == "" {
|
|
return errors.New("LUA login failed - no SID received - check username and password")
|
|
}
|
|
|
|
lua.SID = sid
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoadData load a lua bage and return content
|
|
func (lua *LuaSession) LoadData(page LuaPage) ([]byte, error) {
|
|
method := "POST"
|
|
path := page.Path
|
|
|
|
// handle method prefix
|
|
pathParts := strings.SplitN(path, ":", 2)
|
|
if len(pathParts) > 1 {
|
|
method = pathParts[0]
|
|
path = pathParts[1]
|
|
}
|
|
|
|
dataURL := fmt.Sprintf("%s/%s", lua.BaseURL, path)
|
|
|
|
callDone := false
|
|
var resp *http.Response
|
|
var err error
|
|
for !callDone {
|
|
// perform login if no SID or previous call failed with (403)
|
|
if lua.SID == "" || resp != nil {
|
|
err = lua.Login()
|
|
callDone = true // consider call done, since we tried login
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// send by UI for data.lua: xhr=1&sid=xxxxxxx&lang=de&page=energy&xhrId=all&no_sidrenew=
|
|
// but SID and page seem to be enough
|
|
params := "sid=" + lua.SID
|
|
if page.Params != "" {
|
|
params += "&" + page.Params
|
|
}
|
|
|
|
if method == "POST" {
|
|
resp, err = http.Post(dataURL, "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(params)))
|
|
} else if method == "GET" {
|
|
resp, err = http.Get(dataURL + "?" + params)
|
|
} else {
|
|
err = fmt.Errorf("method %s is unsupported in path %s", method, page.Path)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
callDone = true
|
|
} else if resp.StatusCode == http.StatusForbidden && !callDone {
|
|
// we assume SID is expired, so retry login
|
|
} else {
|
|
return nil, fmt.Errorf("%s failed: %s", page.Path, resp.Status)
|
|
}
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
// ParseJSON generic parser for unmarshalling into map
|
|
func ParseJSON(jsonData []byte) (map[string]interface{}, error) {
|
|
var data map[string]interface{}
|
|
|
|
// Unmarshal or Decode the JSON to the interface.
|
|
json.Unmarshal(jsonData, &data)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func getRenamedLabel(labelRenames *[]LabelRename, label string) string {
|
|
if labelRenames != nil {
|
|
for _, lblRen := range *labelRenames {
|
|
if lblRen.Pattern.MatchString(label) {
|
|
return lblRen.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
return label
|
|
}
|
|
|
|
func getValueFromHashOrArray(mapOrArray interface{}, key string, path string) (interface{}, error) {
|
|
var value interface{}
|
|
|
|
switch moa := mapOrArray.(type) {
|
|
case map[string]interface{}:
|
|
var exists bool
|
|
value, exists = moa[key]
|
|
if !exists {
|
|
return nil, fmt.Errorf("hash '%s' has no element '%s'", path, key)
|
|
}
|
|
case []interface{}:
|
|
// since type is array there can't be any labels to differentiate values, so only one value supported !
|
|
index, err := strconv.Atoi(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("item '%s' is an array, but index '%s' is not a number", path, key)
|
|
}
|
|
|
|
if index < 0 {
|
|
// this is an index from the end of the values
|
|
index += len(moa)
|
|
}
|
|
|
|
if index < 0 || index >= len(moa) {
|
|
return nil, fmt.Errorf("index %d is invalid for array '%s' with length %d", index, path, len(moa))
|
|
}
|
|
value = moa[index]
|
|
default:
|
|
return nil, fmt.Errorf("item '%s' is not a hash or array, can't get value %s", path, key)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// GetMetrics get metrics from parsed lua page for definition and rename labels
|
|
func GetMetrics(labelRenames *[]LabelRename, data map[string]interface{}, metricDef LuaMetricValueDefinition) ([]LuaMetricValue, error) {
|
|
|
|
var values []interface{}
|
|
var err error
|
|
if metricDef.Path != "" {
|
|
pathItems := strings.Split(metricDef.Path, ".")
|
|
values, err = _getValues(data, pathItems, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
values = make([]interface{}, 1)
|
|
values[0] = data
|
|
}
|
|
|
|
metrics := make([]LuaMetricValue, 0)
|
|
keyItems := strings.Split(metricDef.Key, ".")
|
|
|
|
VALUE:
|
|
for _, pathVal := range values {
|
|
valUntyped := pathVal
|
|
path := metricDef.Path
|
|
|
|
// now handle if key is also splitted
|
|
for _, key := range keyItems {
|
|
valUntyped, err = getValueFromHashOrArray(valUntyped, key, path)
|
|
if err != nil {
|
|
// since we may have other values, we simply continue (should we report it?)
|
|
continue VALUE
|
|
}
|
|
|
|
if path != "" {
|
|
path += "."
|
|
}
|
|
path += key
|
|
}
|
|
|
|
var sVal = toString(valUntyped)
|
|
var floatVal float64
|
|
if metricDef.OkValue != "" {
|
|
if metricDef.OkValue == sVal {
|
|
floatVal = 1
|
|
} else {
|
|
floatVal = 0
|
|
}
|
|
} else {
|
|
// convert value to float
|
|
floatVal, err = strconv.ParseFloat(sVal, 64)
|
|
if err != nil {
|
|
continue VALUE
|
|
}
|
|
}
|
|
|
|
// create metric value
|
|
lmv := metricDef.createValue(path, floatVal)
|
|
|
|
// add labels if pathVal is a hash
|
|
valMap, isType := pathVal.(map[string]interface{})
|
|
if isType {
|
|
for _, l := range metricDef.Labels {
|
|
lv, exists := valMap[l]
|
|
if exists {
|
|
lmv.Labels[l] = getRenamedLabel(labelRenames, toString(lv))
|
|
}
|
|
}
|
|
}
|
|
|
|
metrics = append(metrics, lmv)
|
|
}
|
|
|
|
if len(metrics) == 0 {
|
|
if err == nil {
|
|
// normal we should already have an error, this is just a fallback
|
|
err = fmt.Errorf("no value found for item '%s' with key '%s'", metricDef.Path, metricDef.Key)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
// from https://stackoverflow.com/questions/33710672/golang-encode-string-utf16-little-endian-and-hash-with-md5
|
|
func utf16leMd5(s string) []byte {
|
|
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
|
|
hasher := md5.New()
|
|
t := transform.NewWriter(hasher, enc)
|
|
t.Write([]byte(s))
|
|
return hasher.Sum(nil)
|
|
}
|
|
|
|
// helper for retrieving values from parsed JSON
|
|
func _getValues(data interface{}, pathItems []string, parentPath string) ([]interface{}, error) {
|
|
|
|
var err error
|
|
values := make([]interface{}, 0)
|
|
value := data
|
|
curPath := parentPath
|
|
|
|
for i, p := range pathItems {
|
|
if p == "*" {
|
|
// handle * case to get all values
|
|
var subvals []interface{}
|
|
switch vv := value.(type) {
|
|
case []interface{}:
|
|
for index, u := range vv {
|
|
subvals, err = _getValues(u, pathItems[i+1:], fmt.Sprintf("%s.%d", curPath, index))
|
|
|
|
if subvals != nil {
|
|
values = append(values, subvals...)
|
|
}
|
|
}
|
|
case map[string]interface{}:
|
|
for subK, subV := range vv {
|
|
subvals, err = _getValues(subV, pathItems[i+1:], fmt.Sprintf("%s.%s", curPath, subK))
|
|
|
|
if subvals != nil {
|
|
values = append(values, subvals...)
|
|
}
|
|
}
|
|
default:
|
|
err = fmt.Errorf("item '%s' is neither a hash or array", curPath)
|
|
}
|
|
|
|
if len(values) == 0 {
|
|
if err == nil {
|
|
err = fmt.Errorf("item '%s.*' has no values", curPath)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return values, nil
|
|
}
|
|
|
|
// this is a single value
|
|
value, err = getValueFromHashOrArray(value, p, curPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if curPath == "" {
|
|
curPath = p
|
|
} else {
|
|
curPath += "." + p
|
|
}
|
|
}
|
|
|
|
values = append(values, value)
|
|
|
|
return values, nil
|
|
}
|
|
|
|
func toString(value interface{}) string {
|
|
// should we better check or simple convert everything ????
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
|