Skip to content

Commit d66f7c3

Browse files
author
Paolo Velati
committed
Support the JSON firmware family of issue #6
@jauling reverse engineered a Goodtop ZX310S-8T2XS and posted the protocol and a payload sample. That is enough to implement it: GET /authorize?loginusr=<md5(user)>&loginpwd=<md5(pass)> GET /port_statistics.json Selected with a new firmware key, defaulting to html so nothing changes for the existing devices. switchclient.JSONClient satisfies the same collector.SwitchClient interface as the HTML client, so the collector does not know which family is in use; the package split from the refactor is what makes this a new file rather than a rewrite. Notable details The credentials are hashed separately here, loginusr and loginpwd, unlike the HTML family which sends one md5(username+password) token. A test pins that the combined token is never sent to this firmware. Ports arrive as top-level Port_N keys rather than an array, so they are collected and sorted numerically. Go map iteration is random and Port_10 sorts before Port_2 lexically, either of which would attach counters to the wrong label. Link_Status carries speed and duplex together ("2500MbpsFull"), so the family publishes port_link_speed_mbps. A 2.5G port that negotiated 1G is one of the more useful things to alert on with these switches. The metric is omitted for the HTML family, which does not report it, and for any port without a link. No PoE endpoint has been reported, so poe with firmware: json is refused at startup rather than failing every scrape. Testing A fixture built from the payload in the issue, a maxlinear fakeswitch profile with its own self-tests, and an acceptance case that runs the real exporter against it. Verified end to end: port_link_speed_mbps reports 1000, 2500, 10000 and 100 for the emulated ports and is absent for the unlinked ones. Implemented from a report, not from hardware. The device table marks it untested and asks for confirmation. Also corrects the Sodola contributor back to @dennyreiter, which an earlier commit in this branch had changed by mistake.
1 parent 4b4deb7 commit d66f7c3

15 files changed

Lines changed: 990 additions & 21 deletions

AGENTS.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ mis-typechecks the sources and reports nonsense. The pinned version is in
3030
|------|----------------|
3131
| `main.go` | Flags, logging, HTTP server, process lifecycle |
3232
| `internal/config` | Schema, validation, secrets, redaction |
33-
| `internal/switchclient` | Device transport and HTML interpretation |
33+
| `internal/switchclient` | Device transport and response interpretation, both firmware families |
3434
| `internal/collector` | `prometheus.Collector`, nothing else |
3535
| `internal/fakeswitch` | Device emulator shared by tests and `cmd/fakeswitch` |
3636
| `cmd/fakeswitch` | Standalone emulator for manual testing and demos |
@@ -66,6 +66,17 @@ it is offered. Renaming the counters is the real fix and needs a major version.
6666
`exporter_build_info` must appear even when the device is unreachable. A series
6767
that disappears exactly when things break cannot be alerted on.
6868

69+
**Two firmware families share one package.** `switchclient.Client` scrapes HTML
70+
and `switchclient.JSONClient` reads `/port_statistics.json`. Both satisfy
71+
`collector.SwitchClient`, so the collector does not know which is in use. Shared
72+
helpers (`parseUint`, `parseEnabled`, `normalizePortName`, `md5Hex`) are reused
73+
deliberately; keep new device support inside this seam rather than teaching the
74+
collector about firmwares.
75+
76+
Their credential schemes differ and must not be unified: the HTML family sends
77+
one `md5(username+password)` token, the JSON family hashes the two separately as
78+
`loginusr` and `loginpwd`.
79+
6980
**Device quirks are load-bearing.** Each of these is required by a specific
7081
firmware and must not be tidied away:
7182

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Many budget-friendly network switches do not support standard SNMP monitoring. T
2121
| Horaco | HC-SWTGW124AS | Verified | @arthurbarton |
2222
| KeepLink | KP-9000-9XHPML-X | Verified | @jfallot and @adamchabin |
2323
| Sodola | SL-SWTG124AS | Verified | @dennyreiter |
24+
| Goodtop | ZX310S-8T2XS | Untested, `firmware: json` | @jauling |
2425

2526
## Installation
2627

@@ -105,6 +106,7 @@ password: "password" # Web interface password
105106
poll_rate_seconds: 10 # Minimum delay between two polls of the switch
106107
timeout_seconds: 5 # Per-request timeout
107108
poe: false # Scrape the PoE pages (1/0 also accepted)
109+
firmware: html # html (default) or json, see below
108110
```
109111
110112
Unknown keys are rejected, so a misspelled option fails at startup instead of being ignored.
@@ -137,6 +139,26 @@ web:
137139

138140
TLS is served with a minimum version of TLS 1.2. `/healthz` is intentionally left unauthenticated so container health checks keep working. When the endpoint is bound to a non-loopback address without authentication, the exporter logs a warning at startup.
139141

142+
### Firmware families
143+
144+
Two device families are supported. `firmware: html` is the default and covers
145+
every device in the table above: an RTL8373 style web interface whose statistics
146+
are scraped from `/port.cgi`.
147+
148+
`firmware: json` covers MaxLinear based devices that authenticate through
149+
`/authorize` and serve `/port_statistics.json`, reported for the Goodtop
150+
ZX310S-8T2XS in [issue #6](https://github.com/pvelati/cheap-switch-exporter/issues/6).
151+
Two differences worth knowing:
152+
153+
- It reports the negotiated link speed, so `port_link_speed_mbps` is published.
154+
The HTML family does not expose it and omits the metric.
155+
- No PoE endpoint has been reported for it, so `poe: true` together with
156+
`firmware: json` is refused at startup rather than failing every scrape.
157+
158+
This family is implemented from the protocol and payload in the issue and
159+
verified against an emulated device. Nobody has confirmed it against real
160+
hardware yet; reports welcome.
161+
140162
### Polling behaviour
141163

142164
`poll_rate_seconds` is the minimum delay between two polls of the switch. Scrapes arriving inside that window are answered from the previous result, including failures. That keeps a redundant pair of Prometheus servers, or a short scrape interval, from overwhelming a device whose web interface serves one session at a time. Set it to `0` to poll on every scrape.
@@ -153,6 +175,7 @@ The exporter now authenticates lazily: it requests the statistics page, and only
153175

154176
- `port_state`: Port enabled/disabled status (1=Enable, 0=Disable)
155177
- `port_link_status`: Port link up/down status (1=Link Up, 0=Link Down)
178+
- `port_link_speed_mbps`: Negotiated speed in Mbps, only with `firmware: json`
156179
- `port_tx_good_pkt`: Transmitted good packets
157180
- `port_tx_bad_pkt`: Transmitted bad packets
158181
- `port_rx_good_pkt`: Received good packets
@@ -244,6 +267,7 @@ go run ./cmd/fakeswitch -list-profiles
244267
| `unauthorized` | HTTP 401 |
245268
| `slow` | Never answers in time |
246269
| `flaky` | Drops the session every other request |
270+
| `maxlinear` | The JSON firmware of issue #6, with negotiated link speeds |
247271

248272
### Test layers
249273

acceptance_test.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func TestAcceptanceProfiles(t *testing.T) {
7373
tests := []struct {
7474
profile fakeswitch.Profile
7575
poe bool
76+
firmware string
7677
timeout int
7778
wantUp float64
7879
contains []string
@@ -148,6 +149,19 @@ func TestAcceptanceProfiles(t *testing.T) {
148149
contains: []string{`exporter_up 0`},
149150
absent: []string{`port_state{`},
150151
},
152+
{
153+
// Issue #6: JSON firmware, authorised through /authorize.
154+
profile: fakeswitch.ProfileMaxLinear, firmware: "json", timeout: 2, wantUp: 1,
155+
contains: []string{
156+
`port_state{port="1"} 1`,
157+
`port_link_status{port="1"} 1`,
158+
`port_tx_good_pkt{port="1"}`,
159+
// Only this family reports a negotiated speed.
160+
`port_link_speed_mbps{port="1"}`,
161+
`exporter_up 1`,
162+
},
163+
absent: []string{`poe_port_watts`},
164+
},
151165
}
152166

153167
for _, tc := range tests {
@@ -159,10 +173,14 @@ func TestAcceptanceProfiles(t *testing.T) {
159173
Delay: 3 * time.Second,
160174
})
161175
listenAddr := freeAddr(t)
176+
firmware := tc.firmware
177+
if firmware == "" {
178+
firmware = "html"
179+
}
162180
configPath := writeConfig(t, fmt.Sprintf(
163181
"address: %q\nusername: admin\npassword: admin\n"+
164-
"poll_rate_seconds: 0\ntimeout_seconds: %d\npoe: %t\n",
165-
switchAddr, tc.timeout, tc.poe))
182+
"poll_rate_seconds: 0\ntimeout_seconds: %d\npoe: %t\nfirmware: %s\n",
183+
switchAddr, tc.timeout, tc.poe, firmware))
166184

167185
startExporter(t, configPath, listenAddr)
168186
body := scrape(t, listenAddr)
@@ -409,3 +427,53 @@ func TestAcceptanceConcurrentScrapes(t *testing.T) {
409427
}
410428
}
411429
}
430+
431+
// Only the JSON firmware reports a negotiated link speed, so the metric must be
432+
// present for it and absent for the HTML family rather than reported as zero.
433+
func TestAcceptanceLinkSpeedOnlyWhereReported(t *testing.T) {
434+
for _, tc := range []struct {
435+
profile fakeswitch.Profile
436+
firmware string
437+
wantHave bool
438+
}{
439+
{fakeswitch.ProfileMaxLinear, "json", true},
440+
{fakeswitch.ProfileStandard, "html", false},
441+
} {
442+
t.Run(string(tc.profile), func(t *testing.T) {
443+
clearEnv(t)
444+
switchAddr, _ := startFake(t, fakeswitch.Options{Profile: tc.profile, Seed: 13})
445+
listenAddr := freeAddr(t)
446+
configPath := writeConfig(t, fmt.Sprintf(
447+
"address: %q\nusername: admin\npassword: admin\n"+
448+
"poll_rate_seconds: 0\ntimeout_seconds: 2\nfirmware: %s\n",
449+
switchAddr, tc.firmware))
450+
startExporter(t, configPath, listenAddr)
451+
452+
body := scrape(t, listenAddr)
453+
_, have := sample(t, body, "port_link_speed_mbps")
454+
if have != tc.wantHave {
455+
t.Errorf("port_link_speed_mbps present = %t, want %t", have, tc.wantHave)
456+
}
457+
// Port 1 is the first linked port in the emulator and negotiates 1G.
458+
if tc.wantHave && !strings.Contains(body, `port_link_speed_mbps{port="1"} 1000`) {
459+
t.Errorf("want port 1 to report 1000 Mbps, got:\n%s",
460+
grepLines(body, "port_link_speed_mbps"))
461+
}
462+
// A port with no link must report no speed at all, not zero.
463+
if tc.wantHave && strings.Contains(body, `port_link_speed_mbps{port="2"}`) {
464+
t.Error("port 2 has no link and must not report a speed")
465+
}
466+
})
467+
}
468+
}
469+
470+
// grepLines returns the exposition lines containing needle, for error messages.
471+
func grepLines(body, needle string) string {
472+
var out []string
473+
for _, line := range strings.Split(body, "\n") {
474+
if strings.Contains(line, needle) && !strings.HasPrefix(line, "#") {
475+
out = append(out, " "+line)
476+
}
477+
}
478+
return strings.Join(out, "\n")
479+
}

config.yaml.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ timeout_seconds: 5
1616
# Scrape the PoE pages. Accepts true/false; 1/0 also still works.
1717
poe: false
1818

19+
# How the device exposes its statistics.
20+
# html the RTL8373 style web interface, scraped from /port.cgi (default)
21+
# json MaxLinear based devices serving /port_statistics.json
22+
# The json family also reports port_link_speed_mbps and does not support poe.
23+
firmware: html
24+
1925
# Optional hardening of the exporter's own endpoint. Everything below can be
2026
# left out entirely.
2127
#

internal/collector/collector.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type Collector struct {
6969

7070
portState *prometheus.Desc
7171
portLinkStatus *prometheus.Desc
72+
portLinkSpeed *prometheus.Desc
7273
portTxGoodPkt *prometheus.Desc
7374
portTxBadPkt *prometheus.Desc
7475
portRxGoodPkt *prometheus.Desc
@@ -130,6 +131,8 @@ func New(ctx context.Context, opts Options) *Collector {
130131
"State of the port (1=Enable, 0=Disable).", portLabels, nil),
131132
portLinkStatus: prometheus.NewDesc("port_link_status",
132133
"Link status of the port (1=Link Up, 0=Link Down).", portLabels, nil),
134+
portLinkSpeed: prometheus.NewDesc("port_link_speed_mbps",
135+
"Negotiated link speed in Mbps, when the firmware reports it.", portLabels, nil),
133136
portTxGoodPkt: prometheus.NewDesc("port_tx_good_pkt",
134137
"Number of good packets transmitted on the port.", portLabels, nil),
135138
portTxBadPkt: prometheus.NewDesc("port_tx_bad_pkt",
@@ -171,8 +174,8 @@ func New(ctx context.Context, opts Options) *Collector {
171174
// registration time.
172175
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
173176
for _, d := range []*prometheus.Desc{
174-
c.portState, c.portLinkStatus, c.portTxGoodPkt, c.portTxBadPkt,
175-
c.portRxGoodPkt, c.portRxBadPkt,
177+
c.portState, c.portLinkStatus, c.portLinkSpeed,
178+
c.portTxGoodPkt, c.portTxBadPkt, c.portRxGoodPkt, c.portRxBadPkt,
176179
c.poeSystemConsumption, c.poeState, c.poePower, c.poeType,
177180
c.poeWatts, c.poeVoltage, c.poeCurrent,
178181
c.up, c.scrapeDuration, c.scrapeErrorsD, c.buildInfo,
@@ -198,6 +201,7 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) {
198201
for _, p := range res.ports {
199202
emitBool(ch, c.portState, p.Enabled, p.Name)
200203
emitBool(ch, c.portLinkStatus, p.LinkUp, p.Name)
204+
emit(ch, c.portLinkSpeed, prometheus.GaugeValue, p.LinkSpeedMbps, p.Name)
201205
emit(ch, c.portTxGoodPkt, prometheus.CounterValue, p.TxGoodPkt, p.Name)
202206
emit(ch, c.portTxBadPkt, prometheus.CounterValue, p.TxBadPkt, p.Name)
203207
emit(ch, c.portRxGoodPkt, prometheus.CounterValue, p.RxGoodPkt, p.Name)

internal/config/config.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ type Config struct {
6161
// PoE enables scraping of the PoE pages. Accepts true/false as well as
6262
// the historical 1/0 spelling.
6363
PoE FlexBool `yaml:"poe"`
64+
// Firmware selects how the device exposes its statistics. Empty means
65+
// FirmwareHTML, which is what every device supported before this key
66+
// existed.
67+
Firmware string `yaml:"firmware"`
6468
// Web configures the exporter's own HTTP endpoint.
6569
Web WebConfig `yaml:"web"`
6670
}
@@ -76,6 +80,23 @@ type WebConfig struct {
7680
AuthPassword string `yaml:"auth_password"`
7781
}
7882

83+
// Firmware families the exporter can talk to.
84+
const (
85+
// FirmwareHTML scrapes the HTML pages of the RTL8373 style web interface.
86+
FirmwareHTML = "html"
87+
// FirmwareJSON reads /port_statistics.json, as served by MaxLinear based
88+
// devices such as the Goodtop ZX310S-8T2XS. See issue #6.
89+
FirmwareJSON = "json"
90+
)
91+
92+
// FirmwareOrDefault returns the configured firmware family, defaulting to HTML.
93+
func (c Config) FirmwareOrDefault() string {
94+
if c.Firmware == "" {
95+
return FirmwareHTML
96+
}
97+
return strings.ToLower(strings.TrimSpace(c.Firmware))
98+
}
99+
79100
// PollRate returns the minimum delay between two polls of the switch.
80101
func (c Config) PollRate() time.Duration {
81102
if c.PollRateSeconds == nil {
@@ -110,7 +131,7 @@ func (c Config) String() string {
110131
return fmt.Sprintf("address=%s username=%s password=%s poll_rate=%s "+
111132
"timeout=%s poe=%t tls=%t auth=%t",
112133
c.Address, c.Username, c.Password, c.PollRate(),
113-
c.Timeout(), bool(c.PoE), c.TLSEnabled(), c.AuthEnabled())
134+
c.Timeout(), bool(c.PoE), c.TLSEnabled(), c.AuthEnabled()) + " firmware=" + c.FirmwareOrDefault()
114135
}
115136

116137
func redactIfSet(s string) string {
@@ -194,6 +215,17 @@ func (c Config) Validate() error {
194215
if c.PollRateSeconds != nil && *c.PollRateSeconds < 0 {
195216
return fmt.Errorf("poll_rate_seconds must not be negative, got %d", *c.PollRateSeconds)
196217
}
218+
switch c.FirmwareOrDefault() {
219+
case FirmwareHTML:
220+
case FirmwareJSON:
221+
// No PoE endpoint has been reported for this family, so asking for it
222+
// would fail every scrape.
223+
if bool(c.PoE) {
224+
return fmt.Errorf("poe is not supported with firmware: %s", FirmwareJSON)
225+
}
226+
default:
227+
return fmt.Errorf("firmware must be %q or %q, got %q", FirmwareHTML, FirmwareJSON, c.Firmware)
228+
}
197229
return c.Web.validate()
198230
}
199231

internal/config/config_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,47 @@ func TestCheckPermissions(t *testing.T) {
367367
t.Errorf("CheckPermissions(absent) = %v, want nil", err)
368368
}
369369
}
370+
371+
func TestFirmwareSelection(t *testing.T) {
372+
clearEnv(t)
373+
374+
// Absent means the HTML family, which is what every earlier release did.
375+
path := writeConfig(t, "address: h\nusername: u\npassword: p\n")
376+
cfg, err := Load(path)
377+
if err != nil {
378+
t.Fatalf("Load: %v", err)
379+
}
380+
if cfg.FirmwareOrDefault() != FirmwareHTML {
381+
t.Errorf("default firmware = %q, want %q", cfg.FirmwareOrDefault(), FirmwareHTML)
382+
}
383+
384+
for _, spelling := range []string{"json", "JSON", " json "} {
385+
path := writeConfig(t, "address: h\nusername: u\npassword: p\nfirmware: \""+spelling+"\"\n")
386+
cfg, err := Load(path)
387+
if err != nil {
388+
t.Fatalf("Load(%q): %v", spelling, err)
389+
}
390+
if cfg.FirmwareOrDefault() != FirmwareJSON {
391+
t.Errorf("firmware %q resolved to %q", spelling, cfg.FirmwareOrDefault())
392+
}
393+
}
394+
395+
path = writeConfig(t, "address: h\nusername: u\npassword: p\nfirmware: xml\n")
396+
if _, err := Load(path); err == nil {
397+
t.Error("want an error for an unknown firmware")
398+
}
399+
}
400+
401+
// No PoE endpoint has been reported for the JSON family, so asking for it would
402+
// fail every scrape. Better to refuse at startup.
403+
func TestFirmwareJSONRejectsPoE(t *testing.T) {
404+
clearEnv(t)
405+
path := writeConfig(t, "address: h\nusername: u\npassword: p\nfirmware: json\npoe: true\n")
406+
_, err := Load(path)
407+
if err == nil {
408+
t.Fatal("want an error for poe with firmware: json")
409+
}
410+
if !strings.Contains(err.Error(), "poe is not supported") {
411+
t.Errorf("err = %v, want it to explain the combination", err)
412+
}
413+
}

0 commit comments

Comments
 (0)