diff --git a/README.md b/README.md index 4006fbb..bd4cc84 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ key: password - name: LOG_LEVEL value: debug + livenessProbe: + httpGet: + path: /healthz + port: http-wh-metrics + initialDelaySeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /readyz + port: http-wh-metrics + initialDelaySeconds: 10 + timeoutSeconds: 5 policy: sync sources: ["ingress", "service"] txtOwnerId: default diff --git a/cmd/webhook/init/configuration/configuration.go b/cmd/webhook/init/configuration/configuration.go index 8e81f7c..fd5a17d 100644 --- a/cmd/webhook/init/configuration/configuration.go +++ b/cmd/webhook/init/configuration/configuration.go @@ -9,7 +9,7 @@ import ( // Config struct for configuration environmental variables type Config struct { - ServerHost string `env:"SERVER_HOST" envDefault:"0.0.0.0"` + ServerHost string `env:"SERVER_HOST" envDefault:"localhost"` ServerPort int `env:"SERVER_PORT" envDefault:"8888"` ServerReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT"` ServerWriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT"` diff --git a/cmd/webhook/init/server/server.go b/cmd/webhook/init/server/server.go index f21d8e0..ab356da 100644 --- a/cmd/webhook/init/server/server.go +++ b/cmd/webhook/init/server/server.go @@ -13,33 +13,53 @@ import ( "github.com/go-chi/chi/v5" "github.com/kashalls/external-dns-provider-unifi/cmd/webhook/init/configuration" "github.com/kashalls/external-dns-provider-unifi/pkg/webhook" + "github.com/prometheus/client_golang/prometheus/promhttp" log "github.com/sirupsen/logrus" ) -// Init server initialization function -// The server will respond to the following endpoints: -// - / (GET): initialization, negotiates headers and returns the domain filter -// - /records (GET): returns the current records -// - /records (POST): applies the changes -// - /adjustendpoints (POST): executes the AdjustEndpoints method -func Init(config configuration.Config, p *webhook.Webhook) *http.Server { - r := chi.NewRouter() +// HealthCheckHandler returns the status of the service +func HealthCheckHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// ReadinessHandler returns whether the service is ready to accept requests +func ReadinessHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// Init initializes the http server +func Init(config configuration.Config, p *webhook.Webhook) (*http.Server, *http.Server) { + mainRouter := chi.NewRouter() + mainRouter.Get("/", p.Negotiate) + mainRouter.Get("/records", p.Records) + mainRouter.Post("/records", p.ApplyChanges) + mainRouter.Post("/adjustendpoints", p.AdjustEndpoints) + + mainServer := createHTTPServer(fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort), mainRouter, config.ServerReadTimeout, config.ServerWriteTimeout) + go func() { + log.Infof("starting server on addr: '%s' ", mainServer.Addr) + if err := mainServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("can't serve on addr: '%s', error: %v", mainServer.Addr, err) + } + }() - r.Use(webhook.Health) - r.Get("/", p.Negotiate) - r.Get("/records", p.Records) - r.Post("/records", p.ApplyChanges) - r.Post("/adjustendpoints", p.AdjustEndpoints) + healthRouter := chi.NewRouter() + healthRouter.Get("/metrics", promhttp.Handler().ServeHTTP) + healthRouter.Get("/healthz", HealthCheckHandler) + healthRouter.Get("/readyz", ReadinessHandler) - srv := createHTTPServer(fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort), r, config.ServerReadTimeout, config.ServerWriteTimeout) + healthServer := createHTTPServer("0.0.0.0:8080", healthRouter, config.ServerReadTimeout, config.ServerWriteTimeout) go func() { - log.Infof("starting server on addr: '%s' ", srv.Addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Errorf("can't serve on addr: '%s', error: %v", srv.Addr, err) + log.Infof("starting health server on addr: '%s' ", healthServer.Addr) + if err := healthServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("can't serve health on addr: '%s', error: %v", healthServer.Addr, err) } }() - return srv + + return mainServer, healthServer } func createHTTPServer(addr string, hand http.Handler, readTimeout, writeTimeout time.Duration) *http.Server { @@ -52,15 +72,20 @@ func createHTTPServer(addr string, hand http.Handler, readTimeout, writeTimeout } // ShutdownGracefully gracefully shutdown the http server -func ShutdownGracefully(srv *http.Server) { +func ShutdownGracefully(mainServer *http.Server, healthServer *http.Server) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) sig := <-sigCh - log.Infof("shutting down server due to received signal: %v", sig) + log.Infof("shutting down servers due to received signal: %v", sig) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - if err := srv.Shutdown(ctx); err != nil { - log.Errorf("error shutting down server: %v", err) + defer cancel() + + if err := mainServer.Shutdown(ctx); err != nil { + log.Errorf("error shutting down main server: %v", err) + } + + if err := healthServer.Shutdown(ctx); err != nil { + log.Errorf("error shutting down health server: %v", err) } - cancel() } diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index ed0e8b7..f8a55ca 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -33,6 +33,6 @@ func main() { log.Fatalf("failed to initialize provider: %v", err) } - srv := server.Init(config, webhook.New(provider)) - server.ShutdownGracefully(srv) + main, health := server.Init(config, webhook.New(provider)) + server.ShutdownGracefully(main, health) } diff --git a/go.mod b/go.mod index c1e466f..e77bfbe 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.22.3 require ( github.com/caarlos0/env/v11 v11.0.1 github.com/go-chi/chi/v5 v5.0.12 + github.com/prometheus/client_golang v1.19.1 github.com/sirupsen/logrus v1.9.3 golang.org/x/net v0.25.0 sigs.k8s.io/external-dns v0.14.2 @@ -14,6 +15,8 @@ require ( require ( github.com/aws/aws-sdk-go v1.53.9 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -23,9 +26,12 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.53.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/apimachinery v0.30.1 // indirect diff --git a/go.sum b/go.sum index 7a2b092..3e2106c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/aws/aws-sdk-go v1.53.9 h1:6oipls9+L+l2Me5rklqlX3xGWNWGcMinY3F69q9Q+Cg= github.com/aws/aws-sdk-go v1.53.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +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/caarlos0/env/v11 v11.0.1 h1:A8dDt9Ub9ybqRSUF3fQc/TA/gTam2bKT4Pit+cwrsPs= github.com/caarlos0/env/v11 v11.0.1/go.mod h1:2RC3HQu8BQqtEK3V4iHPxj0jOdWdbPpWJ6pOueeU1xM= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,11 +31,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -42,6 +43,14 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +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.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= +github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -89,6 +98,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/internal/unifi/client.go b/internal/unifi/client.go index 0b2e675..c5a1255 100644 --- a/internal/unifi/client.go +++ b/internal/unifi/client.go @@ -23,9 +23,8 @@ type httpClient struct { } const ( - unifiLoginPath = "%s/api/auth/login" - unifiRecordsPath = "%s/proxy/network/v2/api/site/%s/static-dns" - unifiRecordPath = "%s/proxy/network/v2/api/site/%s/static-dns/%s" + unifiLoginPath = "%s/api/auth/login" + unifiRecordPath = "%s/proxy/network/v2/api/site/%s/static-dns/%s" ) // newUnifiClient creates a new DNS provider client and logs in to store cookies. @@ -55,14 +54,21 @@ func newUnifiClient(config *Config) (*httpClient, error) { // login performs a login request to the UniFi controller. func (c *httpClient) login() error { - // Prepare the login request body - body, _ := json.Marshal(map[string]string{ - "username": c.Config.User, - "password": c.Config.Password, + jsonBody, err := json.Marshal(Login{ + Username: c.Config.User, + Password: c.Config.Password, + Remember: true, }) + if err != nil { + return err + } // Perform the login request - resp, err := c.doRequest(http.MethodPost, fmt.Sprintf(unifiLoginPath, c.Config.Host), bytes.NewBuffer(body)) + resp, err := c.doRequest( + http.MethodPost, + FormatUrl(unifiLoginPath, c.Config.Host), + bytes.NewBuffer(jsonBody), + ) if err != nil { return err } @@ -130,7 +136,11 @@ func (c *httpClient) doRequest(method, path string, body io.Reader) (*http.Respo // GetEndpoints retrieves the list of DNS records from the UniFi controller. func (c *httpClient) GetEndpoints() ([]DNSRecord, error) { - resp, err := c.doRequest(http.MethodGet, fmt.Sprintf(unifiRecordsPath, c.Config.Host, c.Config.Site), nil) + resp, err := c.doRequest( + http.MethodGet, + FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site), + nil, + ) if err != nil { return nil, err } @@ -155,9 +165,13 @@ func (c *httpClient) CreateEndpoint(endpoint *endpoint.Endpoint) (*DNSRecord, er Value: endpoint.Targets[0], }) if err != nil { - return nil, fmt.Errorf("failed to marshal DNS record: %w", err) + return nil, err } - resp, err := c.doRequest(http.MethodPost, fmt.Sprintf(unifiRecordsPath, c.Config.Host, c.Config.Site), bytes.NewReader(jsonBody)) + resp, err := c.doRequest( + http.MethodPost, + FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site), + bytes.NewReader(jsonBody), + ) if err != nil { return nil, err } @@ -174,20 +188,24 @@ func (c *httpClient) CreateEndpoint(endpoint *endpoint.Endpoint) (*DNSRecord, er // DeleteEndpoint deletes a DNS record from the UniFi controller. func (c *httpClient) DeleteEndpoint(endpoint *endpoint.Endpoint) error { - lookup, err := c.LookupIdentifier(endpoint.DNSName, endpoint.RecordType) + lookup, err := c.lookupIdentifier(endpoint.DNSName, endpoint.RecordType) if err != nil { return err } - if _, err = c.doRequest(http.MethodPost, fmt.Sprintf(unifiRecordPath, c.Config.Host, c.Config.Site, lookup.ID), nil); err != nil { + if _, err = c.doRequest( + http.MethodPost, + FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site, lookup.ID), + nil, + ); err != nil { return err } return nil } -// LookupIdentifier finds the ID of a DNS record in the UniFi controller. -func (c *httpClient) LookupIdentifier(key, recordType string) (*DNSRecord, error) { +// lookupIdentifier finds the ID of a DNS record in the UniFi controller. +func (c *httpClient) lookupIdentifier(key, recordType string) (*DNSRecord, error) { records, err := c.GetEndpoints() if err != nil { return nil, err @@ -199,7 +217,7 @@ func (c *httpClient) LookupIdentifier(key, recordType string) (*DNSRecord, error } } - return nil, fmt.Errorf("record not found") + return nil, err } // setHeaders sets the headers for the HTTP request. diff --git a/internal/unifi/types.go b/internal/unifi/types.go index a49e88d..391bc0b 100644 --- a/internal/unifi/types.go +++ b/internal/unifi/types.go @@ -2,7 +2,7 @@ package unifi import "sigs.k8s.io/external-dns/endpoint" -// Config holds configuration from environmental variables +// Config represents the configuration for the UniFi API. type Config struct { Host string `env:"UNIFI_HOST,notEmpty"` User string `env:"UNIFI_USER,notEmpty"` @@ -11,6 +11,13 @@ type Config struct { SkipTLSVerify bool `env:"UNIFI_SKIP_TLS_VERIFY" envDefault:"true"` } +// Login represents a login request to the UniFi API. +type Login struct { + Username string `json:"username"` + Password string `json:"password"` + Remember bool `json:"remember"` +} + // DNSRecord represents a DNS record in the UniFi API. type DNSRecord struct { ID string `json:"_id,omitempty"` diff --git a/internal/unifi/utils.go b/internal/unifi/utils.go new file mode 100644 index 0000000..515b530 --- /dev/null +++ b/internal/unifi/utils.go @@ -0,0 +1,14 @@ +package unifi + +import "strings" + +// FormatUrl formats a URL with the given parameters. +func FormatUrl(path string, params ...string) string { + segments := strings.Split(path, "%s") + for i, param := range params { + if param != "" { + segments[i] += param + } + } + return strings.Join(segments, "") +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index 78bfbc3..b16a5a9 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -17,7 +17,6 @@ const ( contentTypePlaintext = "text/plain" acceptHeader = "Accept" varyHeader = "Vary" - healthPath = "/healthz" logFieldRequestPath = "requestPath" logFieldRequestMethod = "requestMethod" logFieldError = "error" @@ -34,17 +33,6 @@ func New(provider provider.Provider) *Webhook { return &p } -// Health handles the health request -func Health(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == healthPath { - w.WriteHeader(http.StatusOK) - return - } - next.ServeHTTP(w, r) - }) -} - func (p *Webhook) contentTypeHeaderCheck(w http.ResponseWriter, r *http.Request) error { return p.headerCheck(true, w, r) }