Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
speedport-exporter
|
||||
speedport-exporter-*
|
||||
*.exe
|
||||
dist/
|
||||
.env
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=none
|
||||
ARG DATE=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 go build \
|
||||
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /speedport-exporter .
|
||||
|
||||
# --- Runtime ---
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /speedport-exporter /usr/local/bin/speedport-exporter
|
||||
|
||||
EXPOSE 9810
|
||||
|
||||
ENTRYPOINT ["speedport-exporter"]
|
||||
CMD ["serve"]
|
||||
55
Makefile
Normal file
55
Makefile
Normal file
@@ -0,0 +1,55 @@
|
||||
BINARY := speedport-exporter
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
|
||||
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
|
||||
|
||||
GOFLAGS := -trimpath
|
||||
PLATFORMS := linux/amd64 linux/arm64 linux/arm
|
||||
|
||||
.PHONY: all build run test clean docker cross
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
run-status: build
|
||||
./$(BINARY) status
|
||||
|
||||
run-serve: build
|
||||
./$(BINARY) serve
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) $(BINARY)-*
|
||||
|
||||
# Cross-compile for common platforms (Pi, NAS, etc.)
|
||||
cross:
|
||||
@for platform in $(PLATFORMS); do \
|
||||
os=$${platform%/*}; \
|
||||
arch=$${platform#*/}; \
|
||||
output=$(BINARY)-$${os}-$${arch}; \
|
||||
echo "Building $$output..."; \
|
||||
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \
|
||||
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $$output . ; \
|
||||
done
|
||||
|
||||
# Build for Raspberry Pi specifically (ARMv7 + ARM64)
|
||||
pi:
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \
|
||||
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-armv7 .
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
|
||||
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 .
|
||||
|
||||
docker:
|
||||
docker build \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
--build-arg COMMIT=$(COMMIT) \
|
||||
--build-arg DATE=$(DATE) \
|
||||
-t $(BINARY):$(VERSION) .
|
||||
|
||||
install: build
|
||||
install -m 755 $(BINARY) /usr/local/bin/
|
||||
226
README.md
Normal file
226
README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# speedport-exporter
|
||||
|
||||
Prometheus Exporter und CLI-Tool für den **Telekom Speedport Smart 4** Router.
|
||||
|
||||
Liest die verschlüsselte Statusseite (`Status.json`) des Routers aus, entschlüsselt sie (AES-256-CCM) und stellt die Daten als Prometheus-Metriken bereit oder gibt sie auf der Kommandozeile aus.
|
||||
|
||||
## Features
|
||||
|
||||
- **Keine Abhängigkeiten** – einzelnes Go-Binary, kein Python/Node nötig
|
||||
- **AES-256-CCM Entschlüsselung** – Status.json wird automatisch entschlüsselt
|
||||
- **Prometheus Exporter** – DSL-Metriken, Fehler-Counter, Online-Status
|
||||
- **CLI-Modi** – `status`, `raw`, `json`, `endpoints`
|
||||
- **Login-Support** – Für geschützte Endpunkte (Geräteliste, IP-Daten etc.)
|
||||
- **Cross-Compile** – Läuft auf Raspberry Pi, NAS, x86 Server
|
||||
|
||||
## Schnellstart
|
||||
|
||||
### Bauen
|
||||
|
||||
```bash
|
||||
# Lokal bauen
|
||||
make build
|
||||
|
||||
# Für Raspberry Pi
|
||||
make pi
|
||||
|
||||
# Cross-compile (amd64, arm64, arm)
|
||||
make cross
|
||||
|
||||
# Docker
|
||||
make docker
|
||||
```
|
||||
|
||||
### CLI-Nutzung
|
||||
|
||||
```bash
|
||||
# DSL-Status anzeigen (formatierte Übersicht)
|
||||
./speedport-exporter status
|
||||
|
||||
# Alle Rohdaten anzeigen
|
||||
./speedport-exporter raw
|
||||
|
||||
# JSON-Output (für Weiterverarbeitung mit jq etc.)
|
||||
./speedport-exporter json
|
||||
|
||||
# Alle bekannten Endpunkte abfragen
|
||||
./speedport-exporter endpoints
|
||||
|
||||
# Mit spezifischer Router-Adresse
|
||||
./speedport-exporter status -host 192.168.2.1
|
||||
|
||||
# Mit Passwort für geschützte Endpunkte
|
||||
SPEEDPORT_PASSWORD=meinpasswort ./speedport-exporter endpoints
|
||||
```
|
||||
|
||||
### Prometheus Exporter
|
||||
|
||||
```bash
|
||||
# Server starten (Standard: Port 9810)
|
||||
./speedport-exporter serve
|
||||
|
||||
# Auf anderem Port
|
||||
./speedport-exporter serve -listen :9820
|
||||
|
||||
# Mit Environment-Variablen
|
||||
SPEEDPORT_HOST=192.168.2.1 SPEEDPORT_PASSWORD=meinpasswort \
|
||||
./speedport-exporter serve
|
||||
```
|
||||
|
||||
Metriken sind dann unter `http://localhost:9810/metrics` abrufbar.
|
||||
|
||||
## Prometheus-Metriken
|
||||
|
||||
| Metrik | Typ | Beschreibung |
|
||||
|--------|-----|--------------|
|
||||
| `speedport_up` | Gauge | Router erreichbar (1/0) |
|
||||
| `speedport_online_status` | Gauge | Internet-Verbindung online (1/0) |
|
||||
| `speedport_dsl_rate_kbps` | Gauge | DSL-Rate in kbit/s (Labels: direction, type) |
|
||||
| `speedport_dsl_snr_db` | Gauge | Signal-Rausch-Abstand in dB |
|
||||
| `speedport_dsl_attenuation_db` | Gauge | Leitungsdämpfung in dB |
|
||||
| `speedport_dsl_power_dbm` | Gauge | Signalleistung in dBm |
|
||||
| `speedport_dsl_errors_total` | Gauge | Fehlerzähler (CRC, FEC, HEC) |
|
||||
| `speedport_dsl_uptime_seconds` | Gauge | Verbindungs-Uptime |
|
||||
| `speedport_info` | Gauge | Router-Info als Labels |
|
||||
| `speedport_raw_value` | Gauge | Alle numerischen Rohwerte |
|
||||
| `speedport_scrape_duration_seconds` | Gauge | Scrape-Dauer |
|
||||
| `speedport_scrape_errors_total` | Counter | Scrape-Fehler |
|
||||
|
||||
### Beispiel-Labels
|
||||
|
||||
```
|
||||
speedport_dsl_rate_kbps{direction="downstream",type="current"} 99960
|
||||
speedport_dsl_rate_kbps{direction="upstream",type="current"} 39982
|
||||
speedport_dsl_snr_db{direction="downstream"} 8.2
|
||||
speedport_dsl_errors_total{direction="downstream",type="crc"} 42
|
||||
speedport_info{device_name="Speedport Smart 4",firmware_version="010138.6.8.010.0",serial_number="...",dsl_mode="VDSL"} 1
|
||||
```
|
||||
|
||||
## Grafana Dashboard
|
||||
|
||||
Prometheus-Config (`prometheus.yml`):
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'speedport'
|
||||
scrape_interval: 60s
|
||||
static_configs:
|
||||
- targets: ['localhost:9810']
|
||||
```
|
||||
|
||||
Beispiel-Grafana-Queries:
|
||||
|
||||
```promql
|
||||
# Aktuelle Download-Rate
|
||||
speedport_dsl_rate_kbps{direction="downstream",type="current"}
|
||||
|
||||
# SNR-Verlauf
|
||||
speedport_dsl_snr_db
|
||||
|
||||
# CRC-Fehler pro Stunde
|
||||
rate(speedport_dsl_errors_total{type="crc"}[1h]) * 3600
|
||||
|
||||
# Uptime
|
||||
speedport_dsl_uptime_seconds / 3600
|
||||
```
|
||||
|
||||
## Installation als Service
|
||||
|
||||
```bash
|
||||
# Binary installieren
|
||||
sudo make install
|
||||
|
||||
# Service-User anlegen
|
||||
sudo useradd -r -s /bin/false speedport-exporter
|
||||
|
||||
# Systemd-Service installieren
|
||||
sudo cp speedport-exporter.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now speedport-exporter
|
||||
|
||||
# Status prüfen
|
||||
sudo systemctl status speedport-exporter
|
||||
curl localhost:9810/metrics
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Bauen
|
||||
make docker
|
||||
|
||||
# Starten
|
||||
docker run -d \
|
||||
--name speedport-exporter \
|
||||
--network host \
|
||||
-e SPEEDPORT_HOST=192.168.2.1 \
|
||||
speedport-exporter:dev
|
||||
```
|
||||
|
||||
Oder mit Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
speedport-exporter:
|
||||
build: .
|
||||
network_mode: host
|
||||
environment:
|
||||
- SPEEDPORT_HOST=192.168.2.1
|
||||
# - SPEEDPORT_PASSWORD=meinpasswort
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Flag / Env | Default | Beschreibung |
|
||||
|------------|---------|--------------|
|
||||
| `-host` / `SPEEDPORT_HOST` | `speedport.ip` | Router-Adresse |
|
||||
| `-password` / `SPEEDPORT_PASSWORD` | *(leer)* | Router-Passwort |
|
||||
| `-listen` / `SPEEDPORT_LISTEN` | `:9810` | HTTP-Listen-Adresse |
|
||||
| `-debug` | `false` | Debug-Logging |
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Verschlüsselung
|
||||
|
||||
Der Speedport Smart 4 verschlüsselt seine `Status.json`-Antwort mit AES-256-CCM:
|
||||
|
||||
- **Schlüssel**: Fest im Router eingebaut (vom Community reverse-engineered)
|
||||
- **Nonce**: Erste 8 Bytes des Schlüssels
|
||||
- **Auth-Tag**: Letzte 16 Bytes des Ciphertexts
|
||||
- **Kodierung**: Hex-String
|
||||
|
||||
### Login-Mechanismus
|
||||
|
||||
Für geschützte Endpunkte wird ein Challenge-Response-Verfahren verwendet:
|
||||
|
||||
1. `POST data/Login.json` → Challenge erhalten
|
||||
2. SHA256 des Passworts berechnen
|
||||
3. PBKDF2 (SHA256-Pwd, Challenge[:16], 1000 Iterationen) → AES-128 Key
|
||||
4. AES-128-CCM Verschlüsselung des Pwd-Hashs mit Challenge-Nonce/AAD
|
||||
5. `POST data/Login.json` mit verschlüsseltem Passwort → Session-Cookie
|
||||
|
||||
### Bekannte Endpunkte
|
||||
|
||||
| Endpunkt | Auth | Beschreibung |
|
||||
|----------|------|--------------|
|
||||
| `data/Status.json` | Nein | Basis-Status, DSL-Daten |
|
||||
| `data/DSL.json` | Nein | DSL-Detaildaten |
|
||||
| `data/IPData.json` | Ja | WAN-IP, IPv6 |
|
||||
| `data/DeviceList.json` | Ja | Verbundene Geräte |
|
||||
| `data/Modules.json` | Nein | Firmware-Informationen |
|
||||
| `data/Overview.json` | Nein | Übersichtsdaten |
|
||||
|
||||
## Hinweis
|
||||
|
||||
> ⚠️ Dieses Tool ist inoffiziell und steht in keiner Verbindung zur Telekom.
|
||||
> Die Entschlüsselung basiert auf Community-Reverse-Engineering und kann
|
||||
> mit Firmware-Updates nicht mehr funktionieren.
|
||||
|
||||
Credits an die Community-Projekte, deren Reverse-Engineering dies ermöglicht hat:
|
||||
- [aaronk6/dsl-monitoring](https://github.com/aaronk6/dsl-monitoring)
|
||||
- [Andre0512/speedport-api](https://github.com/Andre0512/speedport-api)
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module speedport-exporter
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
)
|
||||
22
go.sum
Normal file
22
go.sum
Normal file
@@ -0,0 +1,22 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
BIN
internal/speedport/.client.go.swp
Normal file
BIN
internal/speedport/.client.go.swp
Normal file
Binary file not shown.
280
internal/speedport/ccm.go
Normal file
280
internal/speedport/ccm.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package speedport
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CCM implements AES-CCM (Counter with CBC-MAC) as defined in RFC 3610.
|
||||
// This is needed because Go's standard library does not include CCM.
|
||||
type CCM struct {
|
||||
key []byte
|
||||
tagSize int
|
||||
}
|
||||
|
||||
// NewCCM creates a new AES-CCM instance.
|
||||
// tagSize is the authentication tag length in bytes (4, 6, 8, 10, 12, 14, or 16).
|
||||
func NewCCM(key []byte, tagSize int) (*CCM, error) {
|
||||
if tagSize < 4 || tagSize > 16 || tagSize%2 != 0 {
|
||||
return nil, fmt.Errorf("invalid tag size %d: must be even and between 4-16", tagSize)
|
||||
}
|
||||
switch len(key) {
|
||||
case 16, 24, 32:
|
||||
// valid AES key sizes
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid key size %d", len(key))
|
||||
}
|
||||
return &CCM{key: key, tagSize: tagSize}, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts and authenticates ciphertextWithTag using the given nonce and optional additional data.
|
||||
// ciphertextWithTag = ciphertext || tag (tag is appended at the end).
|
||||
func (c *CCM) Decrypt(nonce, ciphertextWithTag, additionalData []byte) ([]byte, error) {
|
||||
if len(nonce) < 7 || len(nonce) > 13 {
|
||||
return nil, fmt.Errorf("invalid nonce size %d: must be 7-13 bytes", len(nonce))
|
||||
}
|
||||
if len(ciphertextWithTag) < c.tagSize {
|
||||
return nil, errors.New("ciphertext too short for tag")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Split ciphertext and tag
|
||||
ct := ciphertextWithTag[:len(ciphertextWithTag)-c.tagSize]
|
||||
tag := ciphertextWithTag[len(ciphertextWithTag)-c.tagSize:]
|
||||
|
||||
// L = 15 - len(nonce) (number of bytes for the length field)
|
||||
L := 15 - len(nonce)
|
||||
|
||||
// --- CTR Decryption ---
|
||||
|
||||
// Build A_0 (first counter block, used for tag encryption)
|
||||
a0 := make([]byte, 16)
|
||||
a0[0] = byte(L - 1) // flags for counter: just L-1 in lower 3 bits
|
||||
copy(a0[1:1+len(nonce)], nonce)
|
||||
// Counter bytes are 0 for A_0
|
||||
|
||||
// Encrypt A_0 to get S_0
|
||||
s0 := make([]byte, 16)
|
||||
block.Encrypt(s0, a0)
|
||||
|
||||
// Decrypt ciphertext using CTR starting from A_1
|
||||
plaintext := make([]byte, len(ct))
|
||||
ctr := make([]byte, 16)
|
||||
copy(ctr, a0)
|
||||
|
||||
for i := 0; i < len(ct); i += aes.BlockSize {
|
||||
// Increment counter (last L bytes, big-endian)
|
||||
c.incrementCounter(ctr, L)
|
||||
|
||||
var keystreamBlock [aes.BlockSize]byte
|
||||
block.Encrypt(keystreamBlock[:], ctr)
|
||||
|
||||
end := i + aes.BlockSize
|
||||
if end > len(ct) {
|
||||
end = len(ct)
|
||||
}
|
||||
for j := i; j < end; j++ {
|
||||
plaintext[j] = ct[j] ^ keystreamBlock[j-i]
|
||||
}
|
||||
}
|
||||
|
||||
// --- CBC-MAC Computation ---
|
||||
|
||||
// Build B_0
|
||||
b0 := make([]byte, 16)
|
||||
hasAAD := len(additionalData) > 0
|
||||
aadFlag := byte(0)
|
||||
if hasAAD {
|
||||
aadFlag = 1 << 6
|
||||
}
|
||||
tagEncoding := byte((c.tagSize-2)/2) << 3
|
||||
b0[0] = aadFlag | tagEncoding | byte(L-1)
|
||||
copy(b0[1:1+len(nonce)], nonce)
|
||||
|
||||
// Encode message length (plaintext length) in last L bytes, big-endian
|
||||
msgLen := len(ct)
|
||||
for i := 0; i < L; i++ {
|
||||
b0[15-i] = byte(msgLen >> (8 * uint(i)))
|
||||
}
|
||||
|
||||
// Start CBC-MAC: X_1 = E(B_0)
|
||||
mac := make([]byte, 16)
|
||||
block.Encrypt(mac, b0)
|
||||
|
||||
// Process AAD if present
|
||||
if hasAAD {
|
||||
aadBlock := c.formatAAD(additionalData)
|
||||
for i := 0; i < len(aadBlock); i += aes.BlockSize {
|
||||
for j := 0; j < aes.BlockSize; j++ {
|
||||
mac[j] ^= aadBlock[i+j]
|
||||
}
|
||||
block.Encrypt(mac, mac)
|
||||
}
|
||||
}
|
||||
|
||||
// Process plaintext blocks
|
||||
for i := 0; i < len(plaintext); i += aes.BlockSize {
|
||||
end := i + aes.BlockSize
|
||||
if end > len(plaintext) {
|
||||
// Last partial block: XOR available bytes, rest stays as-is
|
||||
for j := i; j < len(plaintext); j++ {
|
||||
mac[j-i] ^= plaintext[j]
|
||||
}
|
||||
} else {
|
||||
for j := 0; j < aes.BlockSize; j++ {
|
||||
mac[j] ^= plaintext[i+j]
|
||||
}
|
||||
}
|
||||
block.Encrypt(mac, mac)
|
||||
}
|
||||
|
||||
// Compute expected tag: T = first tagSize bytes of (MAC XOR S_0)
|
||||
expectedTag := make([]byte, c.tagSize)
|
||||
for i := 0; i < c.tagSize; i++ {
|
||||
expectedTag[i] = mac[i] ^ s0[i]
|
||||
}
|
||||
|
||||
// Constant-time comparison
|
||||
if subtle.ConstantTimeCompare(expectedTag, tag) != 1 {
|
||||
return nil, errors.New("CCM authentication failed: tag mismatch")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using the given nonce and optional additional data.
|
||||
// Returns ciphertext || tag.
|
||||
func (c *CCM) Encrypt(nonce, plaintext, additionalData []byte) ([]byte, error) {
|
||||
if len(nonce) < 7 || len(nonce) > 13 {
|
||||
return nil, fmt.Errorf("invalid nonce size %d: must be 7-13 bytes", len(nonce))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
L := 15 - len(nonce)
|
||||
|
||||
// --- CBC-MAC Computation ---
|
||||
|
||||
b0 := make([]byte, 16)
|
||||
hasAAD := len(additionalData) > 0
|
||||
aadFlag := byte(0)
|
||||
if hasAAD {
|
||||
aadFlag = 1 << 6
|
||||
}
|
||||
tagEncoding := byte((c.tagSize-2)/2) << 3
|
||||
b0[0] = aadFlag | tagEncoding | byte(L-1)
|
||||
copy(b0[1:1+len(nonce)], nonce)
|
||||
|
||||
msgLen := len(plaintext)
|
||||
for i := 0; i < L; i++ {
|
||||
b0[15-i] = byte(msgLen >> (8 * uint(i)))
|
||||
}
|
||||
|
||||
mac := make([]byte, 16)
|
||||
block.Encrypt(mac, b0)
|
||||
|
||||
if hasAAD {
|
||||
aadBlock := c.formatAAD(additionalData)
|
||||
for i := 0; i < len(aadBlock); i += aes.BlockSize {
|
||||
for j := 0; j < aes.BlockSize; j++ {
|
||||
mac[j] ^= aadBlock[i+j]
|
||||
}
|
||||
block.Encrypt(mac, mac)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(plaintext); i += aes.BlockSize {
|
||||
end := i + aes.BlockSize
|
||||
if end > len(plaintext) {
|
||||
for j := i; j < len(plaintext); j++ {
|
||||
mac[j-i] ^= plaintext[j]
|
||||
}
|
||||
} else {
|
||||
for j := 0; j < aes.BlockSize; j++ {
|
||||
mac[j] ^= plaintext[i+j]
|
||||
}
|
||||
}
|
||||
block.Encrypt(mac, mac)
|
||||
}
|
||||
|
||||
// --- CTR Encryption ---
|
||||
|
||||
a0 := make([]byte, 16)
|
||||
a0[0] = byte(L - 1)
|
||||
copy(a0[1:1+len(nonce)], nonce)
|
||||
|
||||
s0 := make([]byte, 16)
|
||||
block.Encrypt(s0, a0)
|
||||
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
ctr := make([]byte, 16)
|
||||
copy(ctr, a0)
|
||||
|
||||
for i := 0; i < len(plaintext); i += aes.BlockSize {
|
||||
c.incrementCounter(ctr, L)
|
||||
|
||||
var keystreamBlock [aes.BlockSize]byte
|
||||
block.Encrypt(keystreamBlock[:], ctr)
|
||||
|
||||
end := i + aes.BlockSize
|
||||
if end > len(plaintext) {
|
||||
end = len(plaintext)
|
||||
}
|
||||
for j := i; j < end; j++ {
|
||||
ciphertext[j] = plaintext[j] ^ keystreamBlock[j-i]
|
||||
}
|
||||
}
|
||||
|
||||
// Compute tag
|
||||
tag := make([]byte, c.tagSize)
|
||||
for i := 0; i < c.tagSize; i++ {
|
||||
tag[i] = mac[i] ^ s0[i]
|
||||
}
|
||||
|
||||
return append(ciphertext, tag...), nil
|
||||
}
|
||||
|
||||
// formatAAD formats additional authenticated data per RFC 3610 Section 2.2.
|
||||
func (c *CCM) formatAAD(aad []byte) []byte {
|
||||
var encoded []byte
|
||||
aadLen := len(aad)
|
||||
|
||||
if aadLen < 0xFF00 {
|
||||
encoded = make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(encoded, uint16(aadLen))
|
||||
} else if aadLen < (1 << 32) {
|
||||
encoded = make([]byte, 6)
|
||||
encoded[0] = 0xFF
|
||||
encoded[1] = 0xFE
|
||||
binary.BigEndian.PutUint32(encoded[2:], uint32(aadLen))
|
||||
}
|
||||
|
||||
encoded = append(encoded, aad...)
|
||||
|
||||
// Pad to block boundary
|
||||
if rem := len(encoded) % aes.BlockSize; rem != 0 {
|
||||
encoded = append(encoded, make([]byte, aes.BlockSize-rem)...)
|
||||
}
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
// incrementCounter increments the last L bytes of the counter block (big-endian).
|
||||
func (c *CCM) incrementCounter(ctr []byte, L int) {
|
||||
for i := 15; i >= 16-L; i-- {
|
||||
ctr[i]++
|
||||
if ctr[i] != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
455
internal/speedport/client.go
Normal file
455
internal/speedport/client.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package speedport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
const (
|
||||
// Hardcoded AES-256-CCM key used by the Speedport Smart 4 for Status.json encryption.
|
||||
// This was reverse-engineered by the community (see: github.com/aaronk6/dsl-monitoring).
|
||||
statusKeyHex = "cdc0cac1280b516e674f0057e4929bca84447cca8425007e33a88a5cf598a190"
|
||||
|
||||
// Tag size for Status.json decryption
|
||||
statusTagSize = 16
|
||||
|
||||
// Tag size for login encryption
|
||||
loginTagSize = 8
|
||||
|
||||
// PBKDF2 iterations for login key derivation
|
||||
pbkdf2Iterations = 1000
|
||||
|
||||
// PBKDF2 derived key length
|
||||
pbkdf2KeyLen = 16
|
||||
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Client communicates with a Speedport Smart 4 router.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
statusKey []byte
|
||||
statusCCM *CCM
|
||||
|
||||
mu sync.Mutex
|
||||
csrfToken string
|
||||
loggedIn bool
|
||||
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// ClientOption configures the Client.
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// WithPassword sets the router password for authenticated endpoints.
|
||||
func WithPassword(password string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the HTTP timeout.
|
||||
func WithTimeout(d time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.httpClient.Timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a custom logger.
|
||||
func WithLogger(l *slog.Logger) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new Speedport client.
|
||||
// host is the router address, e.g. "192.168.2.1" or "speedport.ip".
|
||||
func NewClient(host string, opts ...ClientOption) (*Client, error) {
|
||||
keyBytes, err := hex.DecodeString(statusKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode status key: %w", err)
|
||||
}
|
||||
|
||||
ccm, err := NewCCM(keyBytes, statusTagSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CCM: %w", err)
|
||||
}
|
||||
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
c := &Client{
|
||||
baseURL: fmt.Sprintf("https://%s", host),
|
||||
statusKey: keyBytes,
|
||||
statusCCM: ccm,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
Jar: jar,
|
||||
},
|
||||
logger: slog.Default(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// fetchRaw fetches a URL and returns the raw response body.
|
||||
func (c *Client) fetchRaw(path string) ([]byte, error) {
|
||||
reqURL := c.baseURL + "/" + path
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP GET %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP GET %s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// postForm sends a POST request with form data.
|
||||
func (c *Client) postForm(path string, data url.Values) ([]byte, error) {
|
||||
reqURL := c.baseURL + "/" + path
|
||||
|
||||
req, err := http.NewRequest("POST", reqURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP POST %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// decryptStatusResponse decrypts a hex-encoded, AES-256-CCM encrypted response
|
||||
// as used by the Speedport Smart 4 for Status.json (and similar unauthenticated endpoints).
|
||||
func (c *Client) decryptStatusResponse(hexData []byte) ([]byte, error) {
|
||||
// Trim any whitespace/newlines
|
||||
hexStr := strings.TrimSpace(string(hexData))
|
||||
|
||||
// Decode hex to bytes
|
||||
ciphertextWithTag, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hex decode: %w", err)
|
||||
}
|
||||
|
||||
// Nonce is the first 8 bytes of the key
|
||||
nonce := c.statusKey[:8]
|
||||
|
||||
// Decrypt using AES-256-CCM
|
||||
plaintext, err := c.statusCCM.Decrypt(nonce, ciphertextWithTag, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CCM decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// parseResponse parses the Speedport JSON response format.
|
||||
// The response is a JSON array of VarEntry objects.
|
||||
func parseResponse(data []byte) ([]VarEntry, error) {
|
||||
var entries []VarEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// GetStatus fetches and decrypts the Status.json endpoint (no login required).
|
||||
func (c *Client) GetStatus() (*StatusData, error) {
|
||||
c.logger.Debug("fetching Status.json")
|
||||
|
||||
raw, err := c.fetchRaw("data/Status.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch Status.json: %w", err)
|
||||
}
|
||||
|
||||
// Try direct JSON parse first (some firmware versions don't encrypt)
|
||||
var entries []VarEntry
|
||||
if err := json.Unmarshal(raw, &entries); err == nil {
|
||||
c.logger.Debug("Status.json was unencrypted JSON")
|
||||
return NewStatusData(entries), nil
|
||||
}
|
||||
|
||||
// Decrypt the hex-encoded AES-CCM response
|
||||
plaintext, err := c.decryptStatusResponse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt Status.json: %w", err)
|
||||
}
|
||||
|
||||
entries, err = parseResponse(plaintext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Status.json: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("Status.json decoded", "entries", len(entries))
|
||||
return NewStatusData(entries), nil
|
||||
}
|
||||
|
||||
// Login authenticates with the router using the challenge-response mechanism.
|
||||
// This is required for accessing protected endpoints like IPData.json or devices list.
|
||||
func (c *Client) Login() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.password == "" {
|
||||
return fmt.Errorf("no password configured")
|
||||
}
|
||||
|
||||
c.logger.Debug("starting login")
|
||||
|
||||
// Step 1: Request challenge
|
||||
challengeData, err := c.postForm("data/Login.json", url.Values{
|
||||
"csrf_token": {"nulltoken"},
|
||||
"showpw": {"0"},
|
||||
"password": {c.hashPassword()},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("request challenge: %w", err)
|
||||
}
|
||||
|
||||
// The challenge response might be encrypted too
|
||||
var challengeEntries []VarEntry
|
||||
if err := json.Unmarshal(challengeData, &challengeEntries); err != nil {
|
||||
// Try decrypting
|
||||
plaintext, err2 := c.decryptStatusResponse(challengeData)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("parse challenge (raw parse: %v, decrypt: %v)", err, err2)
|
||||
}
|
||||
if err := json.Unmarshal(plaintext, &challengeEntries); err != nil {
|
||||
return fmt.Errorf("parse decrypted challenge: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
challengeSD := NewStatusData(challengeEntries)
|
||||
|
||||
// Extract challenge value
|
||||
challengeV := challengeSD.Get("challengev")
|
||||
if challengeV == "" {
|
||||
// Check if already logged in
|
||||
if challengeSD.Get("login") == "success" {
|
||||
c.loggedIn = true
|
||||
c.csrfToken = challengeSD.Get("csrf_token")
|
||||
c.logger.Info("login successful (no challenge needed)")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no challengev in response, status: %s", challengeSD.Get("login"))
|
||||
}
|
||||
|
||||
c.logger.Debug("got challenge", "length", len(challengeV))
|
||||
|
||||
// Step 2: Derive key from password + challenge
|
||||
sha256Pwd := c.hashPassword()
|
||||
|
||||
// PBKDF2: password=SHA256(device_password), salt=challengev[:16] as bytes
|
||||
salt := []byte(challengeV[:16])
|
||||
derivedKey := pbkdf2.Key([]byte(sha256Pwd), salt, pbkdf2Iterations, pbkdf2KeyLen, sha256.New)
|
||||
|
||||
// Step 3: Encrypt the hashed password with AES-128-CCM
|
||||
loginCCM, err := NewCCM(derivedKey, loginTagSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create login CCM: %w", err)
|
||||
}
|
||||
|
||||
// Nonce = hex decode of challengev[16:32]
|
||||
nonce, err := hex.DecodeString(challengeV[16:32])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode nonce from challenge: %w", err)
|
||||
}
|
||||
|
||||
// AAD = hex decode of challengev[32:48]
|
||||
aad, err := hex.DecodeString(challengeV[32:48])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode AAD from challenge: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the SHA256 password hash
|
||||
encryptedPwd, err := loginCCM.Encrypt(nonce, []byte(sha256Pwd), aad)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt password: %w", err)
|
||||
}
|
||||
|
||||
encryptedPwdHex := hex.EncodeToString(encryptedPwd)
|
||||
|
||||
// Step 4: Send login request with encrypted password
|
||||
loginData, err := c.postForm("data/Login.json", url.Values{
|
||||
"csrf_token": {"nulltoken"},
|
||||
"showpw": {"0"},
|
||||
"password": {encryptedPwdHex},
|
||||
"challengev": {challengeV},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
|
||||
// Parse login response
|
||||
var loginEntries []VarEntry
|
||||
if err := json.Unmarshal(loginData, &loginEntries); err != nil {
|
||||
plaintext, err2 := c.decryptStatusResponse(loginData)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("parse login response: %w (decrypt: %v)", err, err2)
|
||||
}
|
||||
if err := json.Unmarshal(plaintext, &loginEntries); err != nil {
|
||||
return fmt.Errorf("parse decrypted login response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
loginSD := NewStatusData(loginEntries)
|
||||
|
||||
if loginSD.Get("login") != "success" {
|
||||
return fmt.Errorf("login failed: %s", loginSD.Get("login"))
|
||||
}
|
||||
|
||||
c.csrfToken = loginSD.Get("csrf_token")
|
||||
c.loggedIn = true
|
||||
c.logger.Info("login successful")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword returns the SHA256 hex digest of the password.
|
||||
func (c *Client) hashPassword() string {
|
||||
h := sha256.Sum256([]byte(c.password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// GetEndpoint fetches and decrypts any data/*.json endpoint.
|
||||
func (c *Client) GetEndpoint(endpoint string) (*StatusData, error) {
|
||||
c.logger.Debug("fetching endpoint", "endpoint", endpoint)
|
||||
|
||||
raw, err := c.fetchRaw(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
// Try direct JSON parse
|
||||
var entries []VarEntry
|
||||
if err := json.Unmarshal(raw, &entries); err == nil {
|
||||
return NewStatusData(entries), nil
|
||||
}
|
||||
|
||||
// Try decrypting
|
||||
plaintext, err := c.decryptStatusResponse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
entries, err = parseResponse(plaintext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
return NewStatusData(entries), nil
|
||||
}
|
||||
|
||||
// GetDSL fetches DSL-specific data (may require login).
|
||||
func (c *Client) GetDSL() (*StatusData, error) {
|
||||
return c.GetEndpoint("data/DSL.json")
|
||||
}
|
||||
|
||||
// GetIPData fetches IP/WAN data (requires login).
|
||||
func (c *Client) GetIPData() (*StatusData, error) {
|
||||
return c.GetEndpoint("data/IPData.json")
|
||||
}
|
||||
|
||||
// GetModuleInfo fetches module/firmware info.
|
||||
func (c *Client) GetModuleInfo() (*StatusData, error) {
|
||||
return c.GetEndpoint("data/Modules.json")
|
||||
}
|
||||
|
||||
// GetDevices fetches the list of connected devices (requires login).
|
||||
func (c *Client) GetDevices() (*StatusData, error) {
|
||||
return c.GetEndpoint("data/DeviceList.json")
|
||||
}
|
||||
|
||||
// Logout ends the session.
|
||||
func (c *Client) Logout() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if !c.loggedIn {
|
||||
return nil
|
||||
}
|
||||
|
||||
token := c.csrfToken
|
||||
if token == "" {
|
||||
token = "nulltoken"
|
||||
}
|
||||
|
||||
_, err := c.postForm("data/Login.json", url.Values{
|
||||
"csrf_token": {token},
|
||||
"logout": {"byby"},
|
||||
})
|
||||
|
||||
c.loggedIn = false
|
||||
c.csrfToken = ""
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("logout: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLoggedIn returns whether the client has an active session.
|
||||
func (c *Client) IsLoggedIn() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
// AllEndpoints is a list of known Speedport Smart 4 data endpoints.
|
||||
var AllEndpoints = []string{
|
||||
"data/Status.json",
|
||||
"data/DSL.json",
|
||||
"data/IPData.json",
|
||||
"data/Modules.json",
|
||||
"data/DeviceList.json",
|
||||
"data/InetIP.json",
|
||||
"data/Connect.json",
|
||||
"data/WLANBasic.json",
|
||||
"data/Overview.json",
|
||||
"data/SystemMessages.json",
|
||||
}
|
||||
257
internal/speedport/exporter.go
Normal file
257
internal/speedport/exporter.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package speedport
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const namespace = "speedport"
|
||||
|
||||
// Exporter collects Speedport metrics and exposes them as Prometheus metrics.
|
||||
type Exporter struct {
|
||||
client *Client
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
scrapeErrors prometheus.Counter
|
||||
scrapeDuration prometheus.Gauge
|
||||
up prometheus.Gauge
|
||||
|
||||
// DSL metrics
|
||||
dslUpRate *prometheus.GaugeVec
|
||||
dslDownRate *prometheus.GaugeVec
|
||||
dslSNR *prometheus.GaugeVec
|
||||
dslAttenuation *prometheus.GaugeVec
|
||||
dslPower *prometheus.GaugeVec
|
||||
dslErrors *prometheus.GaugeVec
|
||||
dslUptime prometheus.Gauge
|
||||
|
||||
// Connection metrics
|
||||
onlineStatus prometheus.Gauge
|
||||
|
||||
// Generic metric for all raw values
|
||||
rawValues *prometheus.GaugeVec
|
||||
rawInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// NewExporter creates a new Speedport Prometheus exporter.
|
||||
func NewExporter(client *Client, logger *slog.Logger) *Exporter {
|
||||
return &Exporter{
|
||||
client: client,
|
||||
logger: logger,
|
||||
|
||||
scrapeErrors: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: namespace,
|
||||
Name: "scrape_errors_total",
|
||||
Help: "Total number of scrape errors.",
|
||||
}),
|
||||
scrapeDuration: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "scrape_duration_seconds",
|
||||
Help: "Duration of the last scrape in seconds.",
|
||||
}),
|
||||
up: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "up",
|
||||
Help: "Whether the Speedport is reachable (1=up, 0=down).",
|
||||
}),
|
||||
|
||||
dslUpRate: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "rate_kbps",
|
||||
Help: "DSL line rate in kbit/s.",
|
||||
}, []string{"direction", "type"}),
|
||||
|
||||
dslSNR: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "snr_db",
|
||||
Help: "DSL signal-to-noise ratio margin in dB.",
|
||||
}, []string{"direction"}),
|
||||
|
||||
dslAttenuation: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "attenuation_db",
|
||||
Help: "DSL line attenuation in dB.",
|
||||
}, []string{"direction"}),
|
||||
|
||||
dslPower: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "power_dbm",
|
||||
Help: "DSL signal power in dBm.",
|
||||
}, []string{"direction"}),
|
||||
|
||||
dslErrors: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "errors_total",
|
||||
Help: "DSL error counters (CRC, FEC, HEC).",
|
||||
}, []string{"direction", "type"}),
|
||||
|
||||
dslUptime: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: "dsl",
|
||||
Name: "uptime_seconds",
|
||||
Help: "Internet connection uptime in seconds.",
|
||||
}),
|
||||
|
||||
onlineStatus: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "online_status",
|
||||
Help: "Online status (1=online, 0=offline).",
|
||||
}),
|
||||
|
||||
rawValues: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "raw_value",
|
||||
Help: "Raw numeric values from the router status page.",
|
||||
}, []string{"varid", "vartype"}),
|
||||
|
||||
rawInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "info",
|
||||
Help: "Router information as labels (value is always 1).",
|
||||
}, []string{"device_name", "firmware_version", "serial_number", "dsl_mode"}),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
|
||||
e.scrapeErrors.Describe(ch)
|
||||
e.scrapeDuration.Describe(ch)
|
||||
e.up.Describe(ch)
|
||||
e.dslUpRate.Describe(ch)
|
||||
e.dslSNR.Describe(ch)
|
||||
e.dslAttenuation.Describe(ch)
|
||||
e.dslPower.Describe(ch)
|
||||
e.dslErrors.Describe(ch)
|
||||
e.dslUptime.Describe(ch)
|
||||
e.onlineStatus.Describe(ch)
|
||||
e.rawValues.Describe(ch)
|
||||
e.rawInfo.Describe(ch)
|
||||
}
|
||||
|
||||
// Collect implements prometheus.Collector.
|
||||
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Reset vector metrics to avoid stale entries
|
||||
e.dslUpRate.Reset()
|
||||
e.dslSNR.Reset()
|
||||
e.dslAttenuation.Reset()
|
||||
e.dslPower.Reset()
|
||||
e.dslErrors.Reset()
|
||||
e.rawValues.Reset()
|
||||
e.rawInfo.Reset()
|
||||
|
||||
status, err := e.client.GetStatus()
|
||||
if err != nil {
|
||||
e.logger.Error("failed to fetch status", "error", err)
|
||||
e.scrapeErrors.Inc()
|
||||
e.up.Set(0)
|
||||
} else {
|
||||
e.up.Set(1)
|
||||
e.processStatus(status)
|
||||
}
|
||||
|
||||
e.scrapeDuration.Set(time.Since(start).Seconds())
|
||||
|
||||
// Collect all metrics
|
||||
e.scrapeErrors.Collect(ch)
|
||||
e.scrapeDuration.Collect(ch)
|
||||
e.up.Collect(ch)
|
||||
e.dslUpRate.Collect(ch)
|
||||
e.dslSNR.Collect(ch)
|
||||
e.dslAttenuation.Collect(ch)
|
||||
e.dslPower.Collect(ch)
|
||||
e.dslErrors.Collect(ch)
|
||||
e.dslUptime.Collect(ch)
|
||||
e.onlineStatus.Collect(ch)
|
||||
e.rawValues.Collect(ch)
|
||||
e.rawInfo.Collect(ch)
|
||||
}
|
||||
|
||||
// processStatus extracts metrics from status data.
|
||||
func (e *Exporter) processStatus(sd *StatusData) {
|
||||
dsl := ExtractDSLInfo(sd)
|
||||
|
||||
// Online status
|
||||
if strings.EqualFold(dsl.OnlineStatus, "online") {
|
||||
e.onlineStatus.Set(1)
|
||||
} else {
|
||||
e.onlineStatus.Set(0)
|
||||
}
|
||||
|
||||
// DSL rates
|
||||
if dsl.UpstreamRate > 0 {
|
||||
e.dslUpRate.WithLabelValues("upstream", "current").Set(dsl.UpstreamRate)
|
||||
}
|
||||
if dsl.DownstreamRate > 0 {
|
||||
e.dslUpRate.WithLabelValues("downstream", "current").Set(dsl.DownstreamRate)
|
||||
}
|
||||
if dsl.UpstreamMaxRate > 0 {
|
||||
e.dslUpRate.WithLabelValues("upstream", "max").Set(dsl.UpstreamMaxRate)
|
||||
}
|
||||
if dsl.DownstreamMaxRate > 0 {
|
||||
e.dslUpRate.WithLabelValues("downstream", "max").Set(dsl.DownstreamMaxRate)
|
||||
}
|
||||
|
||||
// SNR
|
||||
if dsl.UpstreamNoise > 0 {
|
||||
e.dslSNR.WithLabelValues("upstream").Set(dsl.UpstreamNoise)
|
||||
}
|
||||
if dsl.DownstreamNoise > 0 {
|
||||
e.dslSNR.WithLabelValues("downstream").Set(dsl.DownstreamNoise)
|
||||
}
|
||||
|
||||
// Attenuation
|
||||
if dsl.UpstreamAttn > 0 {
|
||||
e.dslAttenuation.WithLabelValues("upstream").Set(dsl.UpstreamAttn)
|
||||
}
|
||||
if dsl.DownstreamAttn > 0 {
|
||||
e.dslAttenuation.WithLabelValues("downstream").Set(dsl.DownstreamAttn)
|
||||
}
|
||||
|
||||
// Power
|
||||
e.dslPower.WithLabelValues("upstream").Set(dsl.UpstreamPower)
|
||||
e.dslPower.WithLabelValues("downstream").Set(dsl.DownstreamPower)
|
||||
|
||||
// Error counters
|
||||
e.dslErrors.WithLabelValues("upstream", "crc").Set(dsl.UpstreamCRC)
|
||||
e.dslErrors.WithLabelValues("downstream", "crc").Set(dsl.DownstreamCRC)
|
||||
e.dslErrors.WithLabelValues("upstream", "fec").Set(dsl.UpstreamFEC)
|
||||
e.dslErrors.WithLabelValues("downstream", "fec").Set(dsl.DownstreamFEC)
|
||||
e.dslErrors.WithLabelValues("upstream", "hec").Set(dsl.UpstreamHEC)
|
||||
e.dslErrors.WithLabelValues("downstream", "hec").Set(dsl.DownstreamHEC)
|
||||
|
||||
// Uptime
|
||||
if dsl.Uptime > 0 {
|
||||
e.dslUptime.Set(dsl.Uptime)
|
||||
}
|
||||
|
||||
// Info metric
|
||||
e.rawInfo.WithLabelValues(
|
||||
sd.Get("device_name"),
|
||||
sd.Get("firmware_version"),
|
||||
sd.Get("serial_number"),
|
||||
dsl.DSLOperatingMode,
|
||||
).Set(1)
|
||||
|
||||
// Export all numeric raw values
|
||||
for _, entry := range sd.Raw {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(entry.VarValue), 64); err == nil {
|
||||
e.rawValues.WithLabelValues(entry.VarID, entry.VarType).Set(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
138
internal/speedport/types.go
Normal file
138
internal/speedport/types.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package speedport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VarEntry represents a single entry in the Speedport JSON response.
|
||||
// The router returns an array of these objects.
|
||||
type VarEntry struct {
|
||||
VarType string `json:"vartype"`
|
||||
VarID string `json:"varid"`
|
||||
VarValue string `json:"varvalue"`
|
||||
}
|
||||
|
||||
// StatusData holds all parsed status information from the router.
|
||||
type StatusData struct {
|
||||
Raw []VarEntry // Raw entries as returned by the router
|
||||
ByID map[string]string // Lookup by varid -> varvalue
|
||||
ByType map[string][]VarEntry // Grouped by vartype
|
||||
}
|
||||
|
||||
// NewStatusData parses a slice of VarEntry into a structured StatusData.
|
||||
func NewStatusData(entries []VarEntry) *StatusData {
|
||||
sd := &StatusData{
|
||||
Raw: entries,
|
||||
ByID: make(map[string]string, len(entries)),
|
||||
ByType: make(map[string][]VarEntry),
|
||||
}
|
||||
for _, e := range entries {
|
||||
sd.ByID[e.VarID] = e.VarValue
|
||||
sd.ByType[e.VarType] = append(sd.ByType[e.VarType], e)
|
||||
}
|
||||
return sd
|
||||
}
|
||||
|
||||
// Get returns the value for a given varid, or empty string if not found.
|
||||
func (sd *StatusData) Get(varid string) string {
|
||||
return sd.ByID[varid]
|
||||
}
|
||||
|
||||
// GetFloat returns the value for a given varid as float64.
|
||||
func (sd *StatusData) GetFloat(varid string) (float64, error) {
|
||||
v, ok := sd.ByID[varid]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("varid %q not found", varid)
|
||||
}
|
||||
return strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
}
|
||||
|
||||
// GetInt returns the value for a given varid as int64.
|
||||
func (sd *StatusData) GetInt(varid string) (int64, error) {
|
||||
v, ok := sd.ByID[varid]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("varid %q not found", varid)
|
||||
}
|
||||
return strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
}
|
||||
|
||||
// Has returns true if the varid exists.
|
||||
func (sd *StatusData) Has(varid string) bool {
|
||||
_, ok := sd.ByID[varid]
|
||||
return ok
|
||||
}
|
||||
|
||||
// DSLInfo extracts DSL-related metrics from the status data.
|
||||
type DSLInfo struct {
|
||||
OnlineStatus string
|
||||
DSLLinkStatus string
|
||||
UpstreamRate float64 // kbit/s
|
||||
DownstreamRate float64 // kbit/s
|
||||
UpstreamMaxRate float64 // kbit/s
|
||||
DownstreamMaxRate float64 // kbit/s
|
||||
UpstreamNoise float64 // dB (SNR margin)
|
||||
DownstreamNoise float64 // dB (SNR margin)
|
||||
UpstreamAttn float64 // dB (line attenuation)
|
||||
DownstreamAttn float64 // dB (line attenuation)
|
||||
UpstreamPower float64 // dBm
|
||||
DownstreamPower float64 // dBm
|
||||
UpstreamCRC float64
|
||||
DownstreamCRC float64
|
||||
UpstreamFEC float64
|
||||
DownstreamFEC float64
|
||||
UpstreamHEC float64
|
||||
DownstreamHEC float64
|
||||
DSLOperatingMode string
|
||||
Uptime float64 // seconds
|
||||
}
|
||||
|
||||
// ExtractDSLInfo attempts to extract DSL metrics from status data.
|
||||
// Field names may vary between firmware versions.
|
||||
func ExtractDSLInfo(sd *StatusData) *DSLInfo {
|
||||
info := &DSLInfo{
|
||||
OnlineStatus: sd.Get("onlinestatus"),
|
||||
DSLLinkStatus: sd.Get("dsl_link_status"),
|
||||
DSLOperatingMode: sd.Get("dsl_operaing_mode"), // yes, typo is in the firmware
|
||||
}
|
||||
|
||||
// Try common field names for DSL metrics
|
||||
// The Speedport uses different naming conventions across firmware versions
|
||||
info.UpstreamRate, _ = sd.GetFloat("dsl_upstream")
|
||||
info.DownstreamRate, _ = sd.GetFloat("dsl_downstream")
|
||||
|
||||
info.UpstreamMaxRate, _ = sd.GetFloat("dsl_max_upstream")
|
||||
info.DownstreamMaxRate, _ = sd.GetFloat("dsl_max_downstream")
|
||||
|
||||
info.UpstreamNoise, _ = sd.GetFloat("dsl_snr_up")
|
||||
info.DownstreamNoise, _ = sd.GetFloat("dsl_snr_down")
|
||||
|
||||
info.UpstreamAttn, _ = sd.GetFloat("dsl_atnu_up")
|
||||
info.DownstreamAttn, _ = sd.GetFloat("dsl_atnu_down")
|
||||
|
||||
info.UpstreamPower, _ = sd.GetFloat("dsl_pwr_up")
|
||||
info.DownstreamPower, _ = sd.GetFloat("dsl_pwr_down")
|
||||
|
||||
info.UpstreamCRC, _ = sd.GetFloat("dsl_crc_up")
|
||||
info.DownstreamCRC, _ = sd.GetFloat("dsl_crc_down")
|
||||
|
||||
info.UpstreamFEC, _ = sd.GetFloat("dsl_fec_up")
|
||||
info.DownstreamFEC, _ = sd.GetFloat("dsl_fec_down")
|
||||
|
||||
info.UpstreamHEC, _ = sd.GetFloat("dsl_hec_up")
|
||||
info.DownstreamHEC, _ = sd.GetFloat("dsl_hec_down")
|
||||
|
||||
info.Uptime, _ = sd.GetFloat("inet_uptime")
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// DeviceInfo represents a connected network device.
|
||||
type DeviceInfo struct {
|
||||
Name string
|
||||
IPv4 string
|
||||
MAC string
|
||||
Connected bool
|
||||
Type string
|
||||
}
|
||||
315
main.go
Normal file
315
main.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
speedport "speedport-exporter/internal/speedport"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Global flags
|
||||
host := flag.String("host", envOrDefault("SPEEDPORT_HOST", "speedport.ip"), "Speedport router address")
|
||||
password := flag.String("password", envOrDefault("SPEEDPORT_PASSWORD", ""), "Router password (for authenticated endpoints)")
|
||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||
|
||||
// Subcommand detection
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
subcommand := os.Args[1]
|
||||
|
||||
// Handle version flag before subcommand parsing
|
||||
if subcommand == "-version" || subcommand == "--version" || subcommand == "version" {
|
||||
fmt.Printf("speedport-exporter %s (commit: %s, built: %s)\n", version, commit, date)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Parse flags after subcommand
|
||||
flag.CommandLine.Parse(os.Args[2:])
|
||||
|
||||
if *versionFlag {
|
||||
fmt.Printf("speedport-exporter %s (commit: %s, built: %s)\n", version, commit, date)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Setup logger
|
||||
logLevel := slog.LevelInfo
|
||||
if *debug {
|
||||
logLevel = slog.LevelDebug
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
|
||||
|
||||
// Create client
|
||||
opts := []speedport.ClientOption{
|
||||
speedport.WithLogger(logger),
|
||||
}
|
||||
if *password != "" {
|
||||
opts = append(opts, speedport.WithPassword(*password))
|
||||
}
|
||||
|
||||
client, err := speedport.NewClient(*host, opts...)
|
||||
if err != nil {
|
||||
logger.Error("failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch subcommand {
|
||||
case "status":
|
||||
cmdStatus(client, logger)
|
||||
case "raw":
|
||||
cmdRaw(client, logger)
|
||||
case "json":
|
||||
cmdJSON(client, logger)
|
||||
case "endpoints":
|
||||
cmdEndpoints(client, logger)
|
||||
case "serve":
|
||||
cmdServe(client, logger)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", subcommand)
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `speedport-exporter - Speedport Smart 4 Status Reader & Prometheus Exporter
|
||||
|
||||
Usage:
|
||||
speedport-exporter <command> [flags]
|
||||
|
||||
Commands:
|
||||
status Show formatted DSL status overview
|
||||
raw Show all raw key-value pairs from Status.json
|
||||
json Output raw JSON from Status.json
|
||||
endpoints Fetch and dump all known endpoints
|
||||
serve Start Prometheus metrics exporter HTTP server
|
||||
|
||||
Flags:
|
||||
-host Router address (default: speedport.ip, env: SPEEDPORT_HOST)
|
||||
-password Router password for authenticated endpoints (env: SPEEDPORT_PASSWORD)
|
||||
-debug Enable debug logging
|
||||
|
||||
Serve-specific flags:
|
||||
-listen Listen address for HTTP server (default: :9810)
|
||||
-interval Minimum scrape interval (default: 30s)
|
||||
|
||||
Environment variables:
|
||||
SPEEDPORT_HOST Router address
|
||||
SPEEDPORT_PASSWORD Router password
|
||||
|
||||
Examples:
|
||||
speedport-exporter status
|
||||
speedport-exporter status -host 192.168.2.1
|
||||
speedport-exporter raw -host speedport.ip
|
||||
speedport-exporter json | jq .
|
||||
speedport-exporter serve -listen :9810
|
||||
SPEEDPORT_PASSWORD=mypass speedport-exporter endpoints
|
||||
`)
|
||||
}
|
||||
|
||||
// cmdStatus shows a formatted DSL status overview.
|
||||
func cmdStatus(client *speedport.Client, logger *slog.Logger) {
|
||||
sd, err := client.GetStatus()
|
||||
if err != nil {
|
||||
logger.Error("failed to get status", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dsl := speedport.ExtractDSLInfo(sd)
|
||||
|
||||
fmt.Println("╔══════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ Speedport Smart 4 - DSL Status ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
|
||||
fmt.Fprintf(w, "║ %-20s\t%s\n", "Device:", sd.Get("device_name"))
|
||||
fmt.Fprintf(w, "║ %-20s\t%s\n", "Firmware:", sd.Get("firmware_version"))
|
||||
fmt.Fprintf(w, "║ %-20s\t%s\n", "Online Status:", colorStatus(dsl.OnlineStatus))
|
||||
fmt.Fprintf(w, "║ %-20s\t%s\n", "DSL Link:", colorStatus(dsl.DSLLinkStatus))
|
||||
fmt.Fprintf(w, "║ %-20s\t%s\n", "DSL Mode:", dsl.DSLOperatingMode)
|
||||
w.Flush()
|
||||
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
fmt.Println("║ Line Rates Upstream Downstream ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.0f\t%8.0f kbit/s\n", "Current Rate:", dsl.UpstreamRate, dsl.DownstreamRate)
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.0f\t%8.0f kbit/s\n", "Max Attainable Rate:", dsl.UpstreamMaxRate, dsl.DownstreamMaxRate)
|
||||
w.Flush()
|
||||
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
fmt.Println("║ Line Quality Upstream Downstream ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.1f\t%8.1f dB\n", "SNR Margin:", dsl.UpstreamNoise, dsl.DownstreamNoise)
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.1f\t%8.1f dB\n", "Line Attenuation:", dsl.UpstreamAttn, dsl.DownstreamAttn)
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.1f\t%8.1f dBm\n", "Signal Power:", dsl.UpstreamPower, dsl.DownstreamPower)
|
||||
w.Flush()
|
||||
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
fmt.Println("║ Error Counters Upstream Downstream ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.0f\t%8.0f\n", "CRC Errors:", dsl.UpstreamCRC, dsl.DownstreamCRC)
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.0f\t%8.0f\n", "FEC Errors:", dsl.UpstreamFEC, dsl.DownstreamFEC)
|
||||
fmt.Fprintf(w, "║ %-25s\t%8.0f\t%8.0f\n", "HEC Errors:", dsl.UpstreamHEC, dsl.DownstreamHEC)
|
||||
w.Flush()
|
||||
|
||||
if dsl.Uptime > 0 {
|
||||
fmt.Println("╠══════════════════════════════════════════════════════╣")
|
||||
fmt.Fprintf(w, "║ %-25s\t%s\n", "Connection Uptime:", formatDuration(dsl.Uptime))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
fmt.Println("╚══════════════════════════════════════════════════════╝")
|
||||
}
|
||||
|
||||
// cmdRaw dumps all raw key-value pairs.
|
||||
func cmdRaw(client *speedport.Client, logger *slog.Logger) {
|
||||
sd, err := client.GetStatus()
|
||||
if err != nil {
|
||||
logger.Error("failed to get status", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "TYPE\tID\tVALUE\n")
|
||||
fmt.Fprintf(w, "----\t--\t-----\n")
|
||||
for _, entry := range sd.Raw {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", entry.VarType, entry.VarID, entry.VarValue)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// cmdJSON outputs raw JSON.
|
||||
func cmdJSON(client *speedport.Client, logger *slog.Logger) {
|
||||
sd, err := client.GetStatus()
|
||||
if err != nil {
|
||||
logger.Error("failed to get status", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
// Output as a clean map
|
||||
output := make(map[string]interface{})
|
||||
for _, entry := range sd.Raw {
|
||||
output[entry.VarID] = entry.VarValue
|
||||
}
|
||||
|
||||
if err := enc.Encode(output); err != nil {
|
||||
logger.Error("failed to encode JSON", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// cmdEndpoints fetches all known endpoints and dumps them.
|
||||
func cmdEndpoints(client *speedport.Client, logger *slog.Logger) {
|
||||
for _, ep := range speedport.AllEndpoints {
|
||||
fmt.Printf("=== %s ===\n", ep)
|
||||
sd, err := client.GetEndpoint(ep)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
for _, entry := range sd.Raw {
|
||||
fmt.Fprintf(w, " [%s] %s\t= %s\n", entry.VarType, entry.VarID, entry.VarValue)
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// cmdServe starts the Prometheus metrics HTTP server.
|
||||
func cmdServe(client *speedport.Client, logger *slog.Logger) {
|
||||
// Parse serve-specific flags
|
||||
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
listen := serveFlags.String("listen", envOrDefault("SPEEDPORT_LISTEN", ":9810"), "Listen address")
|
||||
_ = serveFlags.Parse(flag.Args())
|
||||
|
||||
exporter := speedport.NewExporter(client, logger)
|
||||
prometheus.MustRegister(exporter)
|
||||
|
||||
http.Handle("/metrics", promhttp.Handler())
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Speedport Exporter</title></head>
|
||||
<body>
|
||||
<h1>Speedport Smart 4 Exporter</h1>
|
||||
<p><a href="/metrics">Metrics</a></p>
|
||||
<p><a href="/health">Health</a></p>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
logger.Info("starting Prometheus exporter", "listen", *listen)
|
||||
if err := http.ListenAndServe(*listen, nil); err != nil {
|
||||
logger.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func envOrDefault(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func colorStatus(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "online", "up":
|
||||
return "✅ " + s
|
||||
case "offline", "down", "":
|
||||
return "❌ " + s
|
||||
default:
|
||||
return "⚠️ " + s
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(seconds float64) string {
|
||||
d := time.Duration(seconds) * time.Second
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
mins := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
32
speedport-exporter.service
Normal file
32
speedport-exporter.service
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Speedport Smart 4 Prometheus Exporter
|
||||
Documentation=https://github.com/your-user/speedport-exporter
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=speedport-exporter
|
||||
Group=speedport-exporter
|
||||
|
||||
ExecStart=/usr/local/bin/speedport-exporter serve -listen :9810
|
||||
|
||||
Environment=SPEEDPORT_HOST=speedport.ip
|
||||
# Uncomment and set if you need authenticated endpoints:
|
||||
# Environment=SPEEDPORT_PASSWORD=your-password-here
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
ReadWritePaths=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user