main
44ceea7 ยท 1 month ago 7 commits
  1// Package tplink implements the TP-Link router web-UI request/response
  2// protocol: the keys/auth bootstrap, the encrypted login, and encrypted
  3// authenticated requests with transparent re-login on session timeout.
  4package main
  5
  6import (
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"net/http"
 11	"net/http/cookiejar"
 12	"net/url"
 13	"os"
 14	"strings"
 15)
 16
 17// debug enables verbose request/response logging via TPLINK_DEBUG=1.
 18var debug = os.Getenv("TPLINK_DEBUG") == "1"
 19
 20// Config holds connection + credential settings.
 21type Config struct {
 22	Host     string // e.g. "192.168.0.1"
 23	Username string // login hash uses username+password; usually "admin"
 24	Password string
 25	RGSec    bool // true -> SHA-256 credential hash instead of MD5
 26	Force    bool // true -> always send confirm=true to evict an existing session
 27}
 28
 29// Client drives the protocol and owns the negotiated session state.
 30type Client struct {
 31	cfg  Config
 32	http *http.Client
 33
 34	// session state, set by login() and reused for every request
 35	stok    string
 36	aesKey  string
 37	aesIV   string
 38	pwdHash string
 39	signKey *PublicKey
 40	seq     int
 41}
 42
 43// apiResponse is the envelope every router endpoint returns.
 44type apiResponse struct {
 45	Success   bool            `json:"success"`
 46	ErrorCode string          `json:"errorcode"`
 47	Data      json.RawMessage `json:"data"`
 48}
 49
 50// New creates a client. A cookie jar is REQUIRED: the router sets a session
 51// cookie during keys/auth that must be echoed back on login (mirrors the
 52// browser's XMLHttpRequest, which carries cookies automatically). Without it,
 53// login returns an empty body.
 54func NewClient(cfg Config) *Client {
 55	if cfg.Username == "" {
 56		cfg.Username = "admin"
 57	}
 58	jar, _ := cookiejar.New(nil)
 59	return &Client{cfg: cfg, http: &http.Client{Jar: jar}}
 60}
 61
 62func (c *Client) base() string {
 63	return fmt.Sprintf("http://%s/cgi-bin/luci/;stok=", c.cfg.Host)
 64}
 65
 66// signDataBody builds "sign=<sign>&data=<data>" in that exact order, which the
 67// router requires (see postForm).
 68func signDataBody(sign, data string) string {
 69	return "sign=" + url.QueryEscape(sign) + "&data=" + url.QueryEscape(data)
 70}
 71
 72// postForm sends a POST with x-www-form-urlencoded body and decodes the
 73// JSON envelope. stok is spliced into the path as the JS does.
 74//
 75// body must be a pre-encoded form string. Field ORDER is significant: the
 76// router rejects login/request bodies with HTTP 403 unless "sign" precedes
 77// "data" (url.Values.Encode would sort them the wrong way round).
 78func (c *Client) postForm(stok, path, body string) (*apiResponse, error) {
 79	endpoint := c.base() + stok + path
 80	req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
 81	if err != nil {
 82		return nil, err
 83	}
 84	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 85	req.Header.Set("Referer", fmt.Sprintf("http://%s/", c.cfg.Host))
 86	req.Header.Set("X-Requested-With", "XMLHttpRequest")
 87
 88	resp, err := c.http.Do(req)
 89	if err != nil {
 90		return nil, err
 91	}
 92	defer resp.Body.Close()
 93	respBody, err := io.ReadAll(resp.Body)
 94	if err != nil {
 95		return nil, err
 96	}
 97	if debug {
 98		fmt.Fprintf(os.Stderr, "[debug] POST %s -> %d (%d bytes): %.300s\n",
 99			path, resp.StatusCode, len(respBody), respBody)
100	}
101	var out apiResponse
102	if err := json.Unmarshal(respBody, &out); err != nil {
103		return nil, fmt.Errorf("decode %s: HTTP %d, %w (body: %.200s)", path, resp.StatusCode, err, respBody)
104	}
105	return &out, nil
106}
107
108// getKeys fetches the 1024-bit password RSA key (POST /login?form=keys).
109func (c *Client) getKeys() (*PublicKey, error) {
110	resp, err := c.postForm("", "/login?form=keys", "operation=read")
111	if err != nil {
112		return nil, err
113	}
114	var d struct {
115		Password []string `json:"password"`
116	}
117	if err := json.Unmarshal(resp.Data, &d); err != nil || len(d.Password) != 2 {
118		return nil, fmt.Errorf("unexpected form=keys data: %s", resp.Data)
119	}
120	return ParsePublicKey(d.Password[0], d.Password[1])
121}
122
123// getAuth fetches the 512-bit signature RSA key and sequence number
124// (POST /login?form=auth).
125func (c *Client) getAuth() (*PublicKey, int, error) {
126	resp, err := c.postForm("", "/login?form=auth", "operation=read")
127	if err != nil {
128		return nil, 0, err
129	}
130	var d struct {
131		Key []string `json:"key"`
132		Seq int      `json:"seq"`
133	}
134	if err := json.Unmarshal(resp.Data, &d); err != nil || len(d.Key) != 2 {
135		return nil, 0, fmt.Errorf("unexpected form=auth data: %s", resp.Data)
136	}
137	pk, err := ParsePublicKey(d.Key[0], d.Key[1])
138	return pk, d.Seq, err
139}
140
141// Login runs the full handshake and stores the session state. If the router
142// reports a session conflict it automatically retries once with confirm=true
143// (the "Login here" / force-takeover path), unless cfg.Force already forced it.
144func (c *Client) Login() error {
145	err := c.attemptLogin(c.cfg.Force)
146	var ce *conflictError
147	if errorsAsConflict(err, &ce) && !c.cfg.Force {
148		if debug {
149			fmt.Fprintf(os.Stderr, "[debug] session conflict (%s) โ€” retrying with confirm=true\n", ce.code)
150		}
151		return c.attemptLogin(true)
152	}
153	return err
154}
155
156// attemptLogin performs one login. When force is true, the inner body carries
157// confirm=true, instructing the router to terminate any existing session.
158func (c *Client) attemptLogin(force bool) error {
159	pwdKey, err := c.getKeys()
160	if err != nil {
161		return fmt.Errorf("keys: %w", err)
162	}
163	signKey, seq, err := c.getAuth()
164	if err != nil {
165		return fmt.Errorf("auth: %w", err)
166	}
167
168	aesKey, err := RandDigits(16)
169	if err != nil {
170		return err
171	}
172	aesIV, err := RandDigits(16)
173	if err != nil {
174		return err
175	}
176
177	encPwd, err := pwdKey.EncryptChunked(c.cfg.Password)
178	if err != nil {
179		return fmt.Errorf("rsa password: %w", err)
180	}
181	body := "operation=login&password=" + encPwd
182	if force {
183		body = "operation=login&confirm=true&password=" + encPwd
184	}
185	data, err := AESEncryptB64(body, aesKey, aesIV)
186	if err != nil {
187		return err
188	}
189	pwdHash := HashCredential(c.cfg.Username, c.cfg.Password, c.cfg.RGSec)
190
191	sigText := fmt.Sprintf("k=%s&i=%s&h=%s&s=%d", aesKey, aesIV, pwdHash, seq+len(data))
192	sign, err := signKey.EncryptChunked(sigText)
193	if err != nil {
194		return fmt.Errorf("rsa sign: %w", err)
195	}
196
197	resp, err := c.postForm("", "/login?form=login", signDataBody(sign, data))
198	if err != nil {
199		return err
200	}
201
202	// The login response is AES-encrypted under our session key; decrypt it.
203	plain, err := decryptPayload(resp.Data, aesKey, aesIV)
204	if err != nil {
205		return fmt.Errorf("login: %w", err)
206	}
207	var parsed struct {
208		Success   bool   `json:"success"`
209		ErrorCode string `json:"errorcode"`
210		Data      struct {
211			Stok      string `json:"stok"`
212			ErrorCode string `json:"errorcode"`
213		} `json:"data"`
214	}
215	_ = json.Unmarshal(plain, &parsed)
216
217	if parsed.Data.Stok != "" {
218		c.stok, c.aesKey, c.aesIV = parsed.Data.Stok, aesKey, aesIV
219		c.pwdHash, c.signKey, c.seq = pwdHash, signKey, seq
220		return nil
221	}
222	if isConflictCode(parsed.ErrorCode) || isConflictCode(parsed.Data.ErrorCode) {
223		return &conflictError{code: firstNonEmpty(parsed.ErrorCode, parsed.Data.ErrorCode)}
224	}
225	return fmt.Errorf("login failed: errorcode=%q (%s)", parsed.ErrorCode, plain)
226}
227
228// decryptPayload returns the plaintext JSON of an envelope's data field, which
229// the router sends as an AES-encrypted base64 string under the session key.
230func decryptPayload(raw json.RawMessage, aesKey, aesIV string) ([]byte, error) {
231	var asString string
232	if json.Unmarshal(raw, &asString) == nil && asString != "" {
233		dec, err := AESDecryptB64(asString, aesKey, aesIV)
234		if err != nil {
235			return nil, fmt.Errorf("decrypt payload: %w", err)
236		}
237		return []byte(dec), nil
238	}
239	return []byte(raw), nil // already plaintext JSON
240}
241
242// conflictError signals that login was refused because another admin session
243// is active ("user conflict"); the caller may retry with confirm=true.
244type conflictError struct{ code string }
245
246func (e *conflictError) Error() string { return "session conflict: " + e.code }
247
248func errorsAsConflict(err error, target **conflictError) bool {
249	for err != nil {
250		if ce, ok := err.(*conflictError); ok {
251			*target = ce
252			return true
253		}
254		u, ok := err.(interface{ Unwrap() error })
255		if !ok {
256			return false
257		}
258		err = u.Unwrap()
259	}
260	return false
261}
262
263func isConflictCode(code string) bool {
264	switch strings.ToLower(code) {
265	case "user conflict", "-5212", "-5002conflict":
266		return true
267	}
268	return strings.Contains(strings.ToLower(code), "conflict")
269}
270
271func firstNonEmpty(vals ...string) string {
272	for _, v := range vals {
273		if v != "" {
274			return v
275		}
276	}
277	return ""
278}
279
280// Request makes an authenticated, AES-encrypted call and returns the decrypted
281// JSON. On a session-timeout style error it logs in again once and retries.
282func (c *Client) Request(path string, fields url.Values) (json.RawMessage, error) {
283	if c.stok == "" {
284		if err := c.Login(); err != nil {
285			return nil, err
286		}
287	}
288	out, err := c.requestOnce(path, fields)
289	if err == nil {
290		return out, nil
291	}
292	if !isSessionError(err) {
293		return nil, err
294	}
295	// session expired / evicted -> fresh handshake, retry once
296	if err := c.Login(); err != nil {
297		return nil, fmt.Errorf("relogin after %v: %w", err, err)
298	}
299	return c.requestOnce(path, fields)
300}
301
302func (c *Client) requestOnce(path string, fields url.Values) (json.RawMessage, error) {
303	body := fields.Encode()
304	data, err := AESEncryptB64(body, c.aesKey, c.aesIV)
305	if err != nil {
306		return nil, err
307	}
308	// post-login signature carries only hash + seq (no AES key)
309	sigText := fmt.Sprintf("h=%s&s=%d", c.pwdHash, c.seq+len(data))
310	sign, err := c.signKey.EncryptChunked(sigText)
311	if err != nil {
312		return nil, err
313	}
314
315	resp, err := c.postForm(c.stok, path, signDataBody(sign, data))
316	if err != nil {
317		return nil, err
318	}
319	if !resp.Success && resp.ErrorCode != "" {
320		return nil, &sessionError{code: resp.ErrorCode}
321	}
322
323	// response data is AES ciphertext under our session key
324	var asString string
325	if json.Unmarshal(resp.Data, &asString) == nil && asString != "" {
326		dec, err := AESDecryptB64(asString, c.aesKey, c.aesIV)
327		if err != nil {
328			return nil, fmt.Errorf("decrypt response: %w", err)
329		}
330		return json.RawMessage(dec), nil
331	}
332	return resp.Data, nil
333}
334
335// Stok exposes the current session token (for logging/debug).
336func (c *Client) Stok() string { return c.stok }
337
338type sessionError struct{ code string }
339
340func (e *sessionError) Error() string { return "session error: " + e.code }
341
342func isSessionError(err error) bool {
343	var se *sessionError
344	if !errorsAs(err, &se) {
345		return false
346	}
347	switch se.code {
348	case "timeout", "user conflict", "permission denied", "exceeded max", "-40101":
349		return true
350	}
351	return false
352}
353
354// errorsAs is a tiny shim to avoid importing errors just for As in one place.
355func errorsAs(err error, target **sessionError) bool {
356	for err != nil {
357		if se, ok := err.(*sessionError); ok {
358			*target = se
359			return true
360		}
361		type unwrapper interface{ Unwrap() error }
362		u, ok := err.(unwrapper)
363		if !ok {
364			return false
365		}
366		err = u.Unwrap()
367	}
368	return false
369}