Plenty of writes now works and an expanded example exists in the terraform directory
This commit is contained in:
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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// PortConfiguration.html is unlike every other page ported so far: it's a
|
||||
// single-port form with an Interface selector (v_1_1_1), not a per-port
|
||||
// table. Selecting a port is done with a "reload" POST (submit_flag=1, not
|
||||
// the usual submit_flag=8) carrying v_1_1_1=<port> -- confirmed live against
|
||||
// real ports (1 and 5) before any write code was written.
|
||||
//
|
||||
// The page also has three rows all captioned "Link Speed" (v_1_8_1,
|
||||
// v_1_13_1, v_1_15_1 -- one per SFP/PHY type group). READS are fine via the
|
||||
// generic xepage.go parser regardless (both ports probed showed all three
|
||||
// mirroring the same displayed value). WRITES are NOT interchangeable,
|
||||
// though -- confirmed live: submitting the same value to all three at once
|
||||
// was rejected by the switch ("Error! Failed to Set 'Link Speed' ... error
|
||||
// occured") because each field has its own server-side accepted-value
|
||||
// enum (v_1_13_1 is 100FX-SFP-specific, v_1_15_1 is 1000Mbps-SFP-specific).
|
||||
// Per _xe_PortConfiguration.js's xa_1_12_1 mapping table, "No SFP" (i.e.
|
||||
// RJ45 copper -- confirmed via port_summary to be every port on this
|
||||
// switch, an all-copper 1810G-24GE) maps to v_1_8_1 as the one real field;
|
||||
// this client only ever writes that one and rejects any other Physical
|
||||
// Type outright rather than guessing.
|
||||
//
|
||||
// The write also needs three additional hidden fields the read-only
|
||||
// generic parser can't see (they're TRs with no defleft/defright, just a
|
||||
// bare hidden <TD>, so parseTable's scalar-row logic skips them entirely):
|
||||
// v_1_21_1 (UnitIndex), v_1_31_1 (duplicate of the interface), v_1_2_1
|
||||
// (mstid). Omitting them was confirmed live to fail with "FILTER_MISSING"
|
||||
// on Admin Mode. Both probed ports show v_1_21_1=v_1_2_1="1" always and
|
||||
// v_1_31_1 mirroring the interface -- hardcoded accordingly rather than
|
||||
// scraped, since this switch is a single, non-stacked unit.
|
||||
package client
|
||||
|
||||
import "fmt"
|
||||
|
||||
const (
|
||||
portConfigPath = "/PortConfiguration.html"
|
||||
portConfigCaption = "Port Configuration"
|
||||
portInterfaceField = "v_1_1_1"
|
||||
portUnitIndexField = "v_1_21_1"
|
||||
portDupInterfaceField = "v_1_31_1"
|
||||
portMstidField = "v_1_2_1"
|
||||
portAdminModeField = "v_1_5_1"
|
||||
portLinkSpeedFieldNoSFP = "v_1_8_1"
|
||||
portInterfaceLabel = "Interface"
|
||||
portLinkStatusLabel = "Link Status"
|
||||
portPhysicalTypeLabel = "Physical Type"
|
||||
portAdminModeLabel = "Admin Mode"
|
||||
portLinkSpeedLabel = "Link Speed"
|
||||
portPhysicalTypeNoSFP = "No SFP"
|
||||
)
|
||||
|
||||
type PortConfig struct {
|
||||
Interface string
|
||||
AdminMode bool // true == Enable
|
||||
LinkSpeed string
|
||||
PhysicalType string
|
||||
LinkStatus string
|
||||
}
|
||||
|
||||
// reloadPortConfig POSTs submit_flag=1 (reload, not apply) with
|
||||
// v_1_1_1=interfaceNum to select that port, then parses the "Port
|
||||
// Configuration" table out of the response the same way FetchTables would.
|
||||
func (c *Client) reloadPortConfig(interfaceNum string) (Table, error) {
|
||||
target := "PortConfiguration.html"
|
||||
form := map[string]string{
|
||||
"submit_flag": "1", // xui_operation_reload -- select the port, apply nothing
|
||||
"submit_target": target,
|
||||
"err_flag": "0",
|
||||
"err_msg": "",
|
||||
"clazz_information": target,
|
||||
portInterfaceField: interfaceNum,
|
||||
}
|
||||
body, err := c.postForm(portConfigPath, form)
|
||||
if err != nil {
|
||||
return Table{}, err
|
||||
}
|
||||
tables, err := ParseXETables(body)
|
||||
if err != nil {
|
||||
return Table{}, err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if t.Caption == portConfigCaption {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Table{}, fmt.Errorf("table captioned %q not found on %s", portConfigCaption, portConfigPath)
|
||||
}
|
||||
|
||||
// ReadPortConfig returns the current configuration for one port.
|
||||
func (c *Client) ReadPortConfig(interfaceNum string) (PortConfig, error) {
|
||||
table, err := c.reloadPortConfig(interfaceNum)
|
||||
if err != nil {
|
||||
return PortConfig{}, err
|
||||
}
|
||||
return PortConfig{
|
||||
Interface: table.Record[portInterfaceLabel],
|
||||
AdminMode: table.Record[portAdminModeLabel] == "Enable",
|
||||
LinkSpeed: table.Record[portLinkSpeedLabel],
|
||||
PhysicalType: table.Record[portPhysicalTypeLabel],
|
||||
LinkStatus: table.Record[portLinkStatusLabel],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPortConfig applies Admin Mode and Link Speed to one port. Only
|
||||
// supports "No SFP" (RJ45 copper) ports -- see the package doc comment.
|
||||
func (c *Client) SetPortConfig(interfaceNum string, adminMode bool, linkSpeed string) error {
|
||||
current, err := c.reloadPortConfig(interfaceNum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
physicalType := current.Record[portPhysicalTypeLabel]
|
||||
if physicalType != portPhysicalTypeNoSFP {
|
||||
return fmt.Errorf("interface %q has Physical Type %q -- this resource only supports %q (RJ45 copper) ports", interfaceNum, physicalType, portPhysicalTypeNoSFP)
|
||||
}
|
||||
|
||||
value := "Disable"
|
||||
if adminMode {
|
||||
value = "Enable"
|
||||
}
|
||||
fields := map[string]string{
|
||||
portInterfaceField: interfaceNum,
|
||||
portUnitIndexField: "1",
|
||||
portDupInterfaceField: interfaceNum,
|
||||
portMstidField: "1",
|
||||
portAdminModeField: value,
|
||||
portLinkSpeedFieldNoSFP: linkSpeed,
|
||||
}
|
||||
_, err = c.SubmitForm(portConfigPath, fields)
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user