// Package client is a Go port of the reference Python implementation's // switch_client.py (see ~/code/experiments/hpe/switch_client.py). package client import ( "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "strings" ) type Client struct { BaseURL string httpClient *http.Client password string } func NewClient(host string, https bool, password string) (*Client, error) { scheme := "http" if https { scheme = "https" } jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("creating cookie jar: %w", err) } return &Client{ BaseURL: fmt.Sprintf("%s://%s", scheme, host), httpClient: &http.Client{Jar: jar}, password: password, }, nil } func (c *Client) resolveURL(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if strings.HasPrefix(path, "/") { return c.BaseURL + path } return c.BaseURL + "/" + path } // Login POSTs to /hp_login.html and establishes the session cookie. // // A bare POST occasionally comes back without setting SID (the switch seems // to want a session cookie already present, even an empty one, before it // will issue a real one) -- a GET first makes login reliable. // // Unlike the Python version, there's no on-disk session cache here: this // provider process lives for the whole plan/apply run, so Configure() logs // in once and every resource/data source reuses the same *Client. func (c *Client) Login() error { if _, err := c.httpClient.Get(c.resolveURL("/")); err != nil { return fmt.Errorf("priming session: %w", err) } form := url.Values{ "pwd": {c.password}, "login": {"Login"}, "err_flag": {""}, "err_msg": {""}, } resp, err := c.httpClient.PostForm(c.resolveURL("/hp_login.html"), form) if err != nil { return fmt.Errorf("login request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("reading login response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("login request returned status %d", resp.StatusCode) } if strings.Contains(strings.ToLower(string(body)), "login") { snippet := string(body) if len(snippet) > 500 { snippet = snippet[:500] } return fmt.Errorf("login failed, response still looks like the login page: %q", snippet) } return nil } // Fetch GETs BaseURL+path (path may be relative or absolute) and returns the body. func (c *Client) Fetch(path string) (string, error) { resp, err := c.httpClient.Get(c.resolveURL(path)) if err != nil { return "", fmt.Errorf("fetching %s: %w", path, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading response for %s: %w", path, err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("fetching %s returned status %d", path, resp.StatusCode) } return string(body), nil } var errFlagRejectedRe = regexp.MustCompile(`(?i)name="err_flag"[^>]*value="1"`) var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`) // SubmitForm POSTs fields to path, merged with the firmware's standard // bookkeeping fields (submit_flag, submit_target, err_flag, err_msg, // clazz_information), and returns the response body. // // Returns an error if the response's err_flag comes back "1" -- the // firmware's own validation-failure signal. NOTE: most value validation in // this firmware happens client-side in JS, not on the server -- an invalid // enum value, for example, is just silently ignored (state left unchanged) // rather than reported via err_flag. So this check catches *some* rejected // writes, but a caller that needs certainty should read the value back // afterwards and compare, rather than trusting the absence of an error. func (c *Client) SubmitForm(path string, fields map[string]string) (string, error) { target := strings.TrimPrefix(path, "/") form := url.Values{ // 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js); // 1 is xui_operation_reload and silently applies nothing. "submit_flag": {"8"}, "submit_target": {target}, "err_flag": {"0"}, "err_msg": {""}, "clazz_information": {target}, } for k, v := range fields { form.Set(k, v) } resp, err := c.httpClient.PostForm(c.resolveURL(path), form) if err != nil { return "", fmt.Errorf("submitting form to %s: %w", path, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading response for %s: %w", path, err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("submitting form to %s returned status %d", path, resp.StatusCode) } bodyStr := string(body) if errFlagRejectedRe.MatchString(bodyStr) { msg := "unknown error" if m := errMsgRe.FindStringSubmatch(bodyStr); m != nil { msg = m[1] } return "", fmt.Errorf("switch rejected write to %q: %s", path, msg) } return bodyStr, nil }