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}