Initial commit

This commit is contained in:
2026-03-07 16:31:30 +01:00
commit 7fdf60b578
13 changed files with 1829 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
speedport-exporter
speedport-exporter-*
*.exe
dist/
.env

26
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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=

Binary file not shown.

280
internal/speedport/ccm.go Normal file
View 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
}
}
}

View 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",
}

View 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
View 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
View 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)
}

View 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