generated from VLADIMIR/template
up
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var ErrRead = errors.New("не удалось прочитать Wi-Fi Cudy")
|
||||
|
||||
const maxBody = 2 << 20
|
||||
|
||||
type reader struct {
|
||||
base string
|
||||
password string
|
||||
http *http.Client
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
func NewReader(base, password string) (Reader, error) {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return nil, ErrRead
|
||||
}
|
||||
ip, err := netip.ParseAddr(u.Hostname())
|
||||
if err != nil || (!ip.IsPrivate() && !ip.IsLoopback()) || password == "" {
|
||||
return nil, ErrRead
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Timeout: 5 * time.Second, Jar: jar, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > 5 || req.URL.Host != u.Host || req.URL.Scheme != u.Scheme {
|
||||
return ErrRead
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
return &reader{base: strings.TrimRight(base, "/"), password: password, http: client}, nil
|
||||
}
|
||||
|
||||
func (r *reader) request(ctx context.Context, path string, form url.Values) ([]byte, int, error) {
|
||||
method := http.MethodGet
|
||||
var body io.Reader
|
||||
if form != nil {
|
||||
method = http.MethodPost
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, body)
|
||||
if err != nil {
|
||||
return nil, 0, ErrRead
|
||||
}
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
return nil, 0, ErrRead
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil || len(data) > maxBody {
|
||||
return nil, resp.StatusCode, ErrRead
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (r *reader) login(ctx context.Context) error {
|
||||
data, code, err := r.request(ctx, "/cgi-bin/luci/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusForbidden && code != http.StatusOK {
|
||||
return ErrRead
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return ErrRead
|
||||
}
|
||||
form := url.Values{}
|
||||
walk(doc, func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "input" && attr(n, "type") == "hidden" {
|
||||
switch attr(n, "name") {
|
||||
case "_csrf", "salt", "token":
|
||||
form.Set(attr(n, "name"), attr(n, "value"))
|
||||
}
|
||||
}
|
||||
})
|
||||
if form.Get("salt") == "" || form.Get("_csrf") == "" {
|
||||
return ErrRead
|
||||
}
|
||||
token, code, err := r.request(ctx, "/cgi-bin/luci/admin/get_token", url.Values{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusOK || len(token) > 256 || len(token) == 0 {
|
||||
return ErrRead
|
||||
}
|
||||
hash := sha256.Sum256([]byte(r.password + form.Get("salt")))
|
||||
hash = sha256.Sum256([]byte(hex.EncodeToString(hash[:]) + strings.TrimSpace(string(token))))
|
||||
form.Set("luci_username", "admin")
|
||||
form.Set("luci_password", hex.EncodeToString(hash[:]))
|
||||
form.Set("token", strings.TrimSpace(string(token)))
|
||||
form.Set("luci_language", "en")
|
||||
data, code, err = r.request(ctx, "/cgi-bin/luci/", form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusOK || strings.Contains(string(data), "luci_password_login") {
|
||||
return ErrRead
|
||||
}
|
||||
r.authenticated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *reader) Read(ctx context.Context) ([]Client, error) {
|
||||
if !r.authenticated {
|
||||
if err := r.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data, code, err := r.request(ctx, "/cgi-bin/luci/admin/network/devices/devlist?detail=1", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code == http.StatusForbidden || code == http.StatusUnauthorized || strings.Contains(string(data), "luci_password_login") {
|
||||
r.authenticated = false
|
||||
if err := r.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, code, err = r.request(ctx, "/cgi-bin/luci/admin/network/devices/devlist?detail=1", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
return nil, ErrRead
|
||||
}
|
||||
return parseClients(data)
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func walk(n *html.Node, visit func(*html.Node)) {
|
||||
visit(n)
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c, visit)
|
||||
}
|
||||
}
|
||||
func nodeText(n *html.Node) string {
|
||||
var out strings.Builder
|
||||
walk(n, func(c *html.Node) {
|
||||
if c.Type == html.TextNode {
|
||||
out.WriteString(c.Data)
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
})
|
||||
return strings.TrimSpace(out.String())
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func parseClients(data []byte) ([]Client, error) {
|
||||
doc, err := html.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return nil, ErrRead
|
||||
}
|
||||
clients := []Client{}
|
||||
var table *html.Node
|
||||
walk(doc, func(n *html.Node) {
|
||||
if n.Data == "th" && nodeText(n) == "IP / MAC Address" {
|
||||
for parent := n.Parent; parent != nil; parent = parent.Parent {
|
||||
if parent.Data == "table" {
|
||||
table = parent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if table == nil {
|
||||
return nil, ErrRead
|
||||
}
|
||||
invalid := false
|
||||
seen := map[string]bool{}
|
||||
walk(table, func(row *html.Node) {
|
||||
if row.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if row.Data != "tr" {
|
||||
return
|
||||
}
|
||||
hasData := false
|
||||
for child := row.FirstChild; child != nil; child = child.NextSibling {
|
||||
hasData = hasData || child.Data == "td"
|
||||
}
|
||||
if !hasData {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(attr(row, "id"), "cbi-table-") {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
fields := map[string]*html.Node{}
|
||||
prefix := attr(row, "id") + "-"
|
||||
walk(row, func(n *html.Node) {
|
||||
if n.Data == "div" && strings.HasPrefix(attr(n, "id"), prefix) {
|
||||
fields[strings.TrimPrefix(attr(n, "id"), prefix)] = n
|
||||
}
|
||||
})
|
||||
var band string
|
||||
wired := false
|
||||
if hostname := fields["hostname"]; hostname != nil {
|
||||
walk(hostname, func(n *html.Node) {
|
||||
if n.Data == "span" {
|
||||
label := nodeText(n)
|
||||
wired = wired || label == "Ethernet"
|
||||
if label == "2.4G WiFi" {
|
||||
band = "2.4 GHz"
|
||||
}
|
||||
if label == "5G WiFi" {
|
||||
band = "5 GHz"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// shortcut: stock TR3000 2.5.27 DOM and two bands; update parser fixtures for other firmware.
|
||||
if band == "" {
|
||||
if !wired {
|
||||
invalid = true
|
||||
}
|
||||
return
|
||||
}
|
||||
client := Client{Band: band}
|
||||
if field := fields["ipmac"]; field != nil {
|
||||
walk(field, func(n *html.Node) {
|
||||
if n.Data != "p" || client.MAC != "" {
|
||||
return
|
||||
}
|
||||
for _, value := range strings.Fields(nodeText(n)) {
|
||||
if ip, e := netip.ParseAddr(value); e == nil {
|
||||
client.IP = ip.String()
|
||||
}
|
||||
if mac, e := net.ParseMAC(value); e == nil && len(mac) == 6 {
|
||||
client.MAC = strings.ToUpper(mac.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if field := fields["signal"]; field != nil {
|
||||
walk(field, func(n *html.Node) {
|
||||
if n.Data == "p" && client.Signal == nil {
|
||||
parts := strings.Fields(nodeText(n))
|
||||
if len(parts) == 2 && parts[1] == "dBm" {
|
||||
if value, e := strconv.Atoi(parts[0]); e == nil && value >= -120 && value <= 0 {
|
||||
client.Signal = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if client.MAC == "" || seen[client.MAC] {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
seen[client.MAC] = true
|
||||
clients = append(clients, client)
|
||||
})
|
||||
if invalid || len(clients) > 1024 {
|
||||
return nil, ErrRead
|
||||
}
|
||||
sort.Slice(clients, func(i, j int) bool { return clients[i].MAC < clients[j].MAC })
|
||||
return clients, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func clientRow(id, mac, band, signal string) string {
|
||||
return fmt.Sprintf(`<tr id="cbi-table-%s"><td><div id="cbi-table-%s-hostname"><p>PRIVATE NAME<br><span>%s</span></p></div></td><td><div id="cbi-table-%s-ipmac"><p>192.168.10.9<br>%s</p><p>192.168.10.9<br>%s</p></div></td><td><div id="cbi-table-%s-signal"><p>%s</p><p>%s</p></div></td></tr>`, id, id, band, id, mac, mac, id, signal, signal)
|
||||
}
|
||||
func clientTable(rows string) string {
|
||||
return `<table><thead><tr><th>IP / MAC Address</th></tr></thead><tbody>` + rows + `</tbody></table>`
|
||||
}
|
||||
|
||||
func TestParseClients(t *testing.T) {
|
||||
rows := clientRow("1", "AA:BB:CC:DD:EE:02", "2.4G WiFi", "-60 dBm") + clientRow("2", "AA:BB:CC:DD:EE:01", "5G WiFi", "—") + clientRow("3", "AA:BB:CC:DD:EE:03", "Ethernet", "—")
|
||||
clients, err := parseClients([]byte(clientTable(rows)))
|
||||
if err != nil || len(clients) != 2 {
|
||||
t.Fatalf("reading: %v, count %d", err, len(clients))
|
||||
}
|
||||
if clients[0].MAC != "AA:BB:CC:DD:EE:01" || clients[0].Signal != nil || clients[1].Signal == nil || *clients[1].Signal != -60 {
|
||||
t.Fatalf("unexpected snapshot: %+v", clients)
|
||||
}
|
||||
data, _ := json.Marshal(clients)
|
||||
if strings.Contains(string(data), "PRIVATE") {
|
||||
t.Fatal("hostname leaked")
|
||||
}
|
||||
for _, text := range []string{`<form>Login</form>`, clientTable(clientRow("1", "bad", "5G WiFi", "-60 dBm")), clientTable(strings.ReplaceAll(clientRow("1", "AA:BB:CC:DD:EE:01", "5G WiFi", "-60 dBm"), "cbi-table-", "changed-")), clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "5 GHz", "-60 dBm")), clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "", "-60 dBm")), clientTable(rows + clientRow("4", "AA:BB:CC:DD:EE:01", "5G WiFi", "-70 dBm"))} {
|
||||
if _, err := parseClients([]byte(text)); !errors.Is(err, ErrRead) {
|
||||
t.Fatalf("invalid DOM accepted: %v", err)
|
||||
}
|
||||
}
|
||||
clients, err = parseClients([]byte(clientTable("")))
|
||||
if err != nil || len(clients) != 0 {
|
||||
t.Fatal("empty table rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderSessionAndSafeErrors(t *testing.T) {
|
||||
sessions := 0
|
||||
expire := false
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/cgi-bin/luci/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
first := sha256.Sum256([]byte("private-password" + "salt"))
|
||||
want := sha256.Sum256([]byte(hex.EncodeToString(first[:]) + "token"))
|
||||
if r.Form.Get("luci_password") != hex.EncodeToString(want[:]) || r.Form.Get("_csrf") != "csrf" || r.Form.Get("luci_language") != "en" {
|
||||
t.Error("incorrect login")
|
||||
}
|
||||
sessions++
|
||||
http.SetCookie(w, &http.Cookie{Name: "sysauth", Value: "session", Path: "/"})
|
||||
fmt.Fprint(w, "authenticated")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprint(w, `<form><input type="hidden" name="salt" value="salt"><input type="hidden" name="_csrf" value="csrf"></form>`)
|
||||
})
|
||||
mux.HandleFunc("/cgi-bin/luci/admin/get_token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Error("token method")
|
||||
}
|
||||
fmt.Fprint(w, "token")
|
||||
})
|
||||
mux.HandleFunc("/cgi-bin/luci/admin/network/devices/devlist", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.RawQuery != "detail=1" {
|
||||
t.Error("not a read-only request")
|
||||
}
|
||||
if _, err := r.Cookie("sysauth"); err != nil {
|
||||
t.Error("session cookie missing")
|
||||
}
|
||||
if expire {
|
||||
expire = false
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "5G WiFi", "-55 dBm")))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
reader, err := NewReader(server.URL, "private-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := reader.Read(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sessions != 1 {
|
||||
t.Fatal("session not reused")
|
||||
}
|
||||
expire = true
|
||||
if _, err := reader.Read(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessions != 2 {
|
||||
t.Fatal("expired session not refreshed once")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := reader.Read(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("cancellation lost")
|
||||
}
|
||||
for _, base := range []string{"https://example.com", "http://user:secret@192.168.10.1", "http://192.168.10.1/path", "http://8.8.8.8"} {
|
||||
if _, err := NewReader(base, "secret"); !errors.Is(err, ErrRead) {
|
||||
t.Fatal("untrusted URL accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package cudy reads Wi-Fi clients from the stock TR3000 web interface.
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
IP string `json:"IP"`
|
||||
MAC string `json:"MAC"`
|
||||
Band string `json:"band"`
|
||||
Signal *int `json:"signal,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot holds only the latest sanitized reading, never names or credentials.
|
||||
type Snapshot struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
HasError bool `json:"hasError"`
|
||||
Clients []Client `json:"clients"`
|
||||
}
|
||||
|
||||
type Reader interface {
|
||||
Read(context.Context) ([]Client, error)
|
||||
}
|
||||
Reference in New Issue
Block a user