Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions icecastConfig.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions icecastConfig_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
73 changes: 57 additions & 16 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,50 +27,65 @@ 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",
Help: "Gauge representing current Icecast stream listeners",
}, []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)
Expand All @@ -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")
Expand All @@ -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)
Expand Down
Loading
Loading