// 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
}