From 8be4919c0d5b45d566b62b6e5bbc6fe118fb16b3 Mon Sep 17 00:00:00 2001 From: Cyb3r-Jak3 Date: Fri, 5 May 2023 20:21:37 -0400 Subject: [PATCH 1/2] Add support for analytics and new testing framework --- README.md | 2 +- go.mod | 8 + go.sum | 20 ++ nextdns/analytics.go | 510 ++++++++++++++++++++++++++++ nextdns/analytics_test.go | 620 ++++++++++++++++++++++++++++++++++ nextdns/client.go | 16 +- nextdns/client_test.go | 58 ++++ nextdns/errors.go | 2 + nextdns/rewrites.go | 2 +- nextdns/security_tlds.go | 2 +- nextdns/security_tlds_test.go | 66 ++++ 11 files changed, 1302 insertions(+), 4 deletions(-) create mode 100644 nextdns/analytics.go create mode 100644 nextdns/analytics_test.go create mode 100644 nextdns/client_test.go create mode 100644 nextdns/security_tlds_test.go diff --git a/README.md b/README.md index e2ca80e..2927f2e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The [official API documentation](https://nextdns.github.io/api/) was the base do APIs supported by this package: - [x] Profile (`/profiles` and `/profiles/:profile`) -- [ ] Analytics (`/profiles/:profile/analytics`) +- [x] Analytics (`/profiles/:profile/analytics`) - [ ] Logs (`/profiles/:profile/logs`) ## Usage diff --git a/go.mod b/go.mod index db8ae8c..a12f35b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,14 @@ module github.com/amalucelli/nextdns-go go 1.19 require ( + github.com/google/go-querystring v1.1.0 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/matryer/is v1.4.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.8.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index d8e90f6..89f981c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,24 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nextdns/analytics.go b/nextdns/analytics.go new file mode 100644 index 0000000..ec2adbb --- /dev/null +++ b/nextdns/analytics.go @@ -0,0 +1,510 @@ +package nextdns + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +const ( + // analyticsAPIPath is the HTTP path for the denylist API. + analyticsAPIPath = "/analytics" + // UnidentifiedDevice is used to filter against unidentified devices. + UnidentifiedDevice = "__UNIDENTIFIED__" +) + +var ErrMissingProfile = errors.New("missing profile is required") + +// analyticsService represents the NextDNS denylist service. +type analyticsService struct { + client *Client +} + +type BaseResponse struct { + Meta struct { + Pagination struct { + Cursor string `json:"cursor,omitempty"` + } `json:"pagination,omitempty"` + } `json:"meta,omitempty"` + Errors ErrorResponse `json:"errors,omitempty"` +} + +var _ AnalyticsService = &analyticsService{} + +// NewAnalyticsService returns a new NextDNS denylist service. +// nolint: revive +func NewAnalyticsService(client *Client) *analyticsService { + return &analyticsService{ + client: client, + } +} + +type AnalyticsQuery struct { + From string `url:"from,omitempty"` + To string `url:"to,omitempty"` + Limit int `url:"limit,omitempty"` + Cursor string `url:"cursor,omitempty"` + Device string `url:"device,omitempty"` +} + +type StatusAnalyticsRequest struct { + ProfileID string `url:"-"` + AnalyticsQuery +} + +type StatusAnalytics struct { + Status string `json:"status"` + Queries int `json:"queries"` +} + +type StatusResponse struct { + Data []*StatusAnalytics `json:"data"` + + BaseResponse +} + +type DomainAnalyticsRequest struct { + ProfileID string `url:"-"` + Status string `url:"status"` + Root bool `url:"root"` + + AnalyticsQuery +} + +type DomainsAnalytics struct { + Domain string `json:"domain"` + Queries int `json:"queries"` + Root string `json:"root"` +} + +type DomainsAnalyticsResponse struct { + Data []*DomainsAnalytics `json:"data"` + + BaseResponse +} + +type ReasonsAnalyticsRequest struct { + ProfileID string `url:"-"` + AnalyticsQuery +} + +type ReasonsAnalytics struct { + ID string `json:"id"` + Name string `json:"name"` + Queries int `json:"queries"` +} + +type ReasonsResponse struct { + Data []*ReasonsAnalytics `json:"data"` + + BaseResponse +} + +type IPsAnalyticsRequest struct { + ProfileID string `url:"-"` + AnalyticsQuery +} + +type IPsAnalytics struct { + IP string `json:"ip"` + Network IPsAnalyticsNetwork `json:"network"` + Geo IPsAnalyticsGeo `json:"geo"` + Queries int `json:"queries"` +} + +type IPsAnalyticsNetwork struct { + Cellular bool `json:"cellular"` + VPN bool `json:"vpn"` + ISP string `json:"isp"` + ASN int `json:"asn"` +} + +type IPsAnalyticsGeo struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + CountryCode string `json:"countryCode"` + Country string `json:"country"` + City string `json:"city"` +} + +type IPsAnalyticsResponse struct { + Data []*IPsAnalytics `json:"data"` + + BaseResponse +} + +type DevicesAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type DevicesAnalytics struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` + LocalIP string `json:"localIp,omitempty"` + Queries int `json:"queries"` +} + +type DevicesResponse struct { + Data []*DevicesAnalytics `json:"data"` + + BaseResponse +} + +type ProtocolsAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type ProtocolsAnalytics struct { + Protocol string `json:"protocol"` + Queries int `json:"queries"` +} + +type ProtocolsResponse struct { + Data []*ProtocolsAnalytics `json:"data"` + + BaseResponse +} + +type QueryTypesAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type QueryTypesAnalytics struct { + Type int `url:"type,omitempty"` + Name string `url:"name,omitempty"` + Queries int `json:"queries"` +} + +type QueryTypesResponse struct { + Data []*QueryTypesAnalytics `json:"data"` + + BaseResponse +} + +type IPVersionsAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type IPVersionsAnalytics struct { + Version int `json:"version"` + Queries int `json:"queries"` +} + +type IPVersionsAnalyticsResponse struct { + Data []*IPVersionsAnalytics `json:"data"` + + BaseResponse +} + +type DNSSECAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type DNSSECAnalytics struct { + DNSSEC bool `json:"dnssec"` + Queries int `json:"queries"` +} + +type DNSSECAnalyticsResponse struct { + Data []*DNSSECAnalytics `json:"data"` + + BaseResponse +} + +type EncryptionAnalyticsRequest struct { + ProfileID string `url:"-"` + + AnalyticsQuery +} + +type EncryptionAnalytics struct { + Encrypted bool `json:"encrypted"` + Queries int `json:"queries"` +} + +type EncryptionResponse struct { + Data []*EncryptionAnalytics `json:"data"` + + BaseResponse +} + +type DestinationAnalyticsType string + +const ( + DestinationAnalyticsTypeCountries DestinationAnalyticsType = "countries" + DestinationAnalyticsTypeGAFAM DestinationAnalyticsType = "gafam" +) + +type DestinationsAnalyticsRequest struct { + ProfileID string `url:"-"` + Type DestinationAnalyticsType `url:"type,omitempty"` + + AnalyticsQuery +} + +type DestinationsAnalytics struct { + Code string `json:"code,omitempty"` + Domains []string `json:"domains,omitempty"` + Company string `json:"company,omitempty"` + Queries int `json:"queries"` +} + +type DestinationsAnalyticsResponse struct { + Data []*DestinationsAnalytics `json:"data"` + + BaseResponse +} + +// AnalyticsService is an interface for communicating with the NextDNS denylist API endpoint. +type AnalyticsService interface { + Status(context.Context, *StatusAnalyticsRequest) ([]*StatusAnalytics, error) + Domains(context.Context, *DomainAnalyticsRequest) ([]*DomainsAnalytics, error) + Reasons(context.Context, *ReasonsAnalyticsRequest) ([]*ReasonsAnalytics, error) + IPs(context.Context, *IPsAnalyticsRequest) ([]*IPsAnalytics, error) + Devices(context.Context, *DevicesAnalyticsRequest) ([]*DevicesAnalytics, error) + Protocols(context.Context, *ProtocolsAnalyticsRequest) ([]*ProtocolsAnalytics, error) + QueryTypes(context.Context, *QueryTypesAnalyticsRequest) ([]*QueryTypesAnalytics, error) + IPVersions(context.Context, *IPVersionsAnalyticsRequest) ([]*IPVersionsAnalytics, error) + DNSSEC(context.Context, *DNSSECAnalyticsRequest) ([]*DNSSECAnalytics, error) + Encryption(context.Context, *EncryptionAnalyticsRequest) ([]*EncryptionAnalytics, error) + Destinations(context.Context, *DestinationsAnalyticsRequest) ([]*DestinationsAnalytics, error) +} + +func AnalyticsPath(profile, path string, query interface{}) string { + return buildURI(profileAPIPath(profile)+analyticsAPIPath+path, query) +} + +// Status returns the status analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsstatus +func (s *analyticsService) Status(ctx context.Context, query *StatusAnalyticsRequest) ([]*StatusAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/status", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var status StatusResponse + err = s.client.do(ctx, req, &status) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return status.Data, nil +} + +// Domains returns the domains analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsdomains +func (s *analyticsService) Domains(ctx context.Context, query *DomainAnalyticsRequest) ([]*DomainsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/domains", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var domains DomainsAnalyticsResponse + err = s.client.do(ctx, req, &domains) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return domains.Data, nil +} + +// Reasons returns the reasons analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsreasons +func (s *analyticsService) Reasons(ctx context.Context, query *ReasonsAnalyticsRequest) ([]*ReasonsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/reasons", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response ReasonsResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// IPs returns the IPs analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsips +func (s *analyticsService) IPs(ctx context.Context, query *IPsAnalyticsRequest) ([]*IPsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/ips", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + response := &IPsAnalyticsResponse{} + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// Devices returns the devices analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsdevices +func (s *analyticsService) Devices(ctx context.Context, query *DevicesAnalyticsRequest) ([]*DevicesAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/devices", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response DevicesResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// Protocols returns the protocols analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsprotocols +func (s *analyticsService) Protocols(ctx context.Context, query *ProtocolsAnalyticsRequest) ([]*ProtocolsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/protocols", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response ProtocolsResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// QueryTypes returns the query types analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsquerytypes +func (s *analyticsService) QueryTypes(ctx context.Context, query *QueryTypesAnalyticsRequest) ([]*QueryTypesAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/queryTypes", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response QueryTypesResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// IPVersions returns the IP versions analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsipversions +func (s *analyticsService) IPVersions(ctx context.Context, query *IPVersionsAnalyticsRequest) ([]*IPVersionsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/ipVersions", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var ipVersions IPVersionsAnalyticsResponse + err = s.client.do(ctx, req, &ipVersions) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return ipVersions.Data, nil +} + +// DNSSEC returns the DNSSEC analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsdnssec +func (s *analyticsService) DNSSEC(ctx context.Context, query *DNSSECAnalyticsRequest) ([]*DNSSECAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/dnssec", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response DNSSECAnalyticsResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// Encryption returns the encryption analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsencryption +func (s *analyticsService) Encryption(ctx context.Context, query *EncryptionAnalyticsRequest) ([]*EncryptionAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/encryption", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response EncryptionResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} + +// Destinations returns the destinations analytics for the given profile. +// See https://nextdns.github.io/api/#profilesprofileanalyticsdestinationstypecountries and https://nextdns.github.io/api/#profilesprofileanalyticsdestinationstypegafam +func (s *analyticsService) Destinations(ctx context.Context, query *DestinationsAnalyticsRequest) ([]*DestinationsAnalytics, error) { + if query.ProfileID == "" { + return nil, ErrMissingProfile + } + req, err := s.client.newRequest(http.MethodGet, AnalyticsPath(query.ProfileID, "/destinations", query), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMakingRequest, err) + } + + var response DestinationsAnalyticsResponse + err = s.client.do(ctx, req, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errDoingRequest, err) + } + + return response.Data, nil +} diff --git a/nextdns/analytics_test.go b/nextdns/analytics_test.go new file mode 100644 index 0000000..5d00d47 --- /dev/null +++ b/nextdns/analytics_test.go @@ -0,0 +1,620 @@ +package nextdns + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnalyticsService_Status(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/status", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "status": "default", + "queries": 819491 + }, + { + "status": "blocked", + "queries": 132513 + }, + { + "status": "allowed", + "queries": 6923 + } + ] + }`) + }) + + _, err := client.Analytics.Status(context.Background(), &StatusAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + result, err := client.Analytics.Status(context.Background(), &StatusAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test profile request") { + assert.Equal(t, 3, len(result), "expected 3 results") + assert.Equal(t, &StatusAnalytics{Status: "default", Queries: 819491}, result[0], "expected default status and 819491 queries") + assert.Equal(t, &StatusAnalytics{Status: "blocked", Queries: 132513}, result[1], "expected blocked status and 132513 queries") + assert.Equal(t, &StatusAnalytics{Status: "allowed", Queries: 6923}, result[2], "expected allowed status and 6923 queries") + } +} + +func TestAnalyticsService_Domain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/domains", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "domain": "app-measurement.com", + "queries": 29801 + }, + { + "domain": "gateway.icloud.com", + "root": "icloud.com", + "queries": 18468 + }, + { + "domain": "app.smartmailcloud.com", + "root": "smartmailcloud.com", + "queries": 16414 + } + ] + }`) + }) + + _, err := client.Analytics.Domains(context.Background(), &DomainAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + result, err := client.Analytics.Domains(context.Background(), &DomainAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test domains request") { + assert.Equal(t, 3, len(result), "expected 3 results") + assert.Equal(t, &DomainsAnalytics{Domain: "app-measurement.com", Queries: 29801}, result[0], "expected app-measurement.com and 29801 queries") + assert.Equal(t, &DomainsAnalytics{Domain: "gateway.icloud.com", Root: "icloud.com", Queries: 18468}, result[1], "expected gateway.icloud.com, icloud.com and 18468 queries") + assert.Equal(t, &DomainsAnalytics{Domain: "app.smartmailcloud.com", Root: "smartmailcloud.com", Queries: 16414}, result[2], "expected app.smartmailcloud.com, smartmailcloud.com and 16414 queries") + } +} + +func TestAnalyticsService_Reasons(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/reasons", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "id": "blocklist:nextdns-recommended", + "name": "NextDNS Ads & Trackers Blocklist", + "queries": 131833 + }, + { + "id": "native:apple", + "name": "Native Tracking (Apple)", + "queries": 402 + }, + { + "id": "disguised-trackers", + "name": "Disguised Third-Party Trackers", + "queries": 269 + } + ] + }`) + }) + + _, err := client.Analytics.Reasons(context.Background(), &ReasonsAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*ReasonsAnalytics{ + {ID: "blocklist:nextdns-recommended", Name: "NextDNS Ads & Trackers Blocklist", Queries: 131833}, + {ID: "native:apple", Name: "Native Tracking (Apple)", Queries: 402}, + {ID: "disguised-trackers", Name: "Disguised Third-Party Trackers", Queries: 269}, + } + result, err := client.Analytics.Reasons(context.Background(), &ReasonsAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test reasons request") { + assert.Equal(t, 3, len(result), "expected 3 results") + assert.Equal(t, want, result, "expected reasons to match") + } +} + +func TestAnalyticsService_IPs(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/ips", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "ip": "91.171.12.34", + "network": { + "cellular": false, + "vpn": false, + "isp": "Free", + "asn": 12322 + }, + "geo": { + "latitude": 48.8998, + "longitude": 2.703, + "countryCode": "FR", + "country": "France", + "city": "Gagny" + }, + "queries": 136935 + }, + { + "ip": "2a01:e0a:2cd:1234:312a:4c24:215d:185", + "network": { + "cellular": false, + "vpn": false, + "isp": "Free", + "asn": 12322 + }, + "geo": { + "latitude": 48.5136, + "longitude": -1.9042, + "countryCode": "FR", + "country": "France", + "city": "Miniac-Morvan" + }, + "queries": 40410 + } + ] +}`) + }) + + _, err := client.Analytics.IPs(context.Background(), &IPsAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*IPsAnalytics{ + { + IP: "91.171.12.34", + Network: IPsAnalyticsNetwork{ + Cellular: false, + VPN: false, + ISP: "Free", + ASN: 12322, + }, + Geo: IPsAnalyticsGeo{ + Latitude: 48.8998, + Longitude: 2.703, + CountryCode: "FR", + Country: "France", + City: "Gagny", + }, + Queries: 136935, + }, + { + IP: "2a01:e0a:2cd:1234:312a:4c24:215d:185", + Network: IPsAnalyticsNetwork{ + Cellular: false, + VPN: false, + ISP: "Free", + ASN: 12322, + }, + Geo: IPsAnalyticsGeo{ + Latitude: 48.5136, + Longitude: -1.9042, + CountryCode: "FR", + Country: "France", + City: "Miniac-Morvan", + }, + Queries: 40410, + }, + } + + result, err := client.Analytics.IPs(context.Background(), &IPsAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test ips request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_Devices(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/devices", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "id": "8TD1G", + "name": "Romain’s iPhone", + "model": "iPhone 12 Pro Max", + "queries": 489885 + }, + { + "id": "E24AR", + "name": "MBP", + "model": "Macbook Pro", + "localIp": "192.168.0.11", + "queries": 215663 + }, + { + "id": "__UNIDENTIFIED__", + "queries": 74242 + } + ] +}`) + }) + + _, err := client.Analytics.Devices(context.Background(), &DevicesAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*DevicesAnalytics{ + { + ID: "8TD1G", + Name: "Romain’s iPhone", + Model: "iPhone 12 Pro Max", + Queries: 489885, + }, + { + ID: "E24AR", + Name: "MBP", + Model: "Macbook Pro", + LocalIP: "192.168.0.11", + Queries: 215663, + }, + { + ID: UnidentifiedDevice, + Queries: 74242, + }, + } + + result, err := client.Analytics.Devices(context.Background(), &DevicesAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test devices request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_Protocols(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/protocols", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "protocol": "DNS-over-HTTPS", + "queries": 958757 + }, + { + "protocol": "DNS-over-TLS", + "queries": 39582 + }, + { + "protocol": "UDP", + "queries": 2334 + } + ] +}`) + }) + + _, err := client.Analytics.Protocols(context.Background(), &ProtocolsAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*ProtocolsAnalytics{ + { + Protocol: "DNS-over-HTTPS", + Queries: 958757, + }, + { + Protocol: "DNS-over-TLS", + Queries: 39582, + }, + { + Protocol: "UDP", + Queries: 2334, + }, + } + + result, err := client.Analytics.Protocols(context.Background(), &ProtocolsAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test protocols request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_QueryTypes(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/queryTypes", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "type": 28, + "name": "AAAA", + "queries": 356230 + }, + { + "type": 1, + "name": "A", + "queries": 341812 + }, + { + "type": 65, + "name": "HTTPS", + "queries": 260478 + } + ] + }`) + }) + + _, err := client.Analytics.QueryTypes(context.Background(), &QueryTypesAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*QueryTypesAnalytics{ + { + Type: 28, + Name: "AAAA", + Queries: 356230, + }, + { + Type: 1, + Name: "A", + Queries: 341812, + }, + { + Type: 65, + Name: "HTTPS", + Queries: 260478, + }, + } + + result, err := client.Analytics.QueryTypes(context.Background(), &QueryTypesAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test query types request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_IPVersions(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/ipVersions", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "version": 6, + "queries": 958757 + }, + { + "version": 4, + "queries": 39582 + } + ] + }`) + }) + + _, err := client.Analytics.IPVersions(context.Background(), &IPVersionsAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*IPVersionsAnalytics{ + { + Version: 6, + Queries: 958757, + }, + { + Version: 4, + Queries: 39582, + }, + } + + result, err := client.Analytics.IPVersions(context.Background(), &IPVersionsAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test ip versions request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_DNSSEC(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/dnssec", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "queries": 958757, + "dnssec": true + }, + { + "queries": 39582, + "dnssec": false + } + ] + }`) + }) + + _, err := client.Analytics.DNSSEC(context.Background(), &DNSSECAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*DNSSECAnalytics{ + { + Queries: 958757, + DNSSEC: true, + }, + { + Queries: 39582, + DNSSEC: false, + }, + } + + result, err := client.Analytics.DNSSEC(context.Background(), &DNSSECAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test dnssec request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_Encryption(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/encryption", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "queries": 958757, + "encrypted": true + }, + { + "queries": 39582, + "encrypted": false + } + ] + }`) + }) + + _, err := client.Analytics.Encryption(context.Background(), &EncryptionAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + want := []*EncryptionAnalytics{ + { + Queries: 958757, + Encrypted: true, + }, + { + Queries: 39582, + Encrypted: false, + }, + } + + result, err := client.Analytics.Encryption(context.Background(), &EncryptionAnalyticsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test encryption request") { + assert.Equal(t, want, result, "didn't get expected result") + } +} + +func TestAnalyticsService_Destinations(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/analytics/destinations", testProfileID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + w.WriteHeader(http.StatusOK) + switch r.URL.Query().Get("type") { + case "countries": + fmt.Fprintf(w, ` + { + "data": [ + { + "code": "US", + "domains": [ + "app.smartmailcloud.com", + "imap.gmail.com", + "api.coinbase.com", + "events-service.coinbase.com", + "ws.coinbase.com" + ], + "queries": 209851 + }, + { + "code": "FR", + "domains": [ + "inappcheck.itunes.apple.com", + "iphone-ld.apple.com", + "bag.itunes.apple.com", + "itunes.apple.com", + "www.apple.com" + ], + "queries": 105497 + } + ] + }`) + case "gafam": + fmt.Fprintf(w, ` + { + "data": [ + { + "company": "others", + "queries": 478732 + }, + { + "company": "apple", + "queries": 284832 + }, + { + "company": "google", + "queries": 159488 + } + ] + }`) + default: + w.WriteHeader(http.StatusBadRequest) + } + }) + + _, err := client.Analytics.Destinations(context.Background(), &DestinationsAnalyticsRequest{}) + assert.Equal(t, ErrMissingProfile, err, "expected missing profile error") + + wantCountries := []*DestinationsAnalytics{ + { + Code: "US", + Domains: []string{ + "app.smartmailcloud.com", + "imap.gmail.com", + "api.coinbase.com", + "events-service.coinbase.com", + "ws.coinbase.com", + }, + Queries: 209851, + }, + { + Code: "FR", + Domains: []string{ + "inappcheck.itunes.apple.com", + "iphone-ld.apple.com", + "bag.itunes.apple.com", + "itunes.apple.com", + "www.apple.com", + }, + Queries: 105497, + }, + } + wantGafam := []*DestinationsAnalytics{ + { + Company: "others", + Queries: 478732, + }, + { + Company: "apple", + Queries: 284832, + }, + { + Company: "google", + Queries: 159488, + }, + } + + resultCountries, err := client.Analytics.Destinations(context.Background(), &DestinationsAnalyticsRequest{ProfileID: testProfileID, Type: DestinationAnalyticsTypeCountries}) + if assert.NoError(t, err, "got error when making test destinations countries request") { + assert.Equal(t, wantCountries, resultCountries, "didn't get expected result") + } + + resultGafam, err := client.Analytics.Destinations(context.Background(), &DestinationsAnalyticsRequest{ProfileID: testProfileID, Type: DestinationAnalyticsTypeGAFAM}) + if assert.NoError(t, err, "got error when making test destinations gafama request") { + assert.Equal(t, wantGafam, resultGafam, "didn't get expected result") + } +} diff --git a/nextdns/client.go b/nextdns/client.go index 64c5d77..50d0e00 100644 --- a/nextdns/client.go +++ b/nextdns/client.go @@ -12,6 +12,8 @@ import ( "strings" "github.com/hashicorp/go-cleanhttp" + + "github.com/google/go-querystring/query" ) const ( @@ -25,6 +27,9 @@ type Client struct { client *http.Client baseURL *url.URL + // Service for the Analytics. + Analytics AnalyticsService + // Service for the Profile. Profiles ProfilesService @@ -135,6 +140,9 @@ func New(opts ...ClientOption) (*Client, error) { } } + // Initialize the services for the Analytics. + c.Analytics = NewAnalyticsService(c) + // Initialize the services for the Profile. c.Profiles = NewProfilesService(c) @@ -207,7 +215,7 @@ func (c *Client) handleResponse(ctx context.Context, res *http.Response, v inter return nil } - // Sets some default additional informations that can be used by the client to debug the error. + // Sets some default additional information that can be used by the client to debug the error. meta := map[string]string{ "body": string(out), "http_status": http.StatusText(res.StatusCode), @@ -344,3 +352,9 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Add("X-Api-Key", t.apiKey) return t.rt.RoundTrip(req) } + +// buildURI assembles the base path and queries. +func buildURI(path string, options interface{}) string { + v, _ := query.Values(options) + return (&url.URL{Path: path, RawQuery: v.Encode()}).String() +} diff --git a/nextdns/client_test.go b/nextdns/client_test.go new file mode 100644 index 0000000..c84c62e --- /dev/null +++ b/nextdns/client_test.go @@ -0,0 +1,58 @@ +package nextdns + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testProfileID = "testProfile" + +var ( + // mux is the HTTP request multiplexer used with the test server. + mux *http.ServeMux + + // client is the API client being tested. + client *Client + + // server is a test HTTP server used to provide mock API responses. + server *httptest.Server +) + +func setup(opts ...ClientOption) { + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + opts = append(opts, WithAPIKey("testing")) + client, _ = New(opts...) + client.baseURL, _ = url.Parse(server.URL) +} + +func teardown() { + server.Close() +} + +func checkHTTPMethod(t *testing.T, req *http.Request, expectedMethod string) { + t.Helper() + assert.Equal(t, expectedMethod, req.Method, "Expected method '%s', got %s", expectedMethod, req.Method) +} + +func TestClient_Headers(t *testing.T) { + setup() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + assert.Equal(t, "testing", r.Header.Get("X-Api-Key")) + assert.Equal(t, contentType, r.Header.Get("Accept")) + assert.Equal(t, userAgent, r.Header.Get("User-Agent")) + }) + req, err := client.newRequest(http.MethodPost, "/", &UpdateSettingsRequest{}) + assert.NoError(t, err, "got error when making request") + err = client.do(context.Background(), req, nil) + assert.NoError(t, err, "got error when doing request") + teardown() +} diff --git a/nextdns/errors.go b/nextdns/errors.go index 489a3d3..647c93a 100644 --- a/nextdns/errors.go +++ b/nextdns/errors.go @@ -16,6 +16,8 @@ const ( errResponseError = "response error received" errMalformedError = "malformed response body received" errMalformedErrorBody = "malformed error response body received" + errDoingRequest = "error doing request" + errMakingRequest = "error building request" ) const ( diff --git a/nextdns/rewrites.go b/nextdns/rewrites.go index 453416b..9a87d31 100644 --- a/nextdns/rewrites.go +++ b/nextdns/rewrites.go @@ -41,7 +41,7 @@ type RewritesService interface { Delete(context.Context, *DeleteRewritesRequest) error } -// rewritesResponse represents the rewrites response. +// rewritesResponse represents the rewrites' response. type rewritesResponse struct { Rewrites []*Rewrites `json:"data"` } diff --git a/nextdns/security_tlds.go b/nextdns/security_tlds.go index 2a110e3..a7ac896 100644 --- a/nextdns/security_tlds.go +++ b/nextdns/security_tlds.go @@ -9,7 +9,7 @@ import ( // securityTldsAPIPath is the HTTP path for the security TLDs API. const securityTldsAPIPath = "security/tlds" -// Allowlist represents the security TLDs of a profile. +// SecurityTlds represents the security TLDs of a profile. type SecurityTlds struct { ID string `json:"id"` } diff --git a/nextdns/security_tlds_test.go b/nextdns/security_tlds_test.go new file mode 100644 index 0000000..57845ab --- /dev/null +++ b/nextdns/security_tlds_test.go @@ -0,0 +1,66 @@ +package nextdns + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSecurityTldsService_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/security/tlds", testProfileID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "data": [ + { + "id": "ru" + }, + { + "id": "cn" + }, + { + "id": "cf" + }, + { + "id": "accountants" + } + ] + }`) + }) + + want := []*SecurityTlds{ + {ID: "ru"}, + {ID: "cn"}, + {ID: "cf"}, + {ID: "accountants"}, + } + + result, err := client.SecurityTlds.List(context.Background(), &ListSecurityTldsRequest{ProfileID: testProfileID}) + if assert.NoError(t, err, "got error when making test profile request") { + assert.Equal(t, want, result, "got unexpected security tlds") + } +} + +func TestSecurityTldsService_Create(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/profiles/%s/security/tlds", testProfileID), func(w http.ResponseWriter, r *http.Request) { + checkHTTPMethod(t, r, http.MethodPut) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "data": [ + {"id": "ru"} + ] + }`) + }) + + err := client.SecurityTlds.Create(context.Background(), &CreateSecurityTldsRequest{ProfileID: testProfileID, SecurityTlds: []*SecurityTlds{{ID: "ru"}}}) + assert.NoError(t, err, "got error when making test security tlds request") +} From 861d83239ca17952efef9143badd0d04f2a565b6 Mon Sep 17 00:00:00 2001 From: Cyb3r-Jak3 Date: Fri, 5 May 2023 20:37:42 -0400 Subject: [PATCH 2/2] Add note that time series isn't implemented --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2927f2e..6ffb675 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ APIs supported by this package: - [x] Profile (`/profiles` and `/profiles/:profile`) - [x] Analytics (`/profiles/:profile/analytics`) + - [ ] Time Series [docs](https://nextdns.github.io/api/#time-series) - [ ] Logs (`/profiles/:profile/logs`) ## Usage