internal/fritzbox/igd.go
Rama main · réplica de lectura del repositorio original.
package fritzbox
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// The internet connection's counters come from the box's UPnP IGD service,
// the one AVM's TR-064 documentation says its own byte counters depend on.
// TR-064 declares those as 32 bit, which wrap every 4 GiB; only IGD names
// explicit 64 bit fields. IGD needs no login, but the box must have "Transmit
// status information over UPnP" switched on.
const (
igdPort = "49000"
igdControl = "/igdupnp/control/WANCommonIFC1"
igdService = "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1"
)
// WAN is the internet connection's traffic. The byte counters reset when the
// box reconnects or restarts.
type WAN struct {
ReceivedBytes uint64
SentBytes uint64
// Line speed in bits per second, 0 when the box does not say.
DownstreamBps uint64
UpstreamBps uint64
}
func igdBase(baseURL string) string {
u, err := url.Parse(baseURL)
if err != nil || u.Hostname() == "" {
return ""
}
return "http://" + u.Hostname() + ":" + igdPort
}
// WAN reads the traffic counters and the line speed.
func (c *Client) WAN(ctx context.Context) (WAN, error) {
var w WAN
info, err := c.igd(ctx, "GetAddonInfos")
if err != nil {
return w, err
}
// Firmware without the 64 bit fields leaves them empty; the 32 bit
// counters then still chart, with a reset every 4 GiB.
if w.ReceivedBytes, err = counter(info, "NewX_AVM_DE_TotalBytesReceived64", "NewTotalBytesReceived"); err != nil {
return w, err
}
if w.SentBytes, err = counter(info, "NewX_AVM_DE_TotalBytesSent64", "NewTotalBytesSent"); err != nil {
return w, err
}
link, err := c.igd(ctx, "GetCommonLinkProperties")
if err != nil {
return w, err
}
w.DownstreamBps, _ = strconv.ParseUint(link["NewLayer1DownstreamMaxBitRate"], 10, 64)
w.UpstreamBps, _ = strconv.ParseUint(link["NewLayer1UpstreamMaxBitRate"], 10, 64)
return w, nil
}
func counter(args map[string]string, names ...string) (uint64, error) {
for _, n := range names {
if v := args[n]; v != "" {
return strconv.ParseUint(v, 10, 64)
}
}
return 0, fmt.Errorf("the box reported no %s", names[0])
}
// igd calls one argument-less action and returns its out arguments by name.
func (c *Client) igd(ctx context.Context, action string) (map[string]string, error) {
if c.IGD == "" {
return nil, fmt.Errorf("no host in %q", c.BaseURL)
}
body := `<?xml version="1.0" encoding="utf-8"?>` +
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
`<s:Body><u:` + action + ` xmlns:u="` + igdService + `"/></s:Body></s:Envelope>`
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IGD+igdControl, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
// Unquoted, the box answers every action with "XML error".
req.Header.Set("SOAPACTION", `"`+igdService+"#"+action+`"`)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("contacting %s: %w", c.IGD, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, errors.New(`UPnP status information is off: enable "Transmit status information over UPnP" on the box`)
}
args, fault, err := parseSOAP(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", action, err)
}
if fault != "" {
return nil, fmt.Errorf("%s: %s", action, fault)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned HTTP %d", action, resp.StatusCode)
}
return args, nil
}
// parseSOAP collects every New* element of a response, and the error
// description of a fault.
func parseSOAP(raw []byte) (args map[string]string, fault string, err error) {
args = map[string]string{}
dec := xml.NewDecoder(strings.NewReader(string(raw)))
var name string
for {
tok, err := dec.Token()
if err == io.EOF {
return args, fault, nil
}
if err != nil {
return nil, "", fmt.Errorf("parsing response: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
name = t.Name.Local
case xml.CharData:
switch {
case strings.HasPrefix(name, "New"):
args[name] = strings.TrimSpace(string(t))
case name == "errorDescription":
fault = strings.TrimSpace(string(t))
}
case xml.EndElement:
name = ""
}
}
}