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

// 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)
}