initial commit
This commit is contained in:
364
internal/keenetic/client.go
Normal file
364
internal/keenetic/client.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package keenetic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type InternetStatus struct {
|
||||
Checked string `json:"checked"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Reliable bool `json:"reliable"`
|
||||
GatewayAccessible bool `json:"gateway-accessible"`
|
||||
DNSAccessible bool `json:"dns-accessible"`
|
||||
CaptiveAccessible bool `json:"captive-accessible"`
|
||||
Internet bool `json:"internet"`
|
||||
Gateway struct {
|
||||
Interface string `json:"interface"`
|
||||
Address string `json:"address"`
|
||||
Failures float64 `json:"failures"`
|
||||
Accessible bool `json:"accessible"`
|
||||
Excluded bool `json:"excluded"`
|
||||
} `json:"gateway"`
|
||||
Captive struct {
|
||||
Host string `json:"host"`
|
||||
Response string `json:"response"`
|
||||
Location string `json:"location"`
|
||||
Failures float64 `json:"failures"`
|
||||
Resolved bool `json:"resolved"`
|
||||
Address string `json:"address"`
|
||||
} `json:"captive"`
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
ConnFree float64 `json:"connfree"`
|
||||
ConnTotal float64 `json:"conntotal"`
|
||||
CpuLoad float64 `json:"cpuload"`
|
||||
MemBuffers float64 `json:"membuffers"`
|
||||
MemCache float64 `json:"memcache"`
|
||||
MemFree float64 `json:"memfree"`
|
||||
MemTotal float64 `json:"memtotal"`
|
||||
SwapFree float64 `json:"swapfree"`
|
||||
SwapTotal float64 `json:"swaptotal"`
|
||||
Hostname string `json:"hostname"`
|
||||
Uptime string `json:"uptime"`
|
||||
}
|
||||
|
||||
type ProcessCPUStat struct {
|
||||
Cur float64 `json:"cur"`
|
||||
}
|
||||
|
||||
type ProcessStats struct {
|
||||
CPU ProcessCPUStat `json:"cpu"`
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
Comm string `json:"comm"`
|
||||
Pid string `json:"pid"`
|
||||
Threads string `json:"threads"`
|
||||
Fds float64 `json:"fds"`
|
||||
VMSize string `json:"vm-size"`
|
||||
VMRSS string `json:"vm-rss"`
|
||||
Statistics ProcessStats `json:"statistics"`
|
||||
}
|
||||
|
||||
type HotspotClientInfo struct {
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
Name string `json:"name"`
|
||||
SSID string `json:"ssid"`
|
||||
Registered bool `json:"registered"`
|
||||
RXBytes float64 `json:"rxbytes"`
|
||||
TXBytes float64 `json:"txbytes"`
|
||||
TXRate float64 `json:"txrate"`
|
||||
RSSI float64 `json:"rssi"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
}
|
||||
|
||||
type InterfaceInfo struct {
|
||||
ID string `json:"id"`
|
||||
InterfaceName string `json:"interface-name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Link string `json:"link"`
|
||||
Connected string `json:"connected"`
|
||||
State string `json:"state"`
|
||||
MTU float64 `json:"mtu"`
|
||||
TxQueueLength float64 `json:"tx-queue-length"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
MAC string `json:"mac"`
|
||||
Channel float64 `json:"channel"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type InterfaceStats struct {
|
||||
RxPackets float64 `json:"rxpackets"`
|
||||
RxMulticastPackets float64 `json:"rx-multicast-packets"`
|
||||
RxBroadcastPackets float64 `json:"rx-broadcast-packets"`
|
||||
RxBytes float64 `json:"rxbytes"`
|
||||
RxErrors float64 `json:"rxerrors"`
|
||||
RxDropped float64 `json:"rxdropped"`
|
||||
TxPackets float64 `json:"txpackets"`
|
||||
TxMulticastPackets float64 `json:"tx-multicast-packets"`
|
||||
TxBroadcastPackets float64 `json:"tx-broadcast-packets"`
|
||||
TxBytes float64 `json:"txbytes"`
|
||||
TxErrors float64 `json:"txerrors"`
|
||||
TxDropped float64 `json:"txdropped"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
LastOverflow string `json:"last-overflow"`
|
||||
RxSpeed float64 `json:"rxspeed"`
|
||||
TxSpeed float64 `json:"txspeed"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL *url.URL
|
||||
login string
|
||||
password string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func NewClient(rawurl string) (*Client, error) {
|
||||
parsed, err := url.Parse(rawurl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Jar: jar,
|
||||
},
|
||||
baseURL: parsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (c *Client) Init(ctx context.Context, login, password string) error {
|
||||
if err := c.authenticate(ctx, login, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.fetchHostname(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) authenticate(ctx context.Context, login, password string) error {
|
||||
c.login = login
|
||||
c.password = password
|
||||
|
||||
authURL := c.baseURL.ResolveReference(&url.URL{Path: "/auth"})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating auth GET request: %w", err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth GET failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil // already authenticated
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return fmt.Errorf("unexpected auth GET status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
realm := resp.Header.Get("X-NDM-Realm")
|
||||
challenge := resp.Header.Get("X-NDM-Challenge")
|
||||
if realm == "" || challenge == "" {
|
||||
return fmt.Errorf("missing challenge headers")
|
||||
}
|
||||
|
||||
// compute: sha256(challenge + md5(login:realm:password))
|
||||
h := md5.Sum([]byte(fmt.Sprintf("%s:%s:%s", login, realm, password)))
|
||||
md5hex := hex.EncodeToString(h[:])
|
||||
s := sha256.Sum256([]byte(challenge + md5hex))
|
||||
shahex := hex.EncodeToString(s[:])
|
||||
|
||||
reqBody := authRequest{Login: login, Password: shahex}
|
||||
buf := new(bytes.Buffer)
|
||||
if err := json.NewEncoder(buf).Encode(reqBody); err != nil {
|
||||
return fmt.Errorf("encode auth body: %w", err)
|
||||
}
|
||||
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, authURL.String(), buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating auth POST request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth POST failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("auth failed, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchHostname(ctx context.Context) error {
|
||||
sys, err := c.GetSystemInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Hostname = sys.Hostname
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetJSON(ctx context.Context, path string, out any) error {
|
||||
relURL, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing path %q: %w", path, err)
|
||||
}
|
||||
fullURL := c.baseURL.ResolveReference(relURL)
|
||||
|
||||
doRequest := func() (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating GET request: %w", err)
|
||||
}
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
resp, err := doRequest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close() // закрываем перед повтором
|
||||
|
||||
if err := c.authenticate(ctx, c.login, c.password); err != nil {
|
||||
return fmt.Errorf("re-authentication failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err = doRequest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET retry failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GET %s failed: status %d, body: %s", fullURL.String(), resp.StatusCode, data)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) GetInternetStatus(ctx context.Context) (*InternetStatus, error) {
|
||||
var status InternetStatus
|
||||
if err := c.GetJSON(ctx, "/rci/show/internet/status", &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) {
|
||||
var sys SystemInfo
|
||||
if err := c.GetJSON(ctx, "/rci/show/system", &sys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sys, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProcessInfo(ctx context.Context) ([]*ProcessInfo, error) {
|
||||
var raw struct {
|
||||
Process []json.RawMessage `json:"process"`
|
||||
}
|
||||
if err := c.GetJSON(ctx, "/rci/show/processes", &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*ProcessInfo
|
||||
for _, item := range raw.Process {
|
||||
var p ProcessInfo
|
||||
if err := json.Unmarshal(item, &p); err != nil {
|
||||
continue
|
||||
}
|
||||
if p.Pid == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, &p)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetHotspotClientInfo(ctx context.Context) ([]*HotspotClientInfo, error) {
|
||||
var raw struct {
|
||||
Host []*HotspotClientInfo `json:"host"`
|
||||
}
|
||||
if err := c.GetJSON(ctx, "/rci/show/ip/hotspot", &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw.Host, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetInterfaceInfo(ctx context.Context) (map[string]*InterfaceInfo, error) {
|
||||
var result map[string]*InterfaceInfo
|
||||
if err := c.GetJSON(ctx, "/rci/show/interface", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetInterfaceStats(ctx context.Context, name string) (*InterfaceStats, error) {
|
||||
path := fmt.Sprintf("/rci/show/interface/stat?name=%s", url.QueryEscape(name))
|
||||
var stats InterfaceStats
|
||||
if err := c.GetJSON(ctx, path, &stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetConnectedInterfaceStats(ctx context.Context) (map[string]*InterfaceStats, error) {
|
||||
interfaces, err := c.GetInterfaceInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get interfaces: %w", err)
|
||||
}
|
||||
|
||||
statsMap := make(map[string]*InterfaceStats)
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.InterfaceName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Connected == "no" {
|
||||
continue
|
||||
}
|
||||
|
||||
stats, err := c.GetInterfaceStats(ctx, iface.InterfaceName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statsMap[iface.InterfaceName] = stats
|
||||
}
|
||||
|
||||
return statsMap, nil
|
||||
}
|
||||
Reference in New Issue
Block a user