Plenty of writes now works and an expanded example exists in the terraform directory

This commit is contained in:
Joachim Friberg committed 2026-08-25 20:25:08 +02:00
1 parent a6f1019175
commit fb8d468943
15 files changed
+1344 -74

No files matched your search

@@ -10,12 +10,20 @@ import (
"net/url"
"regexp"
"strings"
"sync/atomic"
)
type Client struct {
BaseURL string
httpClient *http.Client
password string
dirty atomic.Bool
// AdminPort is the interface number that resource_port.go's write
// methods refuse to touch, regardless of what's requested -- protects
// whichever port is the switch's uplink/management path. Set once by
// provider.Configure(), read by resource_port.go's guard.
AdminPort int64
}
func NewClient(host string, https bool, password string) (*Client, error) {
@@ -87,6 +95,22 @@ func (c *Client) Login() error {
return nil
}
// Logout releases the switch's one active session slot (GET
// /index.html?logout=1, found by probing -- this firmware has no documented
// logout endpoint). Best-effort: call it when the provider process is
// shutting down so the *next* `tofu` invocation doesn't have to wait out the
// switch's own session timeout (see Web Parameters -> Session Timeout, ~5
// min) before it can log in.
func (c *Client) Logout() error {
resp, err := c.httpClient.Get(c.resolveURL("/index.html?logout=1"))
if err != nil {
return fmt.Errorf("logout request: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
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))
@@ -108,6 +132,33 @@ func (c *Client) Fetch(path string) (string, error) {
var errFlagRejectedRe = regexp.MustCompile(`(?i)name="err_flag"[^>]*value="1"`)
var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`)
// postForm POSTs exactly the fields given (caller supplies everything,
// including submit_flag) and returns the raw response body. Low-level
// building block shared by SubmitForm (submit_flag=8, the common case) and
// port_config.go's reloadPortConfig (submit_flag=1, to select a port
// without applying any change).
func (c *Client) postForm(path string, fields map[string]string) (string, error) {
form := url.Values{}
for k, v := range fields {
form.Set(k, v)
}
resp, err := c.httpClient.PostForm(c.resolveURL(path), form)
if err != nil {
return "", fmt.Errorf("posting 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("posting to %s returned status %d", path, resp.StatusCode)
}
return string(body), nil
}
// 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.
@@ -121,34 +172,24 @@ var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`)
// 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{
merged := map[string]string{
// 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},
"submit_flag": "8",
"submit_target": target,
"err_flag": "0",
"err_msg": "",
"clazz_information": target,
}
for k, v := range fields {
form.Set(k, v)
merged[k] = v
}
resp, err := c.httpClient.PostForm(c.resolveURL(path), form)
bodyStr, err := c.postForm(path, merged)
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)
return "", err
}
bodyStr := string(body)
if errFlagRejectedRe.MatchString(bodyStr) {
msg := "unknown error"
if m := errMsgRe.FindStringSubmatch(bodyStr); m != nil {
@@ -156,5 +197,26 @@ func (c *Client) SubmitForm(path string, fields map[string]string) (string, erro
}
return "", fmt.Errorf("switch rejected write to %q: %s", path, msg)
}
c.dirty.Store(true)
return bodyStr, nil
}
// Dirty reports whether any SubmitForm call has succeeded yet during this
// Client's lifetime -- used by the provider to decide whether a
// save_running_config=true shutdown save is actually warranted (no point
// wearing the switch's flash on a plan-only run that wrote nothing).
func (c *Client) Dirty() bool {
return c.dirty.Load()
}
const SaveAllChangesPath = "/SaveAllChanges.html"
// SaveRunningConfig persists the switch's running configuration to its
// non-volatile config file (the web UI's "Save Configuration" button) --
// without this, config changes made via SubmitForm are lost on the next
// reboot/power cycle. The page has no editable fields, just a submit
// button, so this posts the baseline fields only.
func (c *Client) SaveRunningConfig() error {
_, err := c.SubmitForm(SaveAllChangesPath, map[string]string{})
return err
}