365 lines
9.6 KiB
Go
365 lines
9.6 KiB
Go
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
|
|
}
|