Files
speedport-exporter/main.go
2026-03-07 16:31:30 +01:00

316 lines
10 KiB
Go

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