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