internal/fritzbox/client.go
Rama main · réplica de lectura del repositorio original.
// Package fritzbox talks to a FRITZ!Box over interfaces AVM documents and keeps
// stable across firmware releases: login_sid.lua for the session, the
// AHA-HTTP-Interface for smart home data, and UPnP IGD for the internet
// connection's traffic.
//
// The Smart Home REST API of FRITZ!OS 8.20 is the alternative to AHA, see rest.go.
//
// It deliberately does not touch the web UI. Scraping the UI is what makes
// home-grown FRITZ!Box scripts break on every firmware update; these are
// specified and versioned.
//
// https://fritz.support/resources/HTTP_Session-ID_EN.pdf
// https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf
// https://fritz.support/resources/TR-064_WAN_Common_Interface_Config.pdf
package fritzbox
import (
"context"
"crypto/md5"
"crypto/pbkdf2"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
)
// invalidSID is what the box returns when authentication failed.
const invalidSID = "0000000000000000"
// sessionTTL is conservative: AVM expires a session after 20 minutes idle, so
// re-authenticating well before that avoids a failed poll.
const sessionTTL = 10 * time.Minute
type Client struct {
BaseURL string
Username string
Password string
HTTP *http.Client
// IGD is the box's UPnP address, derived from BaseURL.
IGD string
mu sync.Mutex
sid string
sidTime time.Time
}
func New(baseURL, username, password string) *Client {
return &Client{
BaseURL: strings.TrimSuffix(baseURL, "/"),
Username: username,
Password: password,
HTTP: &http.Client{Timeout: 15 * time.Second},
IGD: igdBase(baseURL),
}
}
type sessionInfo struct {
SID string `xml:"SID"`
Challenge string `xml:"Challenge"`
BlockTime int `xml:"BlockTime"`
}
// sessionID returns a cached session, logging in when needed.
func (c *Client) sessionID(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.sid != "" && c.sid != invalidSID && time.Since(c.sidTime) < sessionTTL {
return c.sid, nil
}
sid, err := c.login(ctx)
if err != nil {
return "", err
}
c.sid, c.sidTime = sid, time.Now()
return sid, nil
}
func (c *Client) login(ctx context.Context) (string, error) {
info, err := c.challenge(ctx)
if err != nil {
return "", err
}
if info.BlockTime > 0 {
// The box rate-limits failed logins. Saying so beats a bare 403.
return "", fmt.Errorf("login blocked for another %ds after a failed attempt", info.BlockTime)
}
response, err := answer(info.Challenge, c.Password)
if err != nil {
return "", err
}
form := url.Values{"username": {c.Username}, "response": {response}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/login_sid.lua?version=2", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var out sessionInfo
if err := c.doXML(req, &out); err != nil {
return "", err
}
if out.SID == "" || out.SID == invalidSID {
return "", errors.New("login rejected: check the username and password, and that the user has Smart Home permission")
}
return out.SID, nil
}
func (c *Client) challenge(ctx context.Context) (sessionInfo, error) {
var out sessionInfo
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/login_sid.lua?version=2", nil)
if err != nil {
return out, err
}
err = c.doXML(req, &out)
return out, err
}
// answer computes the challenge response. Firmware from FRITZ!OS 7.24 sends a
// PBKDF2 challenge beginning "2$"; anything else is the legacy MD5 scheme.
func answer(challenge, password string) (string, error) {
if strings.HasPrefix(challenge, "2$") {
return pbkdf2Answer(challenge, password)
}
return md5Answer(challenge, password), nil
}
// pbkdf2Answer implements 2$<iter1>$<salt1>$<iter2>$<salt2>: hash the password
// with the first salt, then hash *that* with the second.
func pbkdf2Answer(challenge, password string) (string, error) {
parts := strings.Split(challenge, "$")
if len(parts) != 5 {
return "", fmt.Errorf("malformed PBKDF2 challenge %q", challenge)
}
iter1, err := strconv.Atoi(parts[1])
if err != nil {
return "", fmt.Errorf("bad first iteration count: %w", err)
}
salt1, err := hex.DecodeString(parts[2])
if err != nil {
return "", fmt.Errorf("bad first salt: %w", err)
}
iter2, err := strconv.Atoi(parts[3])
if err != nil {
return "", fmt.Errorf("bad second iteration count: %w", err)
}
salt2, err := hex.DecodeString(parts[4])
if err != nil {
return "", fmt.Errorf("bad second salt: %w", err)
}
hash1, err := pbkdf2.Key(sha256.New, password, salt1, iter1, 32)
if err != nil {
return "", err
}
hash2, err := pbkdf2.Key(sha256.New, string(hash1), salt2, iter2, 32)
if err != nil {
return "", err
}
return parts[4] + "$" + hex.EncodeToString(hash2), nil
}
// md5Answer is the pre-7.24 scheme. The digest is taken over UTF-16LE, which
// is the detail everyone gets wrong.
func md5Answer(challenge, password string) string {
text := challenge + "-" + password
var buf []byte
for _, r := range utf16.Encode([]rune(text)) {
buf = append(buf, byte(r), byte(r>>8))
}
sum := md5.Sum(buf)
return challenge + "-" + hex.EncodeToString(sum[:])
}
func (c *Client) doXML(req *http.Request, out any) error {
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("contacting %s: %w", c.BaseURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned HTTP %d", req.URL.Path, resp.StatusCode)
}
if err := xml.Unmarshal(body, out); err != nil {
return fmt.Errorf("parsing response from %s: %w", req.URL.Path, err)
}
return nil
}
// aha issues one AHA command and returns the raw body.
func (c *Client) aha(ctx context.Context, cmd string) ([]byte, error) {
sid, err := c.sessionID(ctx)
if err != nil {
return nil, err
}
q := url.Values{"sid": {sid}, "switchcmd": {cmd}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.BaseURL+"/webservices/homeautoswitch.lua?"+q.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("contacting %s: %w", c.BaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
// The session went stale or the user lacks the Smart Home permission.
// Drop the cached SID so the next call logs in again.
c.mu.Lock()
c.sid = ""
c.mu.Unlock()
return nil, errors.New("AHA request forbidden: session expired, or the user lacks the Smart Home permission")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("AHA request returned HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}