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 }