diff --git a/icecastConfig.go b/icecastConfig.go new file mode 100644 index 0000000..5b0b15e --- /dev/null +++ b/icecastConfig.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/xml" + "fmt" + "os" +) + +type IcecastConfig struct { + XMLName xml.Name `xml:"icecast"` + AdminUser string `xml:"authentication>admin-user"` + AdminPassword string `xml:"authentication>admin-password"` +} + +func ParseIcecastConfig(path string) (*IcecastConfig, error) { + configFile, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error read file: %w", err) + } + + var config = &IcecastConfig{} + if err := xml.Unmarshal(configFile, config); err != nil { + return nil, fmt.Errorf("error unmarshal file: %w", err) + } + return config, nil +} diff --git a/icecastConfig_test.go b/icecastConfig_test.go new file mode 100644 index 0000000..218d30b --- /dev/null +++ b/icecastConfig_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" +) + +func TestParseIcecastConfig(t *testing.T) { + configPath := "config.xml" + config, err := ParseIcecastConfig(configPath) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if config == nil { + t.Fatal("Expected config not to be nil") + } + expectedUser := "admin" + expectedPass := "hackme" + + if config.AdminUser != expectedUser { + t.Errorf("Expected AdminUser %s, got %s", expectedUser, config.AdminUser) + } + + if config.AdminPassword != expectedPass { + t.Errorf("Expected AdminPassword %s, got %s", expectedPass, config.AdminPassword) + } +} diff --git a/main.go b/main.go index 5fe1c0b..67b405b 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,14 @@ import ( "encoding/json" "flag" "fmt" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" "log" "net/http" "os" "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" ) type StatusRoot struct { @@ -26,6 +27,12 @@ type Stream struct { ServerName string `json:"server_name"` } +type IcecastClient struct { + Username string + Password string + httpClient *http.Client +} + var ( listeners = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "icecast_listeners", @@ -33,43 +40,52 @@ var ( }, []string{"name", "id"}) ) -func LoadIcecastStatus(url string) (stats *StatusRoot, err error) { - resp, err := http.Get(url) - +func (c IcecastClient) LoadIcecastStatus(url string) (stats *StatusRoot, err error) { + req, err := http.NewRequest("GET", url, nil) if err != nil { - return + return nil, fmt.Errorf("error get request: %w", err) + } + if c.Username != "" { + req.SetBasicAuth(c.Username, c.Password) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error get response: %w", err) } - defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("icecast returned unexpected status: %s", resp.Status) + } stats = new(StatusRoot) json.NewDecoder(resp.Body).Decode(&stats) - return + return stats, nil } func publishVClock(clock string, listeners int) { s := fmt.Sprintf("http://%s/?Command=SetMem=Listeners,%d", clock, listeners) - + resp, err := http.Get(s) - + if err != nil { return } - + defer resp.Body.Close() - + return } -func updateListeners(url string, wait int, clock string) { +func (c IcecastClient) updateListeners(url string, wait int, clock string) { go func() { for { - resp, err := LoadIcecastStatus(url) + resp, err := c.LoadIcecastStatus(url) if err != nil { log.Println("Error polling Icecast endpoint, trying again in", wait) + log.Printf("Error: %v", err) } else { listeners.WithLabelValues(resp.Icestats.Source.ServerName, "0").Set(float64(resp.Icestats.Source.Listeners)) go publishVClock(clock, resp.Icestats.Source.Listeners) @@ -82,6 +98,9 @@ func updateListeners(url string, wait int, clock string) { func main() { urlPtr := flag.String("url", "", "Icecast status endpoint (normally: http://icecast.example.com/status-json.xsl)") + passwordPtr := flag.String("password", "", "Icecast admin password. Non recomended, use env ICECAST_PASSWORD") + usernamePtr := flag.String("username", "", "Icecast admin username") + IcecastConfigPtr := flag.String("icecast-config", "", "Icecast config. For authorization") portPtr := flag.Int("port", 2112, "Port to listen on for metrics") endpointPtr := flag.String("endpoint", "/metrics", "Metrics endpoint to listen on") waitPtr := flag.Int("interval", 15, "Interval to update statistics from Icecast") @@ -92,10 +111,32 @@ func main() { if *urlPtr == "" { log.Fatalf("Missing required argument -url, see '%s -help' for information", os.Args[0]) } + if *passwordPtr == "" { + *passwordPtr = os.Getenv("ICECAST_PASSWORD") + } + if *usernamePtr != "" && *passwordPtr == "" { + log.Println("Warning: Username provided but password is empty. Check ICECAST_PASSWORD env.") + } + + client := IcecastClient{ + Username: *usernamePtr, + Password: *passwordPtr, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + if *IcecastConfigPtr != "" { + icecastConfig, err := ParseIcecastConfig(*IcecastConfigPtr) + if err != nil { + panic(err) + } + client.Username = icecastConfig.AdminUser + client.Password = icecastConfig.AdminPassword + } log.Println("Starting Icecast Exporter") - updateListeners(*urlPtr, *waitPtr, *clockPtr) + client.updateListeners(*urlPtr, *waitPtr, *clockPtr) http.Handle(*endpointPtr, promhttp.Handler()) http.ListenAndServe(fmt.Sprintf(":%d", *portPtr), nil) diff --git a/test/config.xml b/test/config.xml new file mode 100644 index 0000000..13d5bb5 --- /dev/null +++ b/test/config.xml @@ -0,0 +1,349 @@ + + + + + + + + Earth + + icemaster@localhost + + localhost + + + + 100 + 2 + 524288 + 30 + 15 + 10 + + 65535 + + + + + hackme + + + admin + hackme + + + + + + + + + + 8000 + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @pkgdatadir@ + + + @localstatedir@/log/@PACKAGE@ + @pkgdatadir@/web + @pkgdatadir@/admin + @pkgdatadir@/report-db.xml + + + + + + + + + + access.log + error.log + + information + 10000 + + + + + + false + + + + + + + + + + + + + + +