diff --git a/collector/api.go b/collector/api.go index 9aaf221..76c1405 100644 --- a/collector/api.go +++ b/collector/api.go @@ -9,6 +9,8 @@ import ( "io/ioutil" "net/http" "net/http/cookiejar" + "net/url" + "strconv" "strings" "time" ) @@ -19,49 +21,188 @@ type VodafoneStation struct { client *http.Client } -type LoginResponse struct { +type LoginResponseSalts struct { Error string `json:"error"` Salt string `json:"salt"` SaltWebUI string `json:"saltwebui"` } -func NewVodafoneStation(url, password string) *VodafoneStation { +type LoginResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Data *LoginResponseData `json:"data"` +} + +type LoginResponseData struct { + Interface string `json:"intf"` + User string `json:"user"` + Uid string `json:"uid"` + DefaultPassword string `json:"Dpd"` + RemoteAddress string `json:"remoteAddr"` + UserAgent string `json:"userAgent"` + HttpReferer string `json:"httpReferer"` +} + +type LogoutResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +type DocsisStatusResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Data *DocsisStatusData `json:"data"` +} + +type DocsisStatusData struct { + OfdmDownstreamData []*OfdmDownstreamData `json:"ofdm_downstream"` + Downstream []*DocsisDownstreamChannel `json:"downstream"` + Upstream []*DocsisUpstreamChannel `json:"upstream"` +} + +type OfdmDownstreamData struct { + Id string `json:"__id"` + ChannelIdOfdm string `json:"channelid_ofdm"` + StartFrequency string `json:"start_frequency"` + EndFrequency string `json:"end_frequency"` + CentralFrequencyOfdm string `json:"CentralFrequency_ofdm"` + Bandwidth string `json:"bandwidth"` + PowerOfdm string `json:"power_ofdm"` + SnrOfdm string `json:"SNR_ofdm"` + FftOfdm string `json:"FFT_ofdm"` + LockedOfdm string `json:"locked_ofdm"` + ChannelType string `json:"ChannelType"` +} + +type DocsisDownstreamChannel struct { + Id string `json:"__id"` + ChannelId string `json:"channelid"` + CentralFrequency string `json:"CentralFrequency"` + Power string `json:"power"` + Snr string `json:"SNR"` + Fft string `json:"FFT"` + Locked string `json:"locked"` + ChannelType string `json:"ChannelType"` +} + +type DocsisUpstreamChannel struct { + Id string `json:"__id"` + ChannelIdUp string `json:"channelidup"` + CentralFrequency string `json:"CentralFrequency"` + Power string `json:"power"` + ChannelType string `json:"ChannelType"` + Fft string `json:"FFT"` + RangingStatus string `json:"RangingStatus"` +} + +func NewVodafoneStation(stationUrl, password string) *VodafoneStation { cookieJar, err := cookiejar.New(nil) + parsedUrl, err := url.Parse(stationUrl) + cookieJar.SetCookies(parsedUrl, []*http.Cookie{ + &http.Cookie{ + Name: "Cwd", + Value: "No", + }, + }) if err != nil { panic(err) } return &VodafoneStation{ - URL: url, + URL: stationUrl, Password: password, client: &http.Client{ Jar: cookieJar, - Timeout: time.Second * 2, + Timeout: time.Second * 10, }, } } -func (v *VodafoneStation) getLoginSalts() (*LoginResponse, error) { - requestBody := strings.NewReader("username=admin&password=seeksalthash") - response, err := v.client.Post(v.URL+"/api/v1/session/login", "application/x-www-form-urlencoded", requestBody) +func (v *VodafoneStation) Login() (*LoginResponse, error) { + _, err := v.doRequest("GET", v.URL, "") + if err != nil { + return nil, err + } + loginResponseSalts, err := v.getLoginSalts() + if err != nil { + return nil, err + } + derivedPassword := GetLoginPassword(v.Password, loginResponseSalts.Salt, loginResponseSalts.SaltWebUI) + responseBody, err := v.doRequest("POST", v.URL+"/api/v1/session/login", "username=admin&password="+derivedPassword) + if err != nil { + return nil, err + } + loginResponse := &LoginResponse{} + err = json.Unmarshal(responseBody, loginResponse) + if loginResponse.Error != "ok" { + return nil, fmt.Errorf("Got non error=ok message from vodafone station") + } + return loginResponse, nil +} + +func (v *VodafoneStation) Logout() (*LogoutResponse, error) { + responseBody, err := v.doRequest("POST", v.URL+"/api/v1/session/logout", "") + if err != nil { + return nil, err + } + logoutResponse := &LogoutResponse{} + err = json.Unmarshal(responseBody, logoutResponse) + if err != nil { + return nil, err + } + if logoutResponse.Error != "ok" { + return nil, fmt.Errorf("Got non error=ok message from vodafone station") + } + return logoutResponse, nil +} + +func (v *VodafoneStation) GetDocsisStatus() (*DocsisStatusResponse, error) { + responseBody, err := v.doRequest("GET", v.URL+"/api/v1/sta_docsis_status?_="+strconv.FormatInt(makeTimestamp(), 10), "") + if err != nil { + return nil, err + } + docsisStatusResponse := &DocsisStatusResponse{} + return docsisStatusResponse, json.Unmarshal(responseBody, docsisStatusResponse) +} + +func makeTimestamp() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +func (v *VodafoneStation) getLoginSalts() (*LoginResponseSalts, error) { + responseBody, err := v.doRequest("POST", v.URL+"/api/v1/session/login", "username=admin&password=seeksalthash") + if err != nil { + return nil, err + } + loginResponseSalts := &LoginResponseSalts{} + err = json.Unmarshal(responseBody, loginResponseSalts) + if err != nil { + return nil, err + } + if loginResponseSalts.Error != "ok" { + return nil, fmt.Errorf("Got non error=ok message from vodafone station") + } + return loginResponseSalts, nil +} + +func (v *VodafoneStation) doRequest(method, url, body string) ([]byte, error) { + requestBody := strings.NewReader(body) + request, err := http.NewRequest(method, url, requestBody) + if err != nil { + return nil, err + } + if method == "POST" { + request.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + } + request.Header.Set("Referer", "http://192.168.100.1") + request.Header.Set("X-Requested-With", "XMLHttpRequest") + response, err := v.client.Do(request) if err != nil { return nil, err } if response.Body != nil { defer response.Body.Close() } - responseBody, err := ioutil.ReadAll(response.Body) - if err != nil { - return nil, err - } - loginResponse := &LoginResponse{} - err = json.Unmarshal(responseBody, loginResponse) - if err != nil { - return nil, err - } - if loginResponse.Error != "ok" { - return nil, fmt.Errorf("Got non error=ok message from vodafone station") - } - return loginResponse, nil + return ioutil.ReadAll(response.Body) } // GetLoginPassword derives the password using the given salts diff --git a/collector/collector.go b/collector/collector.go new file mode 100644 index 0000000..95a90f7 --- /dev/null +++ b/collector/collector.go @@ -0,0 +1,115 @@ +package collector + +import ( + "github.com/prometheus/client_golang/prometheus" + "regexp" + "strconv" +) + +type Collector struct { + Station *VodafoneStation +} + +var ( + loginSuccessDesc *prometheus.Desc + loginMessageDesc *prometheus.Desc + userDesc *prometheus.Desc + uidDesc *prometheus.Desc + defaultPasswordDesc *prometheus.Desc + + centralFrequencyDesc *prometheus.Desc + powerDesc *prometheus.Desc + snrDesc *prometheus.Desc + lockedDesc *prometheus.Desc + + logoutSuccessDesc *prometheus.Desc + logoutMessageDesc *prometheus.Desc +) + +const prefix = "vodafone_station_" + +func init() { + loginSuccessDesc = prometheus.NewDesc(prefix+"login_success_bool", "1 if the login was successfull", nil, nil) + loginMessageDesc = prometheus.NewDesc(prefix+"login_message_info", "Login message returned by the web interface", []string{"message"}, nil) + userDesc = prometheus.NewDesc(prefix+"user_info", "User name as returned by the web interface", []string{"username"}, nil) + uidDesc = prometheus.NewDesc(prefix+"uid_info", "User id as returned by the web interface", []string{"uid"}, nil) + defaultPasswordDesc = prometheus.NewDesc(prefix+"default_password_bool", "1 if the default password is in use", nil, nil) + + channelLabels := []string{"id", "channel_id", "fft", "channel_type"} + centralFrequencyDesc = prometheus.NewDesc(prefix+"central_frequency_hertz", "Central frequency in hertz", channelLabels, nil) + powerDesc = prometheus.NewDesc(prefix+"power_dBmV", "Power in dBmV", channelLabels, nil) + snrDesc = prometheus.NewDesc(prefix+"snr_dB", "SNR in dB", channelLabels, nil) + lockedDesc = prometheus.NewDesc(prefix+"locked_bool", "Locking status", channelLabels, nil) + + logoutSuccessDesc = prometheus.NewDesc(prefix+"logout_success_bool", "1 if the logout was successfull", nil, nil) + logoutMessageDesc = prometheus.NewDesc(prefix+"logout_message_info", "Logout message returned by the web interface", []string{"message"}, nil) +} + +// Describe implements prometheus.Collector interface's Describe function +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- loginSuccessDesc + ch <- loginMessageDesc + ch <- userDesc + ch <- uidDesc + ch <- defaultPasswordDesc + ch <- centralFrequencyDesc + ch <- powerDesc + ch <- snrDesc + ch <- snrDesc + ch <- logoutSuccessDesc + ch <- logoutMessageDesc +} + +// Collect implements prometheus.Collector interface's Collect function +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + loginresponse, err := c.Station.Login() + if loginresponse != nil { + ch <- prometheus.MustNewConstMetric(loginMessageDesc, prometheus.GaugeValue, 1, loginresponse.Message) + } + if err != nil { + ch <- prometheus.MustNewConstMetric(loginSuccessDesc, prometheus.GaugeValue, 0) + ch <- prometheus.MustNewConstMetric(logoutSuccessDesc, prometheus.GaugeValue, 0) + return + } + ch <- prometheus.MustNewConstMetric(loginSuccessDesc, prometheus.GaugeValue, 1) + ch <- prometheus.MustNewConstMetric(userDesc, prometheus.GaugeValue, 1, loginresponse.Data.User) + ch <- prometheus.MustNewConstMetric(uidDesc, prometheus.GaugeValue, 1, loginresponse.Data.Uid) + ch <- prometheus.MustNewConstMetric(defaultPasswordDesc, prometheus.GaugeValue, bool2float64(loginresponse.Data.DefaultPassword == "Yes")) + + docsisStatusResponse, err := c.Station.GetDocsisStatus() + if err == nil && docsisStatusResponse.Data != nil { + for _, downstreamChannel := range docsisStatusResponse.Data.Downstream { + labels := []string{downstreamChannel.Id, downstreamChannel.ChannelId, downstreamChannel.Fft, downstreamChannel.ChannelType} + ch <- prometheus.MustNewConstMetric(centralFrequencyDesc, prometheus.GaugeValue, parse2float(downstreamChannel.CentralFrequency), labels...) + ch <- prometheus.MustNewConstMetric(powerDesc, prometheus.GaugeValue, parse2float(downstreamChannel.Power), labels...) + ch <- prometheus.MustNewConstMetric(snrDesc, prometheus.GaugeValue, parse2float(downstreamChannel.Snr), labels...) + ch <- prometheus.MustNewConstMetric(lockedDesc, prometheus.GaugeValue, bool2float64(downstreamChannel.Locked == "Locked"), labels...) + } + } + + logoutresponse, err := c.Station.Logout() + if logoutresponse != nil { + ch <- prometheus.MustNewConstMetric(logoutMessageDesc, prometheus.GaugeValue, 1, logoutresponse.Message) + } + if err != nil { + ch <- prometheus.MustNewConstMetric(logoutSuccessDesc, prometheus.GaugeValue, 0) + } + ch <- prometheus.MustNewConstMetric(logoutSuccessDesc, prometheus.GaugeValue, 1) +} + +func parse2float(str string) float64 { + reg := regexp.MustCompile(`[^\.0-9]+`) + processedString := reg.ReplaceAllString(str, "") + value, err := strconv.ParseFloat(processedString, 64) + if err != nil { + return 0 + } + return value +} + +func bool2float64(b bool) float64 { + if b { + return 1 + } + return 0 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9e8f074 --- /dev/null +++ b/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "github.com/fluepke/vodafone-station-exporter/collector" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/log" + "net/http" + "os" +) + +const version = "0.0.1" + +var ( + showVersion = flag.Bool("version", false, "Print version and exit") + listenAddress = flag.String("web.listen-address", "[::]:9420", "Address to listen on") + metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose metrics") + vodafoneStationUrl = flag.String("vodafone.station-url", "http://192.168.0.1", "Vodafone station URL. For bridge mode this is 192.168.100.1 (note: Configure a route if using bridge mode)") + vodafoneStationPassword = flag.String("vodafone.station-password", "How is the default password calculated? mhmm", "Password for logging into the Vodafone station") +) + +func main() { + flag.Parse() + + if *showVersion { + fmt.Println("vodafone-station-exporter") + fmt.Printf("Version: %s\n", version) + fmt.Println("Author: @fluepke") + fmt.Println("Prometheus Exporter for the Vodafone Station (CGA4233DE)") + os.Exit(0) + } + + startServer() +} + +func startServer() { + log.Infof("Starting vodafone-station-exporter (version %s)", version) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` +