initial commit

This commit is contained in:
2025-10-17 09:29:37 +03:00
parent d71905255a
commit 4d0759c479
23 changed files with 2250 additions and 0 deletions

364
internal/keenetic/client.go Normal file
View 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
}