Die bisherige Anmeldung gehörte zu einer anderen Firmware-Generation und
scheiterte auf diesem Gerät durchgängig mit "login: failed". Der Ablauf wurde
aus js/pages/login.js und js/jquery-addons.js des Routers rekonstruiert:
1. Challenge per CCM-verschlüsseltem POST {getChallenge:1, httoken}
an data/Login.json; das Antwortfeld heisst "challenge", nicht "challengev".
2. Passwort = SHA256(challenge + ":" + passwort), hex — kein PBKDF2/AES-CCM.
3. Nach dem Login dient die Challenge selbst als CCM-Schluessel
(im Client getFromLocal("challenge_val")), nicht mehr der statische.
4. _httoken steht als `var _httoken = <zahl>` inline in angemeldeten Seiten
und wird nach dem Login von dort gelesen.
5. Datenabrufe brauchen Cache-Buster, _tn und den Header
X-Requested-With: XMLHttpRequest — ohne ihn liefert der Router die
HTML-Huelle statt Nutzdaten.
Ausserdem: Login() wurde bisher nirgends aufgerufen (toter Code), und
login_locked wird jetzt als eigene Fehlermeldung ausgegeben. Es ist ein
exponentieller Backoff (2 -> 4 -> 16 -> 32) und die Folge fehlgeschlagener
Versuche, nicht deren Ursache.
Ergebnis: acht Endpunkte liefern Daten. data/DSL.json existiert auf dieser
Firmware nicht (404). data/PortuwMain.json und data/LAN.json antworten
weiterhin mit status=ok ohne Nutzlast; die fuenf geprueften Anfrageformen
stehen als Kommentar an der betreffenden Stelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
630 lines
19 KiB
Go
630 lines
19 KiB
Go
package speedport
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// httokenRe matches `var _httoken = 1931431431` in authenticated pages.
|
|
var httokenRe = regexp.MustCompile(`var\s+_httoken\s*=\s*"?([A-Za-z0-9]+)"?`)
|
|
|
|
const (
|
|
// Hardcoded AES-256-CCM key used by the Speedport Smart 4 for Status.json encryption.
|
|
// This was reverse-engineered by the community (see: github.com/aaronk6/dsl-monitoring).
|
|
statusKeyHex = "cdc0cac1280b516e674f0057e4929bca84447cca8425007e33a88a5cf598a190"
|
|
|
|
// Tag size for Status.json decryption
|
|
statusTagSize = 16
|
|
|
|
// Tag size for login encryption
|
|
loginTagSize = 8
|
|
|
|
// PBKDF2 iterations for login key derivation
|
|
pbkdf2Iterations = 1000
|
|
|
|
// PBKDF2 derived key length
|
|
pbkdf2KeyLen = 16
|
|
|
|
defaultTimeout = 10 * time.Second
|
|
)
|
|
|
|
// Client communicates with a Speedport Smart 4 router.
|
|
type Client struct {
|
|
baseURL string
|
|
password string
|
|
httpClient *http.Client
|
|
statusKey []byte
|
|
statusCCM *CCM
|
|
|
|
mu sync.Mutex
|
|
csrfToken string
|
|
loggedIn bool
|
|
// httoken is a form token the router's UI injects into its pages. The
|
|
// login flow sends it along; empty works for the challenge request.
|
|
httoken string
|
|
// After login the router switches to the challenge as CCM key for
|
|
// protected endpoints — mirroring getFromLocal("challenge_val") in its
|
|
// js/jquery-addons.js. Unprotected endpoints keep using the static key.
|
|
sessionKey []byte
|
|
sessionCCM *CCM
|
|
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// ClientOption configures the Client.
|
|
type ClientOption func(*Client)
|
|
|
|
// WithPassword sets the router password for authenticated endpoints.
|
|
func WithPassword(password string) ClientOption {
|
|
return func(c *Client) {
|
|
c.password = password
|
|
}
|
|
}
|
|
|
|
// WithTimeout sets the HTTP timeout.
|
|
func WithTimeout(d time.Duration) ClientOption {
|
|
return func(c *Client) {
|
|
c.httpClient.Timeout = d
|
|
}
|
|
}
|
|
|
|
// WithLogger sets a custom logger.
|
|
func WithLogger(l *slog.Logger) ClientOption {
|
|
return func(c *Client) {
|
|
c.logger = l
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new Speedport client.
|
|
// host is the router address, e.g. "192.168.2.1" or "speedport.ip".
|
|
func NewClient(host string, opts ...ClientOption) (*Client, error) {
|
|
keyBytes, err := hex.DecodeString(statusKeyHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode status key: %w", err)
|
|
}
|
|
|
|
ccm, err := NewCCM(keyBytes, statusTagSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create CCM: %w", err)
|
|
}
|
|
|
|
jar, _ := cookiejar.New(nil)
|
|
|
|
c := &Client{
|
|
baseURL: fmt.Sprintf("https://%s", host),
|
|
statusKey: keyBytes,
|
|
statusCCM: ccm,
|
|
httpClient: &http.Client{
|
|
Timeout: defaultTimeout,
|
|
Jar: jar,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
},
|
|
},
|
|
logger: slog.Default(),
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// fetchRaw fetches a URL and returns the raw response body.
|
|
// fetchPlain GETs a path verbatim, without the AJAX decorations.
|
|
func (c *Client) fetchPlain(path string) ([]byte, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/"+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func (c *Client) fetchRaw(path string) ([]byte, error) {
|
|
// The UI appends cache busters and the session token as _tn (see
|
|
// addTokenField() in js/global.js) and marks the call as XHR. Without the
|
|
// marker the firmware answers with the HTML shell instead of payload.
|
|
referer := c.baseURL + "/html/content/overview/index.html"
|
|
if c.httoken != "" && !strings.Contains(path, "?") {
|
|
var tok string
|
|
tok, referer = c.tokenFor(path)
|
|
now := time.Now()
|
|
q := url.Values{
|
|
"_time": {strconv.FormatInt(now.UnixMilli(), 10)},
|
|
"_rand": {strconv.FormatInt(now.UnixNano()%1000, 10)},
|
|
"_lang": {"DE"},
|
|
"_tn": {tok},
|
|
}
|
|
path = path + "?" + q.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", c.baseURL+"/"+path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
req.Header.Set("Referer", referer)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP GET %s: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP GET %s: status %d", path, resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// postForm sends a POST request with form data.
|
|
// postBody posts a pre-encoded body (the CCM ciphertext, hex) to path.
|
|
func (c *Client) postBody(path, body string) ([]byte, error) {
|
|
req, err := http.NewRequest("POST", c.baseURL+"/"+path, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
|
|
// The firmware serves the HTML login page instead of encrypted payload
|
|
// unless the request looks like the UI's own AJAX call.
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
req.Header.Set("Origin", c.baseURL)
|
|
req.Header.Set("Referer", c.baseURL+"/html/content/overview/index.html")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP POST %s: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
out, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) postForm(path string, data url.Values) ([]byte, error) {
|
|
reqURL := c.baseURL + "/" + path
|
|
|
|
req, err := http.NewRequest("POST", reqURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept-Language", "en")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP POST %s: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
// decryptStatusResponse decrypts a hex-encoded, AES-256-CCM encrypted response
|
|
// as used by the Speedport Smart 4 for Status.json (and similar unauthenticated endpoints).
|
|
func (c *Client) decryptStatusResponse(hexData []byte) ([]byte, error) {
|
|
// Trim any whitespace/newlines
|
|
hexStr := strings.TrimSpace(string(hexData))
|
|
|
|
// Decode hex to bytes
|
|
ciphertextWithTag, err := hex.DecodeString(hexStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hex decode: %w", err)
|
|
}
|
|
|
|
// Once logged in the router encrypts protected endpoints with the
|
|
// challenge as key; unprotected ones (Status.json, DeviceList.json) stay
|
|
// on the static key. Try the session key first, then fall back.
|
|
if c.sessionCCM != nil {
|
|
if plaintext, err := c.sessionCCM.Decrypt(c.sessionKey[:8], ciphertextWithTag, nil); err == nil {
|
|
return plaintext, nil
|
|
}
|
|
}
|
|
|
|
plaintext, err := c.statusCCM.Decrypt(c.statusKey[:8], ciphertextWithTag, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CCM decrypt: %w", err)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// parseResponse parses the Speedport JSON response format.
|
|
// The response is a JSON array of VarEntry objects.
|
|
func parseResponse(data []byte) ([]VarEntry, error) {
|
|
var entries []VarEntry
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|
return nil, fmt.Errorf("parse JSON: %w", err)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// GetStatus fetches and decrypts the Status.json endpoint (no login required).
|
|
func (c *Client) GetStatus() (*StatusData, error) {
|
|
c.logger.Debug("fetching Status.json")
|
|
|
|
raw, err := c.fetchRaw("data/Status.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch Status.json: %w", err)
|
|
}
|
|
|
|
// Try direct JSON parse first (some firmware versions don't encrypt)
|
|
var entries []VarEntry
|
|
if err := json.Unmarshal(raw, &entries); err == nil {
|
|
c.logger.Debug("Status.json was unencrypted JSON")
|
|
return NewStatusData(entries), nil
|
|
}
|
|
|
|
// Decrypt the hex-encoded AES-CCM response
|
|
plaintext, err := c.decryptStatusResponse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt Status.json: %w", err)
|
|
}
|
|
|
|
entries, err = parseResponse(plaintext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse Status.json: %w", err)
|
|
}
|
|
|
|
c.logger.Debug("Status.json decoded", "entries", len(entries))
|
|
return NewStatusData(entries), nil
|
|
}
|
|
|
|
// Login authenticates with the router.
|
|
//
|
|
// Flow as implemented by the router's own js/pages/login.js on firmware
|
|
// 010146.5.0.001.0:
|
|
//
|
|
// 1. POST a CCM-encrypted body {getChallenge:1, httoken:…} to data/Login.json
|
|
// and decrypt the reply; the challenge is in the field "challenge".
|
|
// 2. password = SHA256(challenge + ":" + plaintext password), hex.
|
|
// 3. POST challenge + hashed password + showpw=0.
|
|
//
|
|
// An earlier implementation used PBKDF2 + AES-128-CCM and looked for a field
|
|
// named "challengev". That belongs to a different firmware generation; this
|
|
// router answers such requests with "login: failed" and raises login_locked.
|
|
func (c *Client) Login() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.password == "" {
|
|
return fmt.Errorf("no password configured")
|
|
}
|
|
|
|
c.logger.Debug("starting login")
|
|
|
|
// Step 1: request the challenge.
|
|
challengeSD, err := c.postEncrypted(url.Values{
|
|
"getChallenge": {"1"},
|
|
"httoken": {c.httoken},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("request challenge: %w", err)
|
|
}
|
|
|
|
challenge := challengeSD.Get("challenge")
|
|
if challenge == "" {
|
|
if challengeSD.Get("login") == "success" {
|
|
c.loggedIn = true
|
|
c.csrfToken = challengeSD.Get("csrf_token")
|
|
c.logger.Info("already logged in")
|
|
return nil
|
|
}
|
|
// login_locked is an exponential backoff the router raises on every
|
|
// failed attempt and decays over time. Observed: 2 -> 4 -> 16 -> 32.
|
|
// It is a consequence of failed logins, not an independent lockout.
|
|
if locked := challengeSD.Get("login_locked"); locked != "" && locked != "0" {
|
|
return fmt.Errorf("no challenge returned; router backoff active "+
|
|
"(login_locked=%s) — wait for it to decay before retrying", locked)
|
|
}
|
|
return fmt.Errorf("no challenge in response, status: %s", challengeSD.Get("login"))
|
|
}
|
|
|
|
c.logger.Debug("got challenge", "length", len(challenge))
|
|
|
|
// Step 2: SHA256(challenge + ":" + password)
|
|
sum := sha256.Sum256([]byte(challenge + ":" + c.password))
|
|
hashed := hex.EncodeToString(sum[:])
|
|
|
|
// Step 3: submit the login.
|
|
loginSD, err := c.postEncrypted(url.Values{
|
|
"challenge": {challenge},
|
|
"password": {hashed},
|
|
"showpw": {"0"},
|
|
"httoken": {c.httoken},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("login request: %w", err)
|
|
}
|
|
|
|
if loginSD.Get("login") != "success" {
|
|
if locked := loginSD.Get("login_locked"); locked != "" && locked != "0" {
|
|
return fmt.Errorf("login rejected (login_locked=%s)", locked)
|
|
}
|
|
return fmt.Errorf("login failed: %s", loginSD.Get("login"))
|
|
}
|
|
|
|
c.csrfToken = loginSD.Get("csrf_token")
|
|
c.loggedIn = true
|
|
|
|
if key, err := hex.DecodeString(challenge); err == nil && len(key) == 32 {
|
|
if sc, err := NewCCM(key, statusTagSize); err == nil {
|
|
c.sessionKey = key
|
|
c.sessionCCM = sc
|
|
c.logger.Debug("session key derived from challenge")
|
|
}
|
|
}
|
|
|
|
c.fetchHTToken()
|
|
|
|
c.logger.Info("login successful")
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchHTToken retrieves the session token the router injects inline into its
|
|
// authenticated pages as `var _httoken = <number>`. Its own UI reads that
|
|
// variable and appends it to every data request (setHTToken in js/global.js).
|
|
func (c *Client) fetchHTToken() {
|
|
page, err := c.fetchRaw("html/content/overview/index.html")
|
|
if err != nil {
|
|
c.logger.Debug("httoken: page fetch failed", "error", err)
|
|
return
|
|
}
|
|
if m := httokenRe.FindSubmatch(page); m != nil {
|
|
c.httoken = string(m[1])
|
|
c.logger.Debug("httoken acquired", "length", len(c.httoken))
|
|
return
|
|
}
|
|
c.logger.Debug("httoken: not found in page")
|
|
}
|
|
|
|
// postEncrypted CCM-encrypts a form body, posts it to data/Login.json and
|
|
// decrypts the reply. Mirrors encryptccm()/decryptccm() in the router's
|
|
// js/jquery-addons.js: the static Status.json key is used while no challenge
|
|
// is stored client-side, the nonce is the first 8 bytes of that key, and the
|
|
// wire format is hex.
|
|
func (c *Client) postEncrypted(form url.Values) (*StatusData, error) {
|
|
ct, err := c.statusCCM.Encrypt(c.statusKey[:8], []byte(form.Encode()), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt request: %w", err)
|
|
}
|
|
|
|
raw, err := c.postBody("data/Login.json", hex.EncodeToString(ct))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entries []VarEntry
|
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
plaintext, derr := c.decryptStatusResponse(raw)
|
|
if derr != nil {
|
|
return nil, fmt.Errorf("parse reply (raw: %v, decrypt: %v)", err, derr)
|
|
}
|
|
if err := json.Unmarshal(plaintext, &entries); err != nil {
|
|
return nil, fmt.Errorf("parse decrypted reply: %w", err)
|
|
}
|
|
}
|
|
|
|
return NewStatusData(entries), nil
|
|
}
|
|
|
|
// hashPassword returns the SHA256 hex digest of the password.
|
|
func (c *Client) hashPassword() string {
|
|
h := sha256.Sum256([]byte(c.password))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// GetEndpoint fetches and decrypts any data/*.json endpoint.
|
|
func (c *Client) GetEndpoint(endpoint string) (*StatusData, error) {
|
|
c.logger.Debug("fetching endpoint", "endpoint", endpoint)
|
|
|
|
// Two request forms exist and no endpoint accepts both:
|
|
// POST — how the UI asks ($.postJSON in js/jquery-addons.js): cache-busting
|
|
// query parameters, an XMLHttpRequest marker header and a
|
|
// CCM-encrypted body carrying httoken. Required for IPData, InetIP,
|
|
// Connect, WLANBasic and SystemMessages.
|
|
// GET — plain, works for Status, DeviceList, Modules and Overview.
|
|
// Try the authenticated POST first, fall back to GET.
|
|
if c.httoken != "" {
|
|
if sd, err := c.fetchViaPost(endpoint); err == nil {
|
|
return sd, nil
|
|
} else {
|
|
c.logger.Debug("POST form failed, falling back to GET", "endpoint", endpoint, "error", err)
|
|
}
|
|
}
|
|
|
|
raw, err := c.fetchRaw(endpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", endpoint, err)
|
|
}
|
|
return c.parsePayload(endpoint, raw)
|
|
}
|
|
|
|
// fetchViaPost issues the authenticated POST the router's own UI uses.
|
|
func (c *Client) fetchViaPost(endpoint string) (*StatusData, error) {
|
|
key, ccm := c.statusKey, c.statusCCM
|
|
if c.sessionCCM != nil {
|
|
key, ccm = c.sessionKey, c.sessionCCM
|
|
}
|
|
|
|
ct, err := ccm.Encrypt(key[:8], []byte(url.Values{"httoken": {c.httoken}}.Encode()), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt request: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
q := url.Values{
|
|
"_time": {strconv.FormatInt(now.UnixMilli(), 10)},
|
|
"_rand": {strconv.FormatInt(now.UnixNano()%1000, 10)},
|
|
"_lang": {"DE"},
|
|
}
|
|
|
|
raw, err := c.postBody(endpoint+"?"+q.Encode(), hex.EncodeToString(ct))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.parsePayload(endpoint, raw)
|
|
}
|
|
|
|
// parsePayload turns a raw reply into StatusData, decrypting when needed.
|
|
func (c *Client) parsePayload(endpoint string, raw []byte) (*StatusData, error) {
|
|
var entries []VarEntry
|
|
if err := json.Unmarshal(raw, &entries); err == nil {
|
|
return NewStatusData(entries), nil
|
|
}
|
|
|
|
plaintext, err := c.decryptStatusResponse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt %s: %w", endpoint, err)
|
|
}
|
|
|
|
if err := json.Unmarshal(plaintext, &entries); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", endpoint, err)
|
|
}
|
|
|
|
return NewStatusData(entries), nil
|
|
}
|
|
|
|
// GetDSL fetches DSL-specific data (may require login).
|
|
func (c *Client) GetDSL() (*StatusData, error) {
|
|
return c.GetEndpoint("data/DSL.json")
|
|
}
|
|
|
|
// GetIPData fetches IP/WAN data (requires login).
|
|
func (c *Client) GetIPData() (*StatusData, error) {
|
|
return c.GetEndpoint("data/IPData.json")
|
|
}
|
|
|
|
// GetModuleInfo fetches module/firmware info.
|
|
func (c *Client) GetModuleInfo() (*StatusData, error) {
|
|
return c.GetEndpoint("data/Modules.json")
|
|
}
|
|
|
|
// GetDevices fetches the list of connected devices (requires login).
|
|
func (c *Client) GetDevices() (*StatusData, error) {
|
|
return c.GetEndpoint("data/DeviceList.json")
|
|
}
|
|
|
|
// Logout ends the session.
|
|
func (c *Client) Logout() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if !c.loggedIn {
|
|
return nil
|
|
}
|
|
|
|
token := c.csrfToken
|
|
if token == "" {
|
|
token = "nulltoken"
|
|
}
|
|
|
|
_, err := c.postForm("data/Login.json", url.Values{
|
|
"csrf_token": {token},
|
|
"logout": {"byby"},
|
|
})
|
|
|
|
c.loggedIn = false
|
|
c.csrfToken = ""
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("logout: %w", err)
|
|
}
|
|
|
|
c.logger.Debug("logged out")
|
|
return nil
|
|
}
|
|
|
|
// IsLoggedIn returns whether the client has an active session.
|
|
func (c *Client) IsLoggedIn() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.loggedIn
|
|
}
|
|
|
|
// AllEndpoints is a list of known Speedport Smart 4 data endpoints.
|
|
// endpointPages maps a data endpoint to the UI page that requests it. The
|
|
// router hands out a fresh _tn per page, and a token from a different page is
|
|
// accepted but yields an empty payload — so the page must be loaded first.
|
|
// NOTE: data/PortuwMain.json and data/LAN.json answer with only {status: ok}
|
|
// for this client, although the UI shows eleven port forwards and an active
|
|
// DHCP range. Tried without success: plain GET, GET with _tn from the overview
|
|
// page, GET with a page-specific _tn plus matching Referer, the UI's exact
|
|
// query string (duplicated _time/_rand, _tn last), and the encrypted POST form.
|
|
// All other authenticated endpoints work with the plain GET below, so the
|
|
// session itself is fine. Whatever these two additionally require has not been
|
|
// identified; their contents were taken from the web UI by hand.
|
|
var endpointPages = map[string]string{
|
|
"data/PortuwMain.json": "html/content/internet/portforwarding.html",
|
|
"data/LAN.json": "html/content/network/dhcp.html",
|
|
"data/DeviceList.json": "html/content/network/devices.html",
|
|
}
|
|
|
|
// tokenFor returns the _tn belonging to the page that owns endpoint, falling
|
|
// back to the session-wide token from the overview page.
|
|
func (c *Client) tokenFor(endpoint string) (token, referer string) {
|
|
page, ok := endpointPages[endpoint]
|
|
if !ok {
|
|
return c.httoken, c.baseURL + "/html/content/overview/index.html"
|
|
}
|
|
referer = c.baseURL + "/" + page + "?lang=de"
|
|
body, err := c.fetchPlain(page)
|
|
if err != nil {
|
|
c.logger.Debug("page token: fetch failed", "page", page, "error", err)
|
|
return c.httoken, referer
|
|
}
|
|
if m := httokenRe.FindSubmatch(body); m != nil {
|
|
return string(m[1]), referer
|
|
}
|
|
return c.httoken, referer
|
|
}
|
|
|
|
var AllEndpoints = []string{
|
|
"data/Status.json",
|
|
"data/DSL.json",
|
|
"data/IPData.json",
|
|
"data/Modules.json",
|
|
"data/DeviceList.json",
|
|
"data/InetIP.json",
|
|
"data/Connect.json",
|
|
"data/WLANBasic.json",
|
|
"data/Overview.json",
|
|
"data/SystemMessages.json",
|
|
// Found by watching the router's own UI: port forwarding and the LAN/DHCP
|
|
// page request these two.
|
|
"data/PortuwMain.json",
|
|
"data/LAN.json",
|
|
}
|