Commit 933033e
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"