diff --git a/.editorconfig b/.editorconfig index de01420..410b34f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,4 @@ tab_width = 2 [*.go] indent_size = 4 tab_width = 4 +indent_style = tab diff --git a/Dockerfile b/Dockerfile index e74cc1f..53a0a41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,9 @@ ENV FRITZBOX_ENDPOINT_URL="http://fritz.box:49000" \ CLOUDFLARE_API_KEY_FILE="" \ CLOUDFLARE_ZONES_IPV4="" \ CLOUDFLARE_ZONES_IPV6="" \ - DEVICE_LOCAL_ADDRESS_IPV6="" + DEVICE_LOCAL_ADDRESS_IPV6="" \ + METRICS_BIND="" \ + METRICS_TOKEN_FILE="" WORKDIR /app diff --git a/README.md b/README.md index 35f6085..0790719 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ In your `.env` file or your system environment variables you can be configured: | CLOUDFLARE_ZONES_IPV6 | comma-separated list of domains to update with new IPv6 addresses. | | CLOUDFLARE_API_EMAIL | deprecated, your Cloudflare account email. | | CLOUDFLARE_API_KEY | deprecated, your Cloudflare Global API key. | -| CLOUDFLARE_API_KEY_FILE | deprecated, path to a file containing your Cloudflare Global API key. | +| CLOUDFLARE_API_KEY_FILE | deprecated, path to a file containing your Cloudflare Global API key. It's recommended to use this over `CLOUDFLARE_API_KEY`. | This service allows to update multiple records, an advanced example would be: @@ -209,7 +209,7 @@ services: env_file: ./updater.env restart: unless-stopped ports: - - 8080/tcp + - "8080/tcp" ``` With your secret configure in the `updater.env` file next to it (as `SOME_VARIABLE=`). @@ -224,6 +224,26 @@ docker run --rm -it -p 8888:8080 fritzbox-cloudflare-dyndns If you leave `CLOUDFLARE_*` unconfigured, pushing to Cloudflare will be disabled for testing purposes, so try to trigger it by calling `http://127.0.0.1:8888/ip?v4=127.0.0.1&v6=::1` and review the logs. +## Metrics and Health Check + +If you want to check whether the service is running correctly, you can configure these with the following variables: + +| Variable name | Description | +|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| METRICS_BIND | required, network interface to bind to, i.e. `:9876` | +| METRICS_TOKEN | token that has to be passed to the endpoints to authenticate | +| METRICS_TOKEN_FILE | path ot a file containing a token that has to be passed to the endpoints to authenticate. It's recommended to use this over `METRICS_TOKEN`. | + +The endpoint for prometheus-compatible metrics is `/metrics`, the endpoint for the health check is `/healthz` and the +endpoint for liveness is `/liveness` on the configured network bind. +If you chose to use a token, you'll have to append it using the query like `/metrics?token=123456`. + +The difference between the liveness and the health endpoint is that the health endpoint will return `503` if any +subsystem has an issue and `200` if not, while the liveness endpoint will always return `204` as long as the HTTP server +is able to respond. + ## History & Credit -Most of the credit goes to [@adrianrudnik](https://github.com/adrianrudnik), who wrote and maintained the software for years. Meanwhile I stepped in at a later point when the repository was transferred to me to continue its basic maintenance should it be required. +Most of the credit goes to [@adrianrudnik](https://github.com/adrianrudnik), who wrote and maintained the software for +years. After he moved on I stepped in at a later point when the repository was transferred to me to continue its basic +maintenance should it be required. diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 2b9d02c..05ddc29 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -22,7 +22,9 @@ ENV FRITZBOX_ENDPOINT_URL="http://fritz.box:49000" \ CLOUDFLARE_API_KEY_FILE="" \ CLOUDFLARE_ZONES_IPV4="" \ CLOUDFLARE_ZONES_IPV6="" \ - DEVICE_LOCAL_ADDRESS_IPV6="" + DEVICE_LOCAL_ADDRESS_IPV6="" \ + METRICS_BIND="" \ + METRICS_TOKEN_FILE="" WORKDIR /app diff --git a/go.mod b/go.mod index 2b85939..2f3e61a 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,24 @@ go 1.23 require ( github.com/cloudflare/cloudflare-go v0.108.0 github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.20.5 golang.org/x/net v0.30.0 gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.7.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index 09a887f..26fc766 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/cloudflare-go v0.108.0 h1:C4Skfjd8I8X3uEOGmQUT4/iGyZcWdkIU7HwvMoLkEE0= github.com/cloudflare/cloudflare-go v0.108.0/go.mod h1:m492eNahT/9MsN7Ppnoge8AaI7QhVFtEgVm3I9HJFeU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -6,30 +10,47 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc h1:LMEBgNcZUqXaP7evD1PZcL6EcDVa2QOFuI+cqM3+AJM= diff --git a/main.go b/main.go index 3ea34e1..a52269f 100644 --- a/main.go +++ b/main.go @@ -2,28 +2,30 @@ package main import ( "context" + "encoding/json" "errors" - "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/avm" "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/cloudflare" "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/dyndns" - "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/logging" + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/polling" + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/util" "github.com/joho/godotenv" + "github.com/prometheus/client_golang/prometheus/promhttp" "log/slog" "net" "net/http" - "net/url" "os" "os/signal" "strings" "syscall" - "time" ) func main() { // Load any env variables defined in .env.dev files _ = godotenv.Load(".env", ".env.dev") - updater := newUpdater() + rootLogger := slog.Default() + + updater, updateStatus := newUpdater(rootLogger) updater.StartWorker() ctx, cancel := context.WithCancelCause(context.Background()) @@ -34,14 +36,24 @@ func main() { if ipv6LocalAddress != "" { localIp = net.ParseIP(ipv6LocalAddress) if localIp == nil { - slog.Error("Failed to parse IP from DEVICE_LOCAL_ADDRESS_IPV6, exiting") + rootLogger.Error("Failed to parse IP from DEVICE_LOCAL_ADDRESS_IPV6, exiting") return } - slog.Info("Using the IPv6 Prefix to construct the IPv6 Address") + rootLogger.Info("Using the IPv6 Prefix to construct the IPv6 Address") } - startPollServer(updater.In, &localIp) - startPushServer(updater.In, &localIp, cancel) + bind := os.Getenv("METRICS_BIND") + pollStatus := polling.StartPollServer(updater.In, &localIp, rootLogger) + pushStatus := startPushServer(updater.In, &localIp, rootLogger, cancel) + status := util.Status{ + Push: pushStatus, + Poll: pollStatus, + Updates: updateStatus, + } + if bind != "" { + token := readSecret("METRICS_TOKEN") + startMetricsServer(bind, rootLogger, status, token, cancel) + } // Create a OS signal shutdown channel shutdown := make(chan os.Signal) @@ -52,53 +64,19 @@ func main() { // Wait for either the context to finish or the shutdown signal select { case <-ctx.Done(): - slog.Error("Context closed", logging.ErrorAttr(context.Cause(ctx))) + rootLogger.Error("Context closed", util.ErrorAttr(context.Cause(ctx))) os.Exit(1) case <-shutdown: break } - slog.Info("Shutdown detected") -} - -func newFritzBox() *avm.FritzBox { - fb := avm.NewFritzBox() - - // Import FritzBox endpoint url - endpointUrl := os.Getenv("FRITZBOX_ENDPOINT_URL") - - if endpointUrl != "" { - v, err := url.ParseRequestURI(endpointUrl) - - if err != nil { - slog.Error("Failed to parse env FRITZBOX_ENDPOINT_URL", logging.ErrorAttr(err)) - panic(err) - } - - fb.Url = strings.TrimRight(v.String(), "/") - } else { - slog.Info("Env FRITZBOX_ENDPOINT_URL not found, disabling FritzBox polling") - return nil - } - - // Import FritzBox endpoint timeout setting - endpointTimeout := os.Getenv("FRITZBOX_ENDPOINT_TIMEOUT") - - if endpointTimeout != "" { - v, err := time.ParseDuration(endpointTimeout) - - if err != nil { - slog.Warn("Failed to parse FRITZBOX_ENDPOINT_TIMEOUT, using defaults", logging.ErrorAttr(err)) - } else { - fb.Timeout = v - } - } - - return fb + rootLogger.Info("Shutdown detected") } -func newUpdater() *cloudflare.Updater { - u := cloudflare.NewUpdater(slog.Default()) +func newUpdater(logger *slog.Logger) (*cloudflare.Updater, []*util.UpdateStatus) { + const subsystem = "cf_updater" + logger = logger.With(util.SubsystemAttr(subsystem)) + u := cloudflare.NewUpdater(slog.Default().With(util.SubsystemAttr(subsystem)), subsystem) token := readSecret("CLOUDFLARE_API_TOKEN") email := os.Getenv("CLOUDFLARE_API_EMAIL") @@ -106,10 +84,10 @@ func newUpdater() *cloudflare.Updater { if token == "" { if email == "" || key == "" { - slog.Info("Env CLOUDFLARE_API_TOKEN not found, disabling Cloudflare updates") - return u + logger.Info("Env CLOUDFLARE_API_TOKEN not found, disabling Cloudflare updates", util.SubsystemAttr(subsystem)) + return u, nil } else { - slog.Warn("Using deprecated credentials via the API key") + logger.Warn("Using deprecated credentials via the API key") } } @@ -117,8 +95,8 @@ func newUpdater() *cloudflare.Updater { ipv6Zone := os.Getenv("CLOUDFLARE_ZONES_IPV6") if ipv4Zone == "" && ipv6Zone == "" { - slog.Warn("Env CLOUDFLARE_ZONES_IPV4 and CLOUDFLARE_ZONES_IPV6 not found, disabling Cloudflare updates") - return u + logger.Warn("Env CLOUDFLARE_ZONES_IPV4 and CLOUDFLARE_ZONES_IPV6 not found, disabling Cloudflare updates", util.SubsystemAttr(subsystem)) + return u, nil } if ipv4Zone != "" { @@ -130,147 +108,115 @@ func newUpdater() *cloudflare.Updater { } var err error + var status []*util.UpdateStatus if token != "" { - err = u.InitWithToken(token) + err, status = u.InitWithToken(token) } else { - err = u.InitWithKey(email, key) + err, status = u.InitWithKey(email, key) } if err != nil { - slog.Error("Failed to init Cloudflare updater, disabling Cloudflare updates") - return u + logger.Error("Failed to init Cloudflare updater, disabling Cloudflare updates") + os.Exit(1) } - return u + return u, status } -func startPushServer(out chan<- *net.IP, localIp *net.IP, cancel context.CancelCauseFunc) { +func startPushServer(out chan<- *net.IP, localIp *net.IP, logger *slog.Logger, cancel context.CancelCauseFunc) *util.PushStatus { + const subsystem = "push_server" + logger = logger.With(util.SubsystemAttr(subsystem)) bind := os.Getenv("DYNDNS_SERVER_BIND") if bind == "" { - slog.Info("Env DYNDNS_SERVER_BIND not found, disabling DynDns server") - return + logger.Info("Env DYNDNS_SERVER_BIND not found, disabling DynDns server") + return nil } - server := dyndns.NewServer(out, localIp, slog.Default()) + status := util.PushStatus{ + Succeeded: true, + } + + server := dyndns.NewServer(out, localIp, logger, subsystem, &status) server.Username = os.Getenv("DYNDNS_SERVER_USERNAME") server.Password = readSecret("DYNDNS_SERVER_PASSWORD") + pushMux := http.NewServeMux() + + pushMux.HandleFunc("/ip", server.Handler) + s := &http.Server{ Addr: bind, - ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError), + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + Handler: pushMux, } - http.HandleFunc("/ip", server.Handler) - go func() { err := s.ListenAndServe() cancel(errors.Join(errors.New("http server error"), err)) }() -} - -func startPollServer(out chan<- *net.IP, localIp *net.IP) { - fritzbox := newFritzBox() - - // Import endpoint polling interval duration - interval := os.Getenv("FRITZBOX_ENDPOINT_INTERVAL") - useIpv4 := os.Getenv("CLOUDFLARE_ZONES_IPV4") != "" - useIpv6 := os.Getenv("CLOUDFLARE_ZONES_IPV6") != "" - var ticker *time.Ticker + logger.Info("DynDns server started", slog.String("addr", bind)) - if interval != "" { - v, err := time.ParseDuration(interval) - - if err != nil { - slog.Warn("Failed to parse FRITZBOX_ENDPOINT_INTERVAL, using defaults", logging.ErrorAttr(err)) - ticker = time.NewTicker(300 * time.Second) - } else { - ticker = time.NewTicker(v) - } - } else { - slog.Info("Env FRITZBOX_ENDPOINT_INTERVAL not found, disabling polling") - return - } + return &status +} - go func() { - lastV4 := net.IP{} - lastV6 := net.IP{} - - poll := func() { - slog.Debug("Polling WAN IPs from router") - - if useIpv4 { - ipv4, err := fritzbox.GetWanIpv4() - - if err != nil { - slog.Warn("Failed to poll WAN IPv4 from router", logging.ErrorAttr(err)) - } else { - out <- &ipv4 - if !lastV4.Equal(ipv4) { - slog.Info("New WAN IPv4 found", slog.Any("ipv4", ipv4)) - lastV4 = ipv4 - } +func startMetricsServer(bind string, logger *slog.Logger, status util.Status, token string, cancel context.CancelCauseFunc) { + const subsystem = "metrics" + logger = logger.With(util.SubsystemAttr(subsystem)) + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + metricsMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if status.Poll != nil && !status.Poll.Succeeded { + w.WriteHeader(http.StatusServiceUnavailable) + } else if status.Push != nil && !status.Push.Succeeded { + w.WriteHeader(http.StatusServiceUnavailable) + } else if status.Updates != nil { + anyUnsuccessful := false + for _, u := range status.Updates { + if !u.Succeeded { + anyUnsuccessful = true + break } } - if *localIp == nil && useIpv6 { - ipv6, err := fritzbox.GetwanIpv6() - - if err != nil { - slog.Warn("Failed to poll WAN IPv6 from router", logging.ErrorAttr(err)) - } else { - if !lastV6.Equal(ipv6) { - slog.Info("New WAN IPv6 found", slog.Any("ipv6", ipv6)) - out <- &ipv6 - lastV6 = ipv6 - } - } - } else if useIpv6 { - prefix, err := fritzbox.GetIpv6Prefix() - - if err != nil { - slog.Warn("Failed to poll IPv6 Prefix from router", logging.ErrorAttr(err)) - } else { - constructedIp := make(net.IP, net.IPv6len) - copy(constructedIp, prefix.IP) - - maskLen, _ := prefix.Mask.Size() - - for i := 0; i < net.IPv6len; i++ { - b := constructedIp[i] - lb := (*localIp)[i] - var mask byte = 0b00000000 - for j := 0; j < 8; j++ { - if (i*8 + j) >= maskLen { - mask += 0b00000001 << (7 - j) - } - } - b += lb & mask - constructedIp[i] = b - } - - slog.Info("New IPv6 Prefix found", slog.Any("prefix", prefix), slog.Any("ipv6", constructedIp)) - - out <- &constructedIp - - if !lastV6.Equal(prefix.IP) { - lastV6 = prefix.IP - } - } + if anyUnsuccessful { + w.WriteHeader(http.StatusServiceUnavailable) + } else { + w.WriteHeader(http.StatusOK) } } + encoder := json.NewEncoder(w) + err := encoder.Encode(status) + if err != nil { + logger.Error("Failed to encode health check response", util.ErrorAttr(err)) + return + } + }) + metricsMux.HandleFunc("/liveness", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) - poll() + metricServer := &http.Server{ + Addr: bind, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } - for { - select { - case <-ticker.C: - poll() - } - } + if token == "" { + metricServer.Handler = metricsMux + } else { + tokenHandler := util.NewTokenHandler(metricsMux, token) + metricServer.Handler = tokenHandler + } + + go func() { + err := metricServer.ListenAndServe() + cancel(errors.Join(errors.New("metrics http server error"), err)) }() + + logger.Info("metrics server started", slog.String("addr", bind)) } func readSecret(envName string) string { @@ -285,7 +231,7 @@ func readSecret(envName string) string { if passwordFilePath != "" { content, err := os.ReadFile(passwordFilePath) if err != nil { - slog.Error("Failed to read secret from file "+passwordFilePath, logging.ErrorAttr(err)) + slog.Error("Failed to read secret from file "+passwordFilePath, util.ErrorAttr(err)) } else { secret = strings.TrimSuffix(strings.TrimSuffix(string(content), "\r\n"), "\n") } diff --git a/pkg/avm/fritzbox.go b/pkg/avm/fritzbox.go index bd146b9..1f78ea4 100644 --- a/pkg/avm/fritzbox.go +++ b/pkg/avm/fritzbox.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "log/slog" "net" "net/http" "time" @@ -12,12 +13,14 @@ import ( type FritzBox struct { Url string Timeout time.Duration + Logger *slog.Logger } -func NewFritzBox() *FritzBox { +func NewFritzBox(logger *slog.Logger) *FritzBox { return &FritzBox{ Url: "http://fritz.box:49000", Timeout: 5 * time.Second, + Logger: logger, } } diff --git a/pkg/cloudflare/updater.go b/pkg/cloudflare/updater.go index 5c28cb3..982f410 100644 --- a/pkg/cloudflare/updater.go +++ b/pkg/cloudflare/updater.go @@ -4,7 +4,9 @@ import ( "context" "fmt" cf "github.com/cloudflare/cloudflare-go" - "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/logging" + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "golang.org/x/net/publicsuffix" "log/slog" "net" @@ -15,7 +17,10 @@ import ( type Action struct { DnsRecord string CfZoneId string - IpVersion int + IpVersion uint8 + + updates prometheus.Summary + status *util.UpdateStatus } type Updater struct { @@ -32,18 +37,31 @@ type Updater struct { lastIpv4 *net.IP lastIpv6 *net.IP + + subsystem string } -func NewUpdater(log *slog.Logger) *Updater { +func NewUpdater(log *slog.Logger, subsystem string) *Updater { return &Updater{ isInit: false, In: make(chan *net.IP, 10), log: log.With(slog.String("module", "cloudflare")), ipv4Zones: make([]string, 0), ipv6Zones: make([]string, 0), + subsystem: subsystem, } } +func (u Updater) makeSummary(labels prometheus.Labels) prometheus.Summary { + return promauto.NewSummary(prometheus.SummaryOpts{ + Subsystem: util.MakePromSubsystem(u.subsystem), + Name: "update_seconds", + Help: "A summary of the push server executions", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + ConstLabels: labels, + }) +} + func (u *Updater) SetIPv4Zones(zones string) { u.ipv4Zones = strings.Split(zones, ",") } @@ -52,27 +70,27 @@ func (u *Updater) SetIPv6Zones(zones string) { u.ipv6Zones = strings.Split(zones, ",") } -func (u *Updater) InitWithToken(token string) error { +func (u *Updater) InitWithToken(token string) (error, []*util.UpdateStatus) { api, err := cf.NewWithAPIToken(token) if err != nil { - return err + return err, nil } return u.init(api) } -func (u *Updater) InitWithKey(email string, key string) error { +func (u *Updater) InitWithKey(email string, key string) (error, []*util.UpdateStatus) { api, err := cf.New(key, email) if err != nil { - return err + return err, nil } return u.init(api) } -func (u *Updater) init(api *cf.API) error { +func (u *Updater) init(api *cf.API) (error, []*util.UpdateStatus) { // Create unique list of zones and fetch their Cloudflare zone IDs zoneIdMap := make(map[string]string) @@ -88,34 +106,52 @@ func (u *Updater) init(api *cf.API) error { zone, err := publicsuffix.EffectiveTLDPlusOne(val) if err != nil { - return err + return err, nil } id, err := api.ZoneIDByName(zone) if err != nil { - return err + return err, nil } zoneIdMap[val] = id } + statusVec := []*util.UpdateStatus{} + // Now create an updater action list for _, val := range u.ipv4Zones { + zoneId := zoneIdMap[val] + labels := prometheus.Labels{"record": val, "ip_version": "4"} + updates := u.makeSummary(labels) + status := util.UpdateStatus{Domain: val, IpVersion: 4, Succeeded: true} + statusVec = append(statusVec, &status) + a := &Action{ DnsRecord: val, - CfZoneId: zoneIdMap[val], + CfZoneId: zoneId, IpVersion: 4, + updates: updates, + status: &status, } u.actions = append(u.actions, a) } for _, val := range u.ipv6Zones { + zoneId := zoneIdMap[val] + labels := prometheus.Labels{"record": val, "ip_version": "6"} + updates := u.makeSummary(labels) + status := util.UpdateStatus{Domain: val, IpVersion: 4, Succeeded: true} + statusVec = append(statusVec, &status) + a := &Action{ DnsRecord: val, - CfZoneId: zoneIdMap[val], + CfZoneId: zoneId, IpVersion: 6, + updates: updates, + status: &status, } u.actions = append(u.actions, a) @@ -124,7 +160,7 @@ func (u *Updater) init(api *cf.API) error { u.api = api u.isInit = true - return nil + return nil, statusVec } func (u *Updater) StartWorker() { @@ -151,6 +187,8 @@ func (u *Updater) spawnWorker() { u.log.Info("Received update request", slog.Any("ip", ip)) for _, action := range u.actions { + timer := prometheus.NewTimer(action.updates) + // Skip IPv6 action mismatching IP version if ip.To4() == nil && action.IpVersion != 6 { continue @@ -184,7 +222,9 @@ func (u *Updater) spawnWorker() { }) if err != nil { - alog.Error("Action failed, could not research DNS records", logging.ErrorAttr(err)) + alog.Error("Action failed, could not research DNS records", util.ErrorAttr(err)) + action.status.Last = time.Now() + action.status.Succeeded = false continue } @@ -203,7 +243,9 @@ func (u *Updater) spawnWorker() { }) if err != nil { - alog.Error("Action failed, could not create DNS record", logging.ErrorAttr(err)) + alog.Error("Action failed, could not create DNS record", util.ErrorAttr(err)) + action.status.Last = time.Now() + action.status.Succeeded = false continue } } @@ -213,6 +255,8 @@ func (u *Updater) spawnWorker() { alog.Info("Updating DNS record", slog.Any("record-id", record.ID)) if record.Content == ip.String() { + action.status.Last = time.Now() + action.status.Succeeded = true continue } @@ -226,12 +270,19 @@ func (u *Updater) spawnWorker() { }) if err != nil { - alog.Error("Action failed, could not update DNS record", logging.ErrorAttr(err)) + alog.Error("Action failed, could not update DNS record", util.ErrorAttr(err)) + action.status.Last = time.Now() + action.status.Succeeded = false continue } } cancel() + + action.status.Last = time.Now() + action.status.Succeeded = true + + timer.ObserveDuration() } if ip.To4() == nil { diff --git a/pkg/dyndns/server.go b/pkg/dyndns/server.go index 89c8e05..ec21897 100644 --- a/pkg/dyndns/server.go +++ b/pkg/dyndns/server.go @@ -1,26 +1,39 @@ package dyndns import ( - "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/logging" + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "log/slog" "net" "net/http" + "time" ) type Server struct { - log *slog.Logger - out chan<- *net.IP - localIp *net.IP + log *slog.Logger + out chan<- *net.IP + localIp *net.IP + pushExecutions prometheus.Summary + status *util.PushStatus Username string Password string } -func NewServer(out chan<- *net.IP, localIp *net.IP, log *slog.Logger) *Server { +func NewServer(out chan<- *net.IP, localIp *net.IP, log *slog.Logger, subsystem string, status *util.PushStatus) *Server { + pushExecutions := promauto.NewSummary(prometheus.SummaryOpts{ + Subsystem: util.MakePromSubsystem(subsystem), + Name: "execution_seconds", + Help: "A summary of the push server executions", + Objectives: map[float64]float64{0: 0, 0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 1: 1}, + }) return &Server{ - log: log.With(slog.String("module", "dyndns")), - out: out, - localIp: localIp, + log: log.With(slog.String("module", "dyndns")), + out: out, + localIp: localIp, + pushExecutions: pushExecutions, + status: status, } } @@ -36,63 +49,103 @@ func NewServer(out chan<- *net.IP, localIp *net.IP, log *slog.Logger) *Server { // // see https://service.avm.de/help/de/FRITZ-Box-Fon-WLAN-7490/016/hilfe_dyndns func (s *Server) Handler(w http.ResponseWriter, r *http.Request) { + s.status.Last = time.Now() + timer := prometheus.NewTimer(s.pushExecutions) + defer func() { + timer.ObserveDuration() + }() + success := true params := r.URL.Query() + defer func() { + s.status.Succeeded = success + }() + s.log.Info("Received incoming DynDNS update") if params.Get("username") != s.Username { s.log.Warn("Rejected due to username mismatch") + success = false + w.WriteHeader(http.StatusUnauthorized) return } if params.Get("password") != s.Password { s.log.Warn("Rejected due to password mismatch") + success = false + w.WriteHeader(http.StatusUnauthorized) return } // Parse IPv4 - ipv4 := net.ParseIP(params.Get("v4")) - if ipv4 != nil && ipv4.To4() != nil { - s.log.Info("Forwarding update request for IPv4", slog.Any("ipv4", ipv4)) - s.out <- &ipv4 + v4Str := params.Get("v4") + if v4Str == "" { + s.log.Warn("No IPv4 can be set as the `v4` parameter was not supplied") + } else { + ipv4 := net.ParseIP(v4Str) + if ipv4 != nil && ipv4.To4() != nil { + s.log.Info("Forwarding update request for IPv4", slog.Any("ipv4", ipv4)) + s.out <- &ipv4 + } else { + s.log.Warn("Failed to parse IPv4 address", slog.String("input", v4Str)) + success = false + } } if *s.localIp == nil { // Parse IPv6 - ipv6 := net.ParseIP(params.Get("v6")) - if ipv6 != nil && ipv6.To4() == nil { - s.log.Info("Forwarding update request for IPv6", slog.Any("ipv6", ipv6)) - s.out <- &ipv6 + v6Str := params.Get("v6") + if v6Str == "" { + s.log.Warn("No IPv6 can be set as the `v6` parameter was not supplied") + } else { + ipv6 := net.ParseIP(v6Str) + if ipv6 != nil && ipv6.To4() == nil { + s.log.Info("Forwarding update request for IPv6", slog.Any("ipv6", ipv6)) + s.out <- &ipv6 + } else { + s.log.Warn("Failed to parse IPv6 address", slog.String("input", v6Str)) + success = false + } } } else { // Parse Prefix - _, prefix, err := net.ParseCIDR(params.Get("prefix")) - if err != nil { - s.log.Warn("Failed to parse prefix", slog.Any("prefix", prefix), logging.ErrorAttr(err)) + prefixStr := params.Get("prefix") + if prefixStr == "" { + s.log.Warn("No IPv6 can be calculated and set as the `prefix` parameter was not supplied") } else { + _, prefix, err := net.ParseCIDR(prefixStr) + if err != nil { + s.log.Warn("Failed to parse prefix", slog.String("input", prefixStr), util.ErrorAttr(err)) + success = false + } else { - constructedIp := make(net.IP, net.IPv6len) - copy(constructedIp, prefix.IP) + constructedIp := make(net.IP, net.IPv6len) + copy(constructedIp, prefix.IP) - maskLen, _ := prefix.Mask.Size() + maskLen, _ := prefix.Mask.Size() - for i := 0; i < net.IPv6len; i++ { - b := constructedIp[i] - lb := (*s.localIp)[i] - var mask byte = 0b00000000 - for j := 0; j < 8; j++ { - if (i*8 + j) >= maskLen { - mask += 0b00000001 << (7 - j) + for i := 0; i < net.IPv6len; i++ { + b := constructedIp[i] + lb := (*s.localIp)[i] + var mask byte = 0b00000000 + for j := 0; j < 8; j++ { + if (i*8 + j) >= maskLen { + mask += 0b00000001 << (7 - j) + } } + b += lb & mask + constructedIp[i] = b } - b += lb & mask - constructedIp[i] = b - } - s.log.Info("Forwarding update request for IPv6", slog.Any("prefix", prefix), slog.Any("ipv6", constructedIp)) - s.out <- &constructedIp + s.log.Info("Forwarding update request for IPv6", slog.Any("prefix", prefix), slog.Any("ipv6", constructedIp)) + s.out <- &constructedIp + } } } - w.WriteHeader(200) + if success { + w.WriteHeader(http.StatusAccepted) + } else { + w.WriteHeader(http.StatusBadRequest) + } } diff --git a/pkg/logging/constants.go b/pkg/logging/constants.go deleted file mode 100644 index 0466223..0000000 --- a/pkg/logging/constants.go +++ /dev/null @@ -1,3 +0,0 @@ -package logging - -const ErrorKey = "error" diff --git a/pkg/logging/helpers.go b/pkg/logging/helpers.go deleted file mode 100644 index 20c4c0a..0000000 --- a/pkg/logging/helpers.go +++ /dev/null @@ -1,7 +0,0 @@ -package logging - -import "log/slog" - -func ErrorAttr(value error) slog.Attr { - return slog.Any(ErrorKey, value) -} diff --git a/pkg/polling/pollserver.go b/pkg/polling/pollserver.go new file mode 100644 index 0000000..183d4f7 --- /dev/null +++ b/pkg/polling/pollserver.go @@ -0,0 +1,193 @@ +package polling + +import ( + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/avm" + "github.com/cromefire/fritzbox-cloudflare-dyndns/pkg/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "log/slog" + "net" + "net/url" + "os" + "strings" + "time" +) + +func StartPollServer(out chan<- *net.IP, localIp *net.IP, logger *slog.Logger) *util.PollStatus { + const subsystem = "fritzbox_polling" + logger = logger.With(util.SubsystemAttr(subsystem)) + fritzbox := newFritzBox(logger) + + // Import endpoint polling interval duration + interval := os.Getenv("FRITZBOX_ENDPOINT_INTERVAL") + useIpv4 := os.Getenv("CLOUDFLARE_ZONES_IPV4") != "" + useIpv6 := os.Getenv("CLOUDFLARE_ZONES_IPV6") != "" + + var ticker *time.Ticker + + if interval != "" { + v, err := time.ParseDuration(interval) + + if err != nil { + logger.Warn("Failed to parse FRITZBOX_ENDPOINT_INTERVAL, using defaults", util.ErrorAttr(err)) + ticker = time.NewTicker(300 * time.Second) + } else { + ticker = time.NewTicker(v) + } + } else { + logger.Info("Env FRITZBOX_ENDPOINT_INTERVAL not found, disabling polling") + return nil + } + + status := util.PollStatus{Succeeded: true} + + go func() { + lastV4 := net.IP{} + lastV6 := net.IP{} + + pollExecutionsUnchanged := promauto.NewSummary(prometheus.SummaryOpts{ + Subsystem: util.MakePromSubsystem(subsystem), + Name: "execution_seconds", + Help: "A summary of the poll server executions", + Objectives: map[float64]float64{0: 0, 0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 1: 1}, + ConstLabels: prometheus.Labels{"changed": "false"}, + }) + pollExecutionsChanged := promauto.NewSummary(prometheus.SummaryOpts{ + Subsystem: util.MakePromSubsystem(subsystem), + Name: "execution_seconds", + Help: "A summary of the poll server executions", + Objectives: map[float64]float64{0: 0, 0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 1: 1}, + ConstLabels: prometheus.Labels{"changed": "true"}, + }) + + poll := func() { + success := true + changed := false + logger.Debug("Polling WAN IPs from router") + timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { + if changed { + pollExecutionsChanged.Observe(v) + } else { + pollExecutionsUnchanged.Observe(v) + } + })) + defer func() { + timer.ObserveDuration() + status.Succeeded = success + status.Last = time.Now() + }() + + if useIpv4 { + ipv4, err := fritzbox.GetWanIpv4() + + if err != nil { + logger.Warn("Failed to poll WAN IPv4 from router", util.ErrorAttr(err)) + success = false + } else { + if !lastV4.Equal(ipv4) { + changed = true + logger.Info("New WAN IPv4 found", slog.Any("ipv4", ipv4)) + out <- &ipv4 + lastV4 = ipv4 + } + } + } + + if *localIp == nil && useIpv6 { + ipv6, err := fritzbox.GetwanIpv6() + + if err != nil { + logger.Warn("Failed to poll WAN IPv6 from router", util.ErrorAttr(err)) + success = false + } else { + if !lastV6.Equal(ipv6) { + changed = true + logger.Info("New WAN IPv6 found", slog.Any("ipv6", ipv6)) + out <- &ipv6 + lastV6 = ipv6 + } + } + } else if useIpv6 { + prefix, err := fritzbox.GetIpv6Prefix() + + if err != nil { + logger.Warn("Failed to poll IPv6 Prefix from router", util.ErrorAttr(err)) + success = false + } else { + constructedIp := make(net.IP, net.IPv6len) + copy(constructedIp, prefix.IP) + + maskLen, _ := prefix.Mask.Size() + + for i := 0; i < net.IPv6len; i++ { + b := constructedIp[i] + lb := (*localIp)[i] + var mask byte = 0b00000000 + for j := 0; j < 8; j++ { + if (i*8 + j) >= maskLen { + mask += 0b00000001 << (7 - j) + } + } + b += lb & mask + constructedIp[i] = b + } + + if !lastV6.Equal(prefix.IP) { + changed = true + logger.Info("New IPv6 Prefix found", slog.Any("prefix", prefix), slog.Any("ipv6", constructedIp)) + out <- &constructedIp + lastV6 = prefix.IP + } + } + } + } + + poll() + + for { + select { + case <-ticker.C: + poll() + } + } + }() + + return &status +} + +func newFritzBox(logger *slog.Logger) *avm.FritzBox { + fb := avm.NewFritzBox(logger) + + // Import FritzBox endpoint url + endpointUrl := os.Getenv("FRITZBOX_ENDPOINT_URL") + + if endpointUrl != "" { + v, err := url.ParseRequestURI(endpointUrl) + + if err != nil { + logger.Error("Failed to parse env FRITZBOX_ENDPOINT_URL", util.ErrorAttr(err)) + panic(err) + } + + fb.Url = strings.TrimRight(v.String(), "/") + fb.Url = strings.TrimRight(v.String(), "/") + } else { + logger.Info("Env FRITZBOX_ENDPOINT_URL not found, disabling FritzBox polling") + return nil + } + + // Import FritzBox endpoint timeout setting + endpointTimeout := os.Getenv("FRITZBOX_ENDPOINT_TIMEOUT") + + if endpointTimeout != "" { + v, err := time.ParseDuration(endpointTimeout) + + if err != nil { + logger.Warn("Failed to parse FRITZBOX_ENDPOINT_TIMEOUT, using defaults", util.ErrorAttr(err)) + } else { + fb.Timeout = v + } + } + + return fb +} diff --git a/pkg/util/constants.go b/pkg/util/constants.go new file mode 100644 index 0000000..f2f8775 --- /dev/null +++ b/pkg/util/constants.go @@ -0,0 +1,5 @@ +package util + +const ErrorKey = "error" +const SubsystemKey = "subsys" +const SubsystemPrefix = "dyndns" diff --git a/pkg/util/helpers.go b/pkg/util/helpers.go new file mode 100644 index 0000000..14a678f --- /dev/null +++ b/pkg/util/helpers.go @@ -0,0 +1,11 @@ +package util + +import "log/slog" + +func ErrorAttr(value error) slog.Attr { + return slog.Any(ErrorKey, value) +} + +func SubsystemAttr(value string) slog.Attr { + return slog.Any(SubsystemKey, value) +} diff --git a/pkg/util/http.go b/pkg/util/http.go new file mode 100644 index 0000000..1205c5a --- /dev/null +++ b/pkg/util/http.go @@ -0,0 +1,25 @@ +package util + +import "net/http" + +type TokenHandler struct { + h http.Handler + token string +} + +func NewTokenHandler(h http.Handler, token string) TokenHandler { + return TokenHandler{ + h: h, + token: token, + } +} + +func (t TokenHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + tokenParam := request.URL.Query().Get("token") + + if tokenParam != t.token { + http.Error(writer, "invalid token", http.StatusUnauthorized) + } else { + t.h.ServeHTTP(writer, request) + } +} diff --git a/pkg/util/metrics.go b/pkg/util/metrics.go new file mode 100644 index 0000000..8e7298e --- /dev/null +++ b/pkg/util/metrics.go @@ -0,0 +1,30 @@ +package util + +import "time" + +func MakePromSubsystem(subsystem string) string { + return SubsystemPrefix + "_" + subsystem +} + +type Status struct { + Push *PushStatus `json:"push"` + Poll *PollStatus `json:"poll"` + Updates []*UpdateStatus `json:"updates"` +} + +type PushStatus struct { + Last time.Time `json:"last"` + Succeeded bool `json:"succeeded"` +} + +type PollStatus struct { + Last time.Time `json:"last"` + Succeeded bool `json:"succeeded"` +} + +type UpdateStatus struct { + Last time.Time `json:"last"` + Domain string `json:"domain"` + IpVersion uint8 `json:"ipVersion"` + Succeeded bool `json:"succeeded"` +}