Prometheus Exporter for low-cost network switches without SNMP support
This Prometheus exporter retrieves port statistics from switches that lack SNMP functionality, by scraping the device's web interface.
Many budget-friendly network switches do not support standard SNMP monitoring. This exporter provides a workaround by scraping port statistics directly from the switch's web interface.
| Manufacturer | Model | Status | Contributor |
|---|---|---|---|
| Ampcom | WAMJHJ-8125MNG | Verified | @askainet |
| Horaco | ZX-SWTGW215AS | Verified | @askainet |
| Horaco | ZX-SWTGW218AS | Verified | @pvelati |
| Horaco | HC-SWTGW218AS | Verified | @arthurbarton |
| Horaco | HC-SWTGW124AS | Verified | @arthurbarton |
| KeepLink | KP-9000-9XHPML-X | Verified | @jfallot and @adamchabin |
| Sodola | SL-SWTG124AS | Verified | @dennyreiter |
| Goodtop | ZX310S-8T2XS | Untested, firmware: json |
@jauling |
- Go 1.25+
- Docker (optional)
- Clone the repository
- Download dependencies
go mod download- Copy configuration template
cp config.yaml.example config.yaml
chmod 600 config.yaml # the file holds the switch password in clear text- Edit
config.yamlwith your switch details and parameters - Run the exporter
go run . -c config.yaml# Build Docker image
docker build -t cheap-switch-exporter .
# Run container (the image looks for /etc/cheap-switch-exporter/config.yaml)
docker run --rm \
-v "$PWD/config.yaml:/etc/cheap-switch-exporter/config.yaml:ro" \
-p 8080:8080 \
cheap-switch-exporterMulti-architecture builds (for example a Raspberry Pi) work out of the box:
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg VERSION="$(git describe --tags --always --dirty)" \
-t cheap-switch-exporter .| Flag | Default | Description |
|---|---|---|
-c, --config, --config-file |
config.yaml |
Path to the configuration file |
--web.listen-address |
:8080 |
Address to listen on |
--web.telemetry-path |
/metrics |
Path under which metrics are exposed |
--log.level |
info |
debug, info, warn or error |
--log.format |
text |
text or json |
--version |
Print the version and exit |
--port is accepted as a deprecated alias of --web.listen-address; it takes an
address, not a port number, and logs a warning.
Use --log.level=debug to see the values the exporter could not interpret; that is the fastest way to diagnose an unsupported firmware.
| Path | Description |
|---|---|
/metrics |
Prometheus metrics. Polls the switch. |
/healthz |
Liveness probe. Does not touch the switch. |
/ |
Landing page |
Create a config.yaml with the following structure (see config.yaml.example):
address: "192.168.1.1" # IP or hostname of the switch, no scheme
username: "admin" # Web interface username
password: "password" # Web interface password
poll_rate_seconds: 10 # Minimum delay between two polls of the switch
timeout_seconds: 5 # Per-request timeout
poe: false # Scrape the PoE pages (1/0 also accepted)
firmware: html # html (default) or json, see belowUnknown keys are rejected, so a misspelled option fails at startup instead of being ignored.
Any of these override the file, so secrets never have to be written to disk:
| Variable | Overrides |
|---|---|
CSE_ADDRESS |
address |
CSE_USERNAME |
username |
CSE_PASSWORD |
password |
CSE_WEB_AUTH_USERNAME |
web.auth_username |
CSE_WEB_AUTH_PASSWORD |
web.auth_password |
If all mandatory values come from the environment, the configuration file may be absent entirely.
Both sections are optional and disabled by default:
web:
tls_cert_file: "/etc/cheap-switch-exporter/tls.crt"
tls_key_file: "/etc/cheap-switch-exporter/tls.key"
auth_username: "prometheus"
auth_password: "change-me"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.
Two device families are supported. firmware: html is the default and covers
every device in the table above: an RTL8373 style web interface whose statistics
are scraped from /port.cgi.
firmware: json covers MaxLinear based devices that authenticate through
/authorize and serve /port_statistics.json, reported for the Goodtop
ZX310S-8T2XS in issue #6.
Two differences worth knowing:
- It reports the negotiated link speed, so
port_link_speed_mbpsis published. The HTML family does not expose it and omits the metric. - No PoE endpoint has been reported for it, so
poe: truetogether withfirmware: jsonis refused at startup rather than failing every scrape.
This family is implemented from the protocol and payload in the issue and verified against an emulated device. Nobody has confirmed it against real hardware yet; reports welcome.
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.
These web interfaces are session based. Some firmwares (Horaco HC-SWTGW218AS, Binardat) serve the login page with HTTP 200 once no session exists, which used to make the exporter return values only while a browser happened to be logged in.
The exporter now authenticates lazily: it requests the statistics page, and only if the answer contains no data does it POST /login.cgi and retry once. On firmwares that need no handshake this costs nothing, and a session cookie issued by the device is stored and replayed. Persistent failure means the credentials are wrong or the page layout is unsupported, and shows up as exporter_up 0 with the reason in the log.
port_state: Port enabled/disabled status (1=Enable, 0=Disable)port_link_status: Port link up/down status (1=Link Up, 0=Link Down)port_link_speed_mbps: Negotiated speed in Mbps, only withfirmware: jsonport_tx_good_pkt: Transmitted good packetsport_tx_bad_pkt: Transmitted bad packetsport_rx_good_pkt: Received good packetsport_rx_bad_pkt: Received bad packetsport_tx_bytes: Transmitted bytesport_rx_bytes: Received bytes
The port label carries the bare port identifier (1, 2, …) on both the port and the PoE metrics, so the two can be joined in PromQL.
port_tx_bytes and port_rx_bytes are only present when the device reports them: the HTML family must serve a nine-column statistics table (the classic seven columns plus TxBytes and RxBytes), and the JSON family must include TxBytes/RxBytes in /port_statistics.json.
A value the exporter cannot interpret produces no sample at all, rather than a zero. On a counter a fake zero would look like a device reset and would show up as a spike in rate().
poe_port_power_on: PoE port power on/off (1=On, 0=Off)poe_port_state: State of the PoE port (1=Enable, 0=Disable)poe_port_type: PoE power class, 1-8, 0 when no powered device is attachedpoe_port_voltage: PoE port voltage in voltspoe_port_watts: PoE port power consumption in wattspoe_port_current_ma: PoE port current in mApoe_system_consumption_watts: Total PoE consumption in watts
exporter_up: 1 when the last poll of the switch succeeded, 0 otherwiseexporter_last_scrape_duration_seconds: Duration of the last pollexporter_scrape_errors_total: Failed requests to the switchexporter_session_active: 1 while the client holds a device session that serves data, 0 otherwiseexporter_session_reauths_total: Successful session (re-)establishments, including the first oneexporter_build_info: Version and Go version of the running binary
Plus the standard go_*, process_* and promhttp_* metrics.
exporter_up is the series to alert on. It is always present, including while the switch is unreachable:
exporter_up == 0
dashboards/ contains an example Grafana dashboard and a set of
Prometheus alerting rules. Both are validated by the test suite, which fails if
they query a metric the exporter does not publish, so they cannot drift out of
date silently. See dashboards/README.md for how to
import them and what to tune.
Run this after every change:
make checkIt checks formatting, runs go vet, builds both binaries and runs the whole test
suite under the race detector. It takes a few seconds and needs no hardware.
| Target | What it does |
|---|---|
make check |
The one to run after every change |
make test / make race |
Tests, with or without the race detector |
make acceptance |
Only the black-box suite, verbose, one subtest per emulated firmware |
make cover |
Coverage summary (make cover-html for the browser view) |
make tidy |
Verify go.mod and go.sum are tidy |
make lint |
golangci-lint, if installed |
make vuln |
Scan dependencies with govulncheck |
make fake |
Run a fake switch on its own |
make demo |
Fake switch plus exporter, for poking at by hand |
cmd/fakeswitch emulates the supported web interfaces, including their quirks
and their failure modes, so the exporter can be exercised without a device.
Counters advance with wall-clock time, so rate() over a fake produces a
plausible graph.
make demo # fake switch + exporter, metrics on :9101
make demo PROFILE=poe # with the PoE pages
make fake PROFILE=session # just the device, on :8081
go run ./cmd/fakeswitch -list-profiles| Profile | Emulates |
|---|---|
standard |
The common interface: plain statistics table, no handshake |
quirks |
Port N names, grouped numbers, dashes, non-breaking spaces, a decoy table |
keeplink |
0- prefixed cells, empty response without a Referer |
session |
Data only after POST /login.cgi (issue #19) |
binardat |
Device-issued session cookie, synthetic one rejected (issue #8) |
poe |
Serves the PoE pages as well |
garbage |
No statistics table, i.e. an unsupported firmware |
unauthorized |
HTTP 401 |
slow |
Never answers in time |
flaky |
Drops the session every other request |
maxlinear |
The JSON firmware of issue #6, with negotiated link speeds |
- Unit — parsers against HTML fixtures of the known firmware quirks
(
internal/switchclient/testdata), configuration loading and validation. - Collector —
prometheus/testutilgolden output, plus a pedantic registry that fails ifDescribeandCollectever disagree. - Simulator self-tests —
internal/fakeswitch, so a broken test double cannot make the acceptance suite pass for the wrong reason. - Acceptance —
acceptance_test.gostarts the real exporter against every emulated firmware and asserts on the exposition output, the way Prometheus sees it.
When adding a device, a fixture of its statistics page is the most useful
contribution; a matching fakeswitch profile is even better.
If you own a switch that is not in the table above, these are the steps. You need no Go experience for step 1, and step 2 alone is already a useful contribution.
cp config.yaml.example config.yaml
chmod 600 config.yaml
$EDITOR config.yaml # address, username, password
go run . -c config.yaml --log.level=debugIn another terminal:
curl -s localhost:8080/metrics | grep -E '^(exporter_up|port_)'Read exporter_up first:
| Result | Meaning |
|---|---|
exporter_up 1 with one port_state per physical port |
It works. Open an issue with the model name and it goes in the table. |
exporter_up 1 but ports are missing, or values look wrong |
The page layout is close but not identical. Go to step 2. |
exporter_up 0 |
Look at the log line: wrong credentials, unreachable, or an unsupported layout. Go to step 2. |
--log.level=debug names every cell the exporter could not interpret, for example
an unfamiliar spelling in the State or Link Status column. That is usually enough
to identify what differs.
Save exactly what your device returns. The request has to look like the
exporter's, because several firmwares only answer with the right cookie and
Referer:
ADDR=192.168.1.1
USER=admin
PASS=admin
# md5sum on Linux, md5 -q on macOS
TOKEN=$(printf '%s' "$USER$PASS" | { md5sum 2>/dev/null || md5 -q; } | cut -d' ' -f1)
curl -sS -X GET "http://$ADDR/port.cgi?page=stats" \
--cookie "admin=$TOKEN" \
--referer "http://$ADDR/menu.cgi" \
--data "username=$USER&password=$PASS&language=EN&Response=$TOKEN" \
-o port_stats_mymodel.htmlIf the result is a login page, your firmware needs a session first (see Session handling); capture it again after:
curl -sS -X POST "http://$ADDR/login.cgi" -c /tmp/cookies.txt \
--data "username=$USER&password=$PASS&language=EN&Response=$TOKEN"and add -b /tmp/cookies.txt to the first command.
For a PoE switch, capture /pse_port.cgi and /pse_system.cgi the same way.
Scrub the file before sharing it. These pages can contain MAC addresses, serial numbers, hostnames and VLAN names. Replace anything identifying; the parser only cares about the table structure.
Attaching that HTML to an issue is already the most useful thing you can contribute, even if you stop here.
Drop the file in internal/switchclient/testdata/ and add one entry to the
fixtures table in internal/switchclient/parse_test.go:
{
device: "Vendor MODEL-1234",
file: "port_stats_mymodel.html",
wantPorts: 8,
wantFirst: Port{
Name: "1", Enabled: ptr(true), LinkUp: ptr(true),
TxGoodPkt: ptr(uint64(1234567)), TxBadPkt: ptr(uint64(0)),
RxGoodPkt: ptr(uint64(7654321)), RxBadPkt: ptr(uint64(3)),
},
},Fill wantFirst with the values your device actually shows in its first row. A
nil field asserts that the exporter publishes no sample for it, which is
the correct expectation for a cell containing -.
make checkIf the test fails, the parser needs to learn your firmware's quirk. Those live in
internal/switchclient/parse.go, next to the existing ones (the KeepLink 0-
prefix, thousands separators, unit suffixes, Port N names). Keep the change
additive so the other devices keep passing.
A profile lets everyone exercise your firmware without owning it, including its
failure modes. Add a Profile constant and its behaviour in
internal/fakeswitch/fakeswitch.go, then a row in the table in
acceptance_test.go. Try it with:
make demo PROFILE=myprofileInclude the model, the firmware version if the web UI shows one, and whether PoE
was tested. Run make check first.
- Fork the repository
- Create your feature branch
- Commit your changes
- Push to the branch
- Create a new Pull Request
Run make check before opening a pull request.
Parts of this project, including the package layout, the test suite and the
device simulator, were written with the help of AI coding tools. Everything is
reviewed by a human before merging and has to pass make check, which builds
both binaries and runs the whole suite under the race detector.
The same expectation applies to contributions: AI assistance is welcome, working and reviewed code is required.
If you point an agent at this repository, AGENTS.md records the
invariants it needs to respect: which device quirks are load-bearing, why
OpenMetrics is disabled, what must not be renamed, and which decisions have
already been settled.
- Requires web interface access to the switch
- Polling-based metrics collection
- Authentication against the switch uses whatever the device implements: an MD5 token over plain HTTP. Treat the management network accordingly.
- One switch per exporter instance
MIT License, see LICENSE file.
Report issues on the GitHub repository's issue tracker.