Commit 933033e

Ansari <ping@ansari.wtf>
2026-07-17 17:11:05
new tplink network scan tool
1 parent 93379db
tplinkctl/client.go
@@ -0,0 +1,369 @@
+// Package tplink implements the TP-Link router web-UI request/response
+// protocol: the keys/auth bootstrap, the encrypted login, and encrypted
+// authenticated requests with transparent re-login on session timeout.
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"os"
+	"strings"
+)
+
+// debug enables verbose request/response logging via TPLINK_DEBUG=1.
+var debug = os.Getenv("TPLINK_DEBUG") == "1"
+
+// Config holds connection + credential settings.
+type Config struct {
+	Host     string // e.g. "192.168.0.1"
+	Username string // login hash uses username+password; usually "admin"
+	Password string
+	RGSec    bool // true -> SHA-256 credential hash instead of MD5
+	Force    bool // true -> always send confirm=true to evict an existing session
+}
+
+// Client drives the protocol and owns the negotiated session state.
+type Client struct {
+	cfg  Config
+	http *http.Client
+
+	// session state, set by login() and reused for every request
+	stok    string
+	aesKey  string
+	aesIV   string
+	pwdHash string
+	signKey *PublicKey
+	seq     int
+}
+
+// apiResponse is the envelope every router endpoint returns.
+type apiResponse struct {
+	Success   bool            `json:"success"`
+	ErrorCode string          `json:"errorcode"`
+	Data      json.RawMessage `json:"data"`
+}
+
+// New creates a client. A cookie jar is REQUIRED: the router sets a session
+// cookie during keys/auth that must be echoed back on login (mirrors the
+// browser's XMLHttpRequest, which carries cookies automatically). Without it,
+// login returns an empty body.
+func NewClient(cfg Config) *Client {
+	if cfg.Username == "" {
+		cfg.Username = "admin"
+	}
+	jar, _ := cookiejar.New(nil)
+	return &Client{cfg: cfg, http: &http.Client{Jar: jar}}
+}
+
+func (c *Client) base() string {
+	return fmt.Sprintf("http://%s/cgi-bin/luci/;stok=", c.cfg.Host)
+}
+
+// signDataBody builds "sign=<sign>&data=<data>" in that exact order, which the
+// router requires (see postForm).
+func signDataBody(sign, data string) string {
+	return "sign=" + url.QueryEscape(sign) + "&data=" + url.QueryEscape(data)
+}
+
+// postForm sends a POST with x-www-form-urlencoded body and decodes the
+// JSON envelope. stok is spliced into the path as the JS does.
+//
+// body must be a pre-encoded form string. Field ORDER is significant: the
+// router rejects login/request bodies with HTTP 403 unless "sign" precedes
+// "data" (url.Values.Encode would sort them the wrong way round).
+func (c *Client) postForm(stok, path, body string) (*apiResponse, error) {
+	endpoint := c.base() + stok + path
+	req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Referer", fmt.Sprintf("http://%s/", c.cfg.Host))
+	req.Header.Set("X-Requested-With", "XMLHttpRequest")
+
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	if debug {
+		fmt.Fprintf(os.Stderr, "[debug] POST %s -> %d (%d bytes): %.300s\n",
+			path, resp.StatusCode, len(respBody), respBody)
+	}
+	var out apiResponse
+	if err := json.Unmarshal(respBody, &out); err != nil {
+		return nil, fmt.Errorf("decode %s: HTTP %d, %w (body: %.200s)", path, resp.StatusCode, err, respBody)
+	}
+	return &out, nil
+}
+
+// getKeys fetches the 1024-bit password RSA key (POST /login?form=keys).
+func (c *Client) getKeys() (*PublicKey, error) {
+	resp, err := c.postForm("", "/login?form=keys", "operation=read")
+	if err != nil {
+		return nil, err
+	}
+	var d struct {
+		Password []string `json:"password"`
+	}
+	if err := json.Unmarshal(resp.Data, &d); err != nil || len(d.Password) != 2 {
+		return nil, fmt.Errorf("unexpected form=keys data: %s", resp.Data)
+	}
+	return ParsePublicKey(d.Password[0], d.Password[1])
+}
+
+// getAuth fetches the 512-bit signature RSA key and sequence number
+// (POST /login?form=auth).
+func (c *Client) getAuth() (*PublicKey, int, error) {
+	resp, err := c.postForm("", "/login?form=auth", "operation=read")
+	if err != nil {
+		return nil, 0, err
+	}
+	var d struct {
+		Key []string `json:"key"`
+		Seq int      `json:"seq"`
+	}
+	if err := json.Unmarshal(resp.Data, &d); err != nil || len(d.Key) != 2 {
+		return nil, 0, fmt.Errorf("unexpected form=auth data: %s", resp.Data)
+	}
+	pk, err := ParsePublicKey(d.Key[0], d.Key[1])
+	return pk, d.Seq, err
+}
+
+// Login runs the full handshake and stores the session state. If the router
+// reports a session conflict it automatically retries once with confirm=true
+// (the "Login here" / force-takeover path), unless cfg.Force already forced it.
+func (c *Client) Login() error {
+	err := c.attemptLogin(c.cfg.Force)
+	var ce *conflictError
+	if errorsAsConflict(err, &ce) && !c.cfg.Force {
+		if debug {
+			fmt.Fprintf(os.Stderr, "[debug] session conflict (%s) โ€” retrying with confirm=true\n", ce.code)
+		}
+		return c.attemptLogin(true)
+	}
+	return err
+}
+
+// attemptLogin performs one login. When force is true, the inner body carries
+// confirm=true, instructing the router to terminate any existing session.
+func (c *Client) attemptLogin(force bool) error {
+	pwdKey, err := c.getKeys()
+	if err != nil {
+		return fmt.Errorf("keys: %w", err)
+	}
+	signKey, seq, err := c.getAuth()
+	if err != nil {
+		return fmt.Errorf("auth: %w", err)
+	}
+
+	aesKey, err := RandDigits(16)
+	if err != nil {
+		return err
+	}
+	aesIV, err := RandDigits(16)
+	if err != nil {
+		return err
+	}
+
+	encPwd, err := pwdKey.EncryptChunked(c.cfg.Password)
+	if err != nil {
+		return fmt.Errorf("rsa password: %w", err)
+	}
+	body := "operation=login&password=" + encPwd
+	if force {
+		body = "operation=login&confirm=true&password=" + encPwd
+	}
+	data, err := AESEncryptB64(body, aesKey, aesIV)
+	if err != nil {
+		return err
+	}
+	pwdHash := HashCredential(c.cfg.Username, c.cfg.Password, c.cfg.RGSec)
+
+	sigText := fmt.Sprintf("k=%s&i=%s&h=%s&s=%d", aesKey, aesIV, pwdHash, seq+len(data))
+	sign, err := signKey.EncryptChunked(sigText)
+	if err != nil {
+		return fmt.Errorf("rsa sign: %w", err)
+	}
+
+	resp, err := c.postForm("", "/login?form=login", signDataBody(sign, data))
+	if err != nil {
+		return err
+	}
+
+	// The login response is AES-encrypted under our session key; decrypt it.
+	plain, err := decryptPayload(resp.Data, aesKey, aesIV)
+	if err != nil {
+		return fmt.Errorf("login: %w", err)
+	}
+	var parsed struct {
+		Success   bool   `json:"success"`
+		ErrorCode string `json:"errorcode"`
+		Data      struct {
+			Stok      string `json:"stok"`
+			ErrorCode string `json:"errorcode"`
+		} `json:"data"`
+	}
+	_ = json.Unmarshal(plain, &parsed)
+
+	if parsed.Data.Stok != "" {
+		c.stok, c.aesKey, c.aesIV = parsed.Data.Stok, aesKey, aesIV
+		c.pwdHash, c.signKey, c.seq = pwdHash, signKey, seq
+		return nil
+	}
+	if isConflictCode(parsed.ErrorCode) || isConflictCode(parsed.Data.ErrorCode) {
+		return &conflictError{code: firstNonEmpty(parsed.ErrorCode, parsed.Data.ErrorCode)}
+	}
+	return fmt.Errorf("login failed: errorcode=%q (%s)", parsed.ErrorCode, plain)
+}
+
+// decryptPayload returns the plaintext JSON of an envelope's data field, which
+// the router sends as an AES-encrypted base64 string under the session key.
+func decryptPayload(raw json.RawMessage, aesKey, aesIV string) ([]byte, error) {
+	var asString string
+	if json.Unmarshal(raw, &asString) == nil && asString != "" {
+		dec, err := AESDecryptB64(asString, aesKey, aesIV)
+		if err != nil {
+			return nil, fmt.Errorf("decrypt payload: %w", err)
+		}
+		return []byte(dec), nil
+	}
+	return []byte(raw), nil // already plaintext JSON
+}
+
+// conflictError signals that login was refused because another admin session
+// is active ("user conflict"); the caller may retry with confirm=true.
+type conflictError struct{ code string }
+
+func (e *conflictError) Error() string { return "session conflict: " + e.code }
+
+func errorsAsConflict(err error, target **conflictError) bool {
+	for err != nil {
+		if ce, ok := err.(*conflictError); ok {
+			*target = ce
+			return true
+		}
+		u, ok := err.(interface{ Unwrap() error })
+		if !ok {
+			return false
+		}
+		err = u.Unwrap()
+	}
+	return false
+}
+
+func isConflictCode(code string) bool {
+	switch strings.ToLower(code) {
+	case "user conflict", "-5212", "-5002conflict":
+		return true
+	}
+	return strings.Contains(strings.ToLower(code), "conflict")
+}
+
+func firstNonEmpty(vals ...string) string {
+	for _, v := range vals {
+		if v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// Request makes an authenticated, AES-encrypted call and returns the decrypted
+// JSON. On a session-timeout style error it logs in again once and retries.
+func (c *Client) Request(path string, fields url.Values) (json.RawMessage, error) {
+	if c.stok == "" {
+		if err := c.Login(); err != nil {
+			return nil, err
+		}
+	}
+	out, err := c.requestOnce(path, fields)
+	if err == nil {
+		return out, nil
+	}
+	if !isSessionError(err) {
+		return nil, err
+	}
+	// session expired / evicted -> fresh handshake, retry once
+	if err := c.Login(); err != nil {
+		return nil, fmt.Errorf("relogin after %v: %w", err, err)
+	}
+	return c.requestOnce(path, fields)
+}
+
+func (c *Client) requestOnce(path string, fields url.Values) (json.RawMessage, error) {
+	body := fields.Encode()
+	data, err := AESEncryptB64(body, c.aesKey, c.aesIV)
+	if err != nil {
+		return nil, err
+	}
+	// post-login signature carries only hash + seq (no AES key)
+	sigText := fmt.Sprintf("h=%s&s=%d", c.pwdHash, c.seq+len(data))
+	sign, err := c.signKey.EncryptChunked(sigText)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.postForm(c.stok, path, signDataBody(sign, data))
+	if err != nil {
+		return nil, err
+	}
+	if !resp.Success && resp.ErrorCode != "" {
+		return nil, &sessionError{code: resp.ErrorCode}
+	}
+
+	// response data is AES ciphertext under our session key
+	var asString string
+	if json.Unmarshal(resp.Data, &asString) == nil && asString != "" {
+		dec, err := AESDecryptB64(asString, c.aesKey, c.aesIV)
+		if err != nil {
+			return nil, fmt.Errorf("decrypt response: %w", err)
+		}
+		return json.RawMessage(dec), nil
+	}
+	return resp.Data, nil
+}
+
+// Stok exposes the current session token (for logging/debug).
+func (c *Client) Stok() string { return c.stok }
+
+type sessionError struct{ code string }
+
+func (e *sessionError) Error() string { return "session error: " + e.code }
+
+func isSessionError(err error) bool {
+	var se *sessionError
+	if !errorsAs(err, &se) {
+		return false
+	}
+	switch se.code {
+	case "timeout", "user conflict", "permission denied", "exceeded max", "-40101":
+		return true
+	}
+	return false
+}
+
+// errorsAs is a tiny shim to avoid importing errors just for As in one place.
+func errorsAs(err error, target **sessionError) bool {
+	for err != nil {
+		if se, ok := err.(*sessionError); ok {
+			*target = se
+			return true
+		}
+		type unwrapper interface{ Unwrap() error }
+		u, ok := err.(unwrapper)
+		if !ok {
+			return false
+		}
+		err = u.Unwrap()
+	}
+	return false
+}
tplinkctl/crypto.go
@@ -0,0 +1,158 @@
+// Package crypto implements the exact primitives the TP-Link router web UI
+// uses (see libs/encrypt.js and libs/tpEncrypt.js):
+//
+//   - AES-128-CBC / PKCS7, where the key and IV are the UTF-8 bytes of
+//     16-character ASCII strings (CryptoJS.enc.Utf8.parse semantics).
+//   - RSA PKCS#1 v1.5 "type 2" encryption padding, with long inputs split
+//     into fixed-size chunks and the hex blocks concatenated (the JS
+//     getSignature() chunking).
+//   - MD5 / SHA-256 credential hashing.
+package main 
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/md5"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"math/big"
+	"strings"
+)
+
+// PublicKey is an RSA public key parsed from the router's (n_hex, e_hex) pair.
+type PublicKey struct {
+	key      *rsa.PublicKey
+	sizeHex  int // modulus size in hex chars (== bytes*2)
+	maxChunk int // max plaintext bytes per block (k - 11 for PKCS#1 v1.5)
+}
+
+// ParsePublicKey builds an RSA public key from hex modulus and exponent,
+// exactly as encrypt.js setPublic(n, e) does.
+func ParsePublicKey(nHex, eHex string) (*PublicKey, error) {
+	n, ok := new(big.Int).SetString(nHex, 16)
+	if !ok {
+		return nil, fmt.Errorf("invalid RSA modulus hex")
+	}
+	e, ok := new(big.Int).SetString(eHex, 16)
+	if !ok {
+		return nil, fmt.Errorf("invalid RSA exponent hex")
+	}
+	k := (n.BitLen() + 7) / 8
+	return &PublicKey{
+		key:      &rsa.PublicKey{N: n, E: int(e.Int64())},
+		sizeHex:  k * 2,
+		maxChunk: k - 11,
+	}, nil
+}
+
+// EncryptChunked RSA-encrypts msg in maxChunk-sized pieces, concatenating the
+// hex of each block (left-padded to the modulus byte length). This matches
+// both su.encrypt (single password block) and getSignature (53-byte chunks).
+func (p *PublicKey) EncryptChunked(msg string) (string, error) {
+	raw := []byte(msg)
+	var b strings.Builder
+	for i := 0; i < len(raw); i += p.maxChunk {
+		end := min(i + p.maxChunk, len(raw))
+		ct, err := rsa.EncryptPKCS1v15(rand.Reader, p.key, raw[i:end])
+		if err != nil {
+			return "", err
+		}
+		h := hex.EncodeToString(ct)
+		// left-pad to fixed block width (JS zero-pads short blocks)
+		if len(h) < p.sizeHex {
+			h = strings.Repeat("0", p.sizeHex-len(h)) + h
+		}
+		b.WriteString(h)
+	}
+	return b.String(), nil
+}
+
+// AESEncryptB64 performs AES-128-CBC + PKCS7 and returns base64, matching
+// CryptoJS.AES.encrypt(plaintext, Utf8(key), {iv: Utf8(iv)}).toString().
+// key and iv must each be 16 ASCII characters.
+func AESEncryptB64(plaintext, key, iv string) (string, error) {
+	block, err := aes.NewCipher([]byte(key))
+	if err != nil {
+		return "", err
+	}
+	if len(iv) != aes.BlockSize {
+		return "", fmt.Errorf("iv must be 16 bytes, got %d", len(iv))
+	}
+	data := pkcs7Pad([]byte(plaintext), aes.BlockSize)
+	out := make([]byte, len(data))
+	cipher.NewCBCEncrypter(block, []byte(iv)).CryptBlocks(out, data)
+	return base64.StdEncoding.EncodeToString(out), nil
+}
+
+// AESDecryptB64 reverses AESEncryptB64 โ€” used to decrypt router responses.
+func AESDecryptB64(ciphertextB64, key, iv string) (string, error) {
+	block, err := aes.NewCipher([]byte(key))
+	if err != nil {
+		return "", err
+	}
+	data, err := base64.StdEncoding.DecodeString(ciphertextB64)
+	if err != nil {
+		return "", err
+	}
+	if len(data) == 0 || len(data)%aes.BlockSize != 0 {
+		return "", fmt.Errorf("ciphertext not a multiple of block size")
+	}
+	out := make([]byte, len(data))
+	cipher.NewCBCDecrypter(block, []byte(iv)).CryptBlocks(out, data)
+	unpadded, err := pkcs7Unpad(out)
+	if err != nil {
+		return "", err
+	}
+	return string(unpadded), nil
+}
+
+// HashCredential computes MD5(username+password), or SHA-256 if isRGSec
+// (the IS_RG_SEC branch in tpEncrypt.js setHash).
+func HashCredential(username, password string, isRGSec bool) string {
+	data := []byte(username + password)
+	if isRGSec {
+		sum := sha256.Sum256(data)
+		return hex.EncodeToString(sum[:])
+	}
+	sum := md5.Sum(data)
+	return hex.EncodeToString(sum[:])
+}
+
+// RandDigits returns n random ASCII digits, matching
+// tpEncrypt.js generateRandomIntString(n).
+func RandDigits(n int) (string, error) {
+	buf := make([]byte, n)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	out := make([]byte, n)
+	for i, b := range buf {
+		out[i] = '0' + (b % 10)
+	}
+	return string(out), nil
+}
+
+func pkcs7Pad(data []byte, blockSize int) []byte {
+	pad := blockSize - len(data)%blockSize
+	out := make([]byte, len(data)+pad)
+	copy(out, data)
+	for i := len(data); i < len(out); i++ {
+		out[i] = byte(pad)
+	}
+	return out
+}
+
+func pkcs7Unpad(data []byte) ([]byte, error) {
+	if len(data) == 0 {
+		return nil, fmt.Errorf("empty plaintext")
+	}
+	pad := int(data[len(data)-1])
+	if pad == 0 || pad > len(data) {
+		return nil, fmt.Errorf("invalid PKCS7 padding")
+	}
+	return data[:len(data)-pad], nil
+}
tplinkctl/go.mod
@@ -0,0 +1,7 @@
+module tplinkctl
+
+go 1.26.5
+
+require golang.org/x/term v0.45.0
+
+require golang.org/x/sys v0.47.0 // indirect
tplinkctl/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
tplinkctl/main.go
@@ -0,0 +1,111 @@
+// Command tplinkctl logs in to a TP-Link router and runs encrypted API calls.
+//
+// Usage:
+//
+//	tplinkctl [flags] <path> [key=value ...]
+//
+// Examples:
+//
+//	export TPLINK_PASSWORD=secret
+//	tplinkctl /admin/status?form=all operation=read
+//	tplinkctl -host 192.168.0.1 /admin/wireless?form=guest operation=read
+//
+// The password is read from -password or the TPLINK_PASSWORD env var so it is
+// never baked into the binary or shell history (use a leading space / env).
+
+//go:debug rsa1024min=0
+
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"net/url"
+	"os"
+	"strings"
+	"golang.org/x/term"
+)
+
+func main() {
+	host := flag.String("host", env("TPLINK_HOST", "192.168.0.1"), "router host/IP")
+	user := flag.String("user", env("TPLINK_USERNAME", "admin"), "login username")
+	pass := flag.String("password", os.Getenv("TPLINK_PASSWORD"), "admin password (or set TPLINK_PASSWORD)")
+	rgSec := flag.Bool("rgsec", os.Getenv("TPLINK_RG_SEC") == "1", "use SHA-256 credential hash (RG-SEC regions)")
+	force := flag.Bool("force", os.Getenv("TPLINK_FORCE") == "1", "force login, evicting any existing session (confirm=true)")
+	flag.Parse()
+
+	if *pass == "" {
+		fmt.Fprint(os.Stderr, "Password: ")
+		password, err := term.ReadPassword(int(os.Stdin.Fd()))
+			if err != nil {
+			fmt.Println("Error:", err)
+			os.Exit(1)  	
+		}
+		*pass = string(password)
+	}
+
+	args := flag.Args()
+	if len(args) == 0 {
+		// no path -> just prove login works and print the token
+		args = []string{""}
+	}
+	path := args[0]
+
+	fields := url.Values{}
+	for _, kv := range args[1:] {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok {
+			fmt.Fprintf(os.Stderr, "error: bad key=value %q\n", kv)
+			os.Exit(2)
+		}
+		fields.Set(k, v)
+	}
+	if path != "" && len(fields) == 0 {
+		fields.Set("operation", "read") // sensible default for reads
+	}
+
+	client := NewClient(Config{
+		Host:     *host,
+		Username: *user,
+		Password: *pass,
+		RGSec:    *rgSec,
+		Force:    *force,
+	})
+
+	if err := client.Login(); err != nil {
+		fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
+		os.Exit(1)
+	}
+	fmt.Fprintf(os.Stderr, "login OK โ€” stok=%s\n", client.Stok())
+
+	if path == "" {
+		return
+	}
+
+	data, err := client.Request(path, fields)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "request failed: %v\n", err)
+		os.Exit(1)
+	}
+	fmt.Println(prettyJSON(data))
+}
+
+func env(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
+
+func prettyJSON(raw json.RawMessage) string {
+	var v any
+	if err := json.Unmarshal(raw, &v); err != nil {
+		return string(raw)
+	}
+	out, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		return string(raw)
+	}
+	return string(out)
+}
Makefile
@@ -4,10 +4,12 @@ SHELL := /bin/bash
 BUILD_DIR   := $(CURDIR)/builds
 SWAY_DIR    := $(CURDIR)/swaylock-mod
 WDU_DIR     := $(CURDIR)/wdu
+TPLINKCTL_DIR := $(CURDIR)/tplinkctl 
 INSTALL_DIR := $(HOME)/.wraith/bin
 
 SWAY_BIN := swaylock
 WDU_BIN  := wdu
+TPLINKCTL_BIN := tplinkctl 
 
 GREEN  := \033[0;32m
 YELLOW := \033[0;33m
@@ -38,13 +40,14 @@ help:
 	@echo "  all             Build all components"
 	@echo "  swaylock        Build swaylock"
 	@echo "  wdu             Build wdu"
+	@echo "  tplinkctl       Build tplinkctl"
 	@echo "  install         Build and install binaries"
 	@echo "  clean           Remove all build artifacts\n"
 
 # ---------------------------------------------------------
 # Targets
 # ---------------------------------------------------------
-all: swaylock wdu
+all: swaylock wdu tplinkctl
 	@echo -e "\n$(GREEN)All builds completed$(RESET)"
 	@echo -e "$(BOLD)Artifacts in $(BUILD_DIR)$(RESET)"
 
@@ -72,15 +75,25 @@ wdu: prepare
 	@cd $(WDU_DIR) && go build -o $(BUILD_DIR)/$(WDU_BIN)
 	@echo -e "$(GREEN)WDU built successfully$(RESET)"
 
+tplinkctl: prepare
+	@echo -e "\n$(BOLD)Building tplinkctl$(RESET)"
+	$(call require,go)
+
+	@cd $(TPLINKCTL_DIR) && go build -o $(BUILD_DIR)/$(TPLINKCTL_BIN)
+	@echo -e "$(GREEN)tplinkctl built successfully$(RESET)"
+
 install: all
 	@echo -e "\n$(BOLD)Installing binaries$(RESET)"
 	@mkdir -p $(INSTALL_DIR)
 	@cp $(BUILD_DIR)/$(SWAY_BIN) $(INSTALL_DIR)/
 	@cp $(BUILD_DIR)/$(WDU_BIN) $(INSTALL_DIR)/
+	@cp $(BUILD_DIR)/$(TPLINKCTL_BIN) $(INSTALL_DIR)/
 	@echo -e "$(GREEN)Installed to $(INSTALL_DIR)$(RESET)"
 
 clean:
 	@echo -e "\n$(YELLOW)Cleaning build artifacts$(RESET)"
 	@rm -rf $(BUILD_DIR)
 	@rm -rf $(SWAY_DIR)/build
+	@rm -rf $(WDU_DIR)/$(WDU_BIN)
+	@rm -rf $(TPLINKCTL_DIR)/$(TPLINKCTL_BIN)
 	@echo -e "$(GREEN)Clean complete$(RESET)\n"