From 67514c1ab6268153023d88ccaace498e61df9de1 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Fri, 7 Nov 2025 14:29:10 +0100 Subject: [PATCH] dns checker (#1528) * dns checker * add assertions * improve dns checker * ci: apply automated fixes * generate proto * clean dns * add dns post api * ci: apply automated fixes * add api for dns monitor * ci: apply automated fixes * fix tests --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/checker/checker/dns.go | 138 +++++++ apps/checker/checker/dns_test.go | 19 + apps/checker/cmd/server/main.go | 2 + apps/checker/handlers/checker.go | 91 +++-- apps/checker/handlers/checker_test.go | 158 +++++++ apps/checker/handlers/dns.go | 384 ++++++++++++++++++ apps/checker/handlers/dns_test.go | 181 +++++++++ apps/checker/handlers/tcp.go | 2 +- apps/checker/pkg/assertions/assertions.go | 38 ++ .../checker/pkg/assertions/assertions_test.go | 82 ++++ apps/checker/pkg/job/dns_job.go | 14 + apps/checker/pkg/job/job.go | 1 + apps/checker/pkg/scheduler/scheduler.go | 162 +++----- apps/checker/pkg/scheduler/scheduler_test.go | 11 + .../private_location/v1/assertions.pb.go | 158 ++++++- .../private_location/v1/dns_monitor.pb.go | 183 +++++++++ .../v1/private_location.connect.go | 29 ++ .../v1/private_location.pb.go | 289 ++++++++++++- apps/checker/request/request.go | 47 ++- .../internal/server/ingest_dns.go | 93 +++++ .../private_location/v1/assertions.pb.go | 158 ++++++- .../private_location/v1/dns_monitor.pb.go | 183 +++++++++ .../v1/private_location.connect.go | 29 ++ .../v1/private_location.pb.go | 289 ++++++++++++- apps/server/src/routes/v1/monitors/index.ts | 4 + .../src/routes/v1/monitors/post_dns.test.ts | 64 +++ .../server/src/routes/v1/monitors/post_dns.ts | 144 +++++++ .../src/routes/v1/monitors/put_dns.test.ts | 55 +++ apps/server/src/routes/v1/monitors/put_dns.ts | 143 +++++++ apps/server/src/routes/v1/monitors/schema.ts | 41 +- apps/ssh-server/main.go | 1 - apps/workflows/src/cron/checker.ts | 22 + packages/assertions/src/v1.ts | 16 + .../private_location/v1/assertions.proto | 66 +-- .../private_location/v1/dns_monitor.proto | 19 + .../v1/private_location.proto | 27 ++ packages/utils/index.ts | 21 + 37 files changed, 3105 insertions(+), 259 deletions(-) create mode 100644 apps/checker/checker/dns.go create mode 100644 apps/checker/checker/dns_test.go create mode 100644 apps/checker/handlers/dns.go create mode 100644 apps/checker/handlers/dns_test.go create mode 100644 apps/checker/pkg/job/dns_job.go create mode 100644 apps/checker/proto/private_location/v1/dns_monitor.pb.go create mode 100644 apps/private-location/internal/server/ingest_dns.go create mode 100644 apps/private-location/proto/private_location/v1/dns_monitor.pb.go create mode 100644 apps/server/src/routes/v1/monitors/post_dns.test.ts create mode 100644 apps/server/src/routes/v1/monitors/post_dns.ts create mode 100644 apps/server/src/routes/v1/monitors/put_dns.test.ts create mode 100644 apps/server/src/routes/v1/monitors/put_dns.ts create mode 100644 packages/proto/private_location/v1/dns_monitor.proto diff --git a/apps/checker/checker/dns.go b/apps/checker/checker/dns.go new file mode 100644 index 00000000..526cda14 --- /dev/null +++ b/apps/checker/checker/dns.go @@ -0,0 +1,138 @@ +package checker + +import ( + "context" + "fmt" + "net" + + "github.com/rs/zerolog/log" +) + + +type DnsResponse struct { + A []string `json:"a,omitempty"` + AAAA []string `json:"aaaa,omitempty"` + CNAME string `json:"cname,omitempty"` + MX []string `json:"mx,omitempty"` + NS []string `json:"ns,omitempty"` + TXT []string `json:"txt,omitempty"` +} + +func Dns(ctx context.Context, host string) (*DnsResponse, error) { + logger:= log.Ctx(ctx).With().Str("monitor", host).Logger() + + A, err := lookupA(host) + if err != nil { + logger.Error().Err(err).Msg("DNS A record lookup failed") + return nil, fmt.Errorf("failed to lookup A record: %w", err) + } + AAAA, err := lookupAAAA(host) + if err != nil { + logger.Error().Err(err).Msg("DNS AAAA record lookup failed") + return nil, fmt.Errorf("failed to lookup AAAA record: %w", err) + } + CNAME,err := lookupCNAME(host) + if err != nil { + logger.Error().Err(err).Msg("DNS CNAME record lookup failed") + return nil, fmt.Errorf("failed to lookup CNAME record: %w", err) + } + MXRecords := lookupMX(host) + + NS,err := lookupNS(host) + if err != nil { + logger.Error().Err(err).Msg("DNS NS record lookup failed") + return nil, fmt.Errorf("failed to lookup NS record: %w", err) + } + TXT := lookupTXT(host) + + + response := &DnsResponse{ + A: A, + AAAA: AAAA, + CNAME: CNAME, + MX: MXRecords, + NS: NS, + TXT: TXT, + } + + return response, nil +} + +func lookupA(domain string) ([]string, error) { + + A := []string{} + ips, err := net.LookupIP(domain) + if err != nil { + return nil, err + } + + for _, ip := range ips { + if ip.To4() != nil { + A = append(A, ip.String()) + } + } + return A, nil +} + +func lookupAAAA(domain string) ([]string, error) { + AAAA := []string{} + ips, err := net.LookupIP(domain) + if err != nil { + return nil, err + } + + for _, ip := range ips { + if ip.To16() != nil && ip.To4() == nil { + + AAAA = append(AAAA, ip.String()) + } + } + return AAAA, nil +} + +func lookupCNAME(domain string) (string, error) { + cname, err := net.LookupCNAME(domain) + if err != nil { + return "", err + } + + return cname, nil +} + +func lookupMX(domain string) ([]string) { + mx := []string{} + mxRecords,_ := net.LookupMX(domain) + + + for _, r := range mxRecords { + mx = append(mx, fmt.Sprintf("%s:%d", r.Host, r.Pref)) + } + return mx +} + +func lookupNS(domain string) ([]string, error) { + + hosts := []string{} + nsRecords, err := net.LookupNS(domain) + if err != nil { + return nil, err + } + + for _, ns := range nsRecords { + hosts = append(hosts, ns.Host) + } + return hosts, nil +} + +func lookupTXT(domain string) ([]string) { + records := []string{} + txtRecords, err := net.LookupTXT(domain) + if err != nil { + return nil + } + + for _, txt := range txtRecords { + records = append(records, txt) + } + return records +} diff --git a/apps/checker/checker/dns_test.go b/apps/checker/checker/dns_test.go new file mode 100644 index 00000000..5b352d03 --- /dev/null +++ b/apps/checker/checker/dns_test.go @@ -0,0 +1,19 @@ +package checker_test + +import ( + "testing" + + "github.com/openstatushq/openstatus/apps/checker/checker" +) + + +func TestPingDNS(t *testing.T) { + ctx := t.Context() + data, err := checker.Dns(ctx, "openstat.us") + if err != nil { + t.Errorf("Dns() error = %v", err) + } + if len(data.A) == 0 { + t.Errorf("Dns() A records = %v", data.A) + } +} diff --git a/apps/checker/cmd/server/main.go b/apps/checker/cmd/server/main.go index 5e46489d..f7e4eb08 100644 --- a/apps/checker/cmd/server/main.go +++ b/apps/checker/cmd/server/main.go @@ -70,8 +70,10 @@ func main() { router.POST("/checker", h.HTTPCheckerHandler) router.POST("/checker/http", h.HTTPCheckerHandler) router.POST("/checker/tcp", h.TCPHandler) + router.POST("/checker/dns", h.DNSHandler) router.POST("/ping/:region", h.PingRegionHandler) router.POST("/tcp/:region", h.TCPHandlerRegion) + router.POST("/dns/:region", h.DNSHandlerRegion) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) diff --git a/apps/checker/handlers/checker.go b/apps/checker/handlers/checker.go index fb472743..b9317bdf 100644 --- a/apps/checker/handlers/checker.go +++ b/apps/checker/handlers/checker.go @@ -46,6 +46,7 @@ type PingData struct { func (h Handler) HTTPCheckerHandler(c *gin.Context) { ctx := c.Request.Context() + const defaultRetry = 3 dataSourceName := "ping_response__v8" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { @@ -111,11 +112,9 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { var result checker.Response - var retry int - if req.Retry == 0 { + retry := defaultRetry + if req.Retry != 0 { retry = int(req.Retry) - } else { - retry = 3 } op := func() error { @@ -170,48 +169,10 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { RequestStatus: requestStatus, } - statusCode := statusCode(res.Status) - var isSuccessfull bool = true - if len(req.RawAssertions) > 0 { - for _, a := range req.RawAssertions { - var assert request.Assertion - err = json.Unmarshal(a, &assert) - if err != nil { - // handle error - return fmt.Errorf("unable to unmarshal assertion: %w", err) - } - - switch assert.AssertionType { - case request.AssertionHeader: - var target assertions.HeaderTarget - if err := json.Unmarshal(a, &target); err != nil { - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) - } - - isSuccessfull = isSuccessfull && target.HeaderEvaluate(data.Headers) - case request.AssertionTextBody: - var target assertions.StringTargetType - if err := json.Unmarshal(a, &target); err != nil { - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) - } - - isSuccessfull = isSuccessfull && target.StringEvaluate(data.Body) - case request.AssertionStatus: - var target assertions.StatusTarget - if err := json.Unmarshal(a, &target); err != nil { - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) - } - - isSuccessfull = isSuccessfull && target.StatusEvaluate(int64(res.Status)) - case request.AssertionJsonBody: - fmt.Println("assertion type", assert.AssertionType) - default: - fmt.Println("! Not Handled assertion type", assert.AssertionType) - } - } - } else { - isSuccessfull = statusCode.IsSuccessful() + isSuccessfull, err = EvaluateHTTPAssertions(req.RawAssertions, data, res) + if err != nil { + return err } // let's retry at least once if the status code is not successful. @@ -354,3 +315,43 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { c.JSON(http.StatusOK, nil) } + +func EvaluateHTTPAssertions(raw []json.RawMessage, data PingData, res checker.Response) (bool, error) { + statusCode := statusCode(res.Status) + if len(raw) == 0 { + return statusCode.IsSuccessful(), nil + } + isSuccessful := true + for _, a := range raw { + var assert request.Assertion + if err := json.Unmarshal(a, &assert); err != nil { + return false, fmt.Errorf("unable to unmarshal assertion: %w", err) + } + switch assert.AssertionType { + case request.AssertionHeader: + var target assertions.HeaderTarget + if err := json.Unmarshal(a, &target); err != nil { + return false, fmt.Errorf("unable to unmarshal HeaderTarget: %w", err) + } + isSuccessful = isSuccessful && target.HeaderEvaluate(data.Headers) + case request.AssertionTextBody: + var target assertions.StringTargetType + if err := json.Unmarshal(a, &target); err != nil { + return false, fmt.Errorf("unable to unmarshal StringTargetType: %w", err) + } + isSuccessful = isSuccessful && target.StringEvaluate(data.Body) + case request.AssertionStatus: + var target assertions.StatusTarget + if err := json.Unmarshal(a, &target); err != nil { + return false, fmt.Errorf("unable to unmarshal StatusTarget: %w", err) + } + isSuccessful = isSuccessful && target.StatusEvaluate(int64(res.Status)) + case request.AssertionJsonBody: + // TODO: Implement JSON body assertion + default: + fmt.Println("unknown assertion type: ", assert.AssertionType) + // TODO: Handle unknown assertion type + } + } + return isSuccessful, nil +} diff --git a/apps/checker/handlers/checker_test.go b/apps/checker/handlers/checker_test.go index bf16182a..3624e466 100644 --- a/apps/checker/handlers/checker_test.go +++ b/apps/checker/handlers/checker_test.go @@ -10,7 +10,9 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/stretchr/testify/assert" @@ -106,3 +108,159 @@ func TestHandler_HTTPCheckerHandler(t *testing.T) { fmt.Println(w.Body.String()) }) } + + +func TestEvaluateAssertions_raw(t *testing.T) { + // Helper to marshal assertion + marshal := func(a any) json.RawMessage { + b, _ := json.Marshal(a) + return b + } + + // Success if no assertions and status code is 200 + t.Run("no assertions, status code 200", func(t *testing.T) { + raw := []json.RawMessage{} + data := handlers.PingData{} + res := checker.Response{Status: 200} + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.True(t, ok) + assert.NoError(t, err) + }) + + // Header assertion success + t.Run("header assertion success", func(t *testing.T) { + assertion := request.Assertion{AssertionType: request.AssertionHeader} + target := struct { + request.Assertion + Comparator request.StringComparator `json:"compare"` + Key string `json:"key"` + Target string `json:"target"` + }{ + assertion, + request.StringContains, + "X-Test", + "ok", + } + rawMsg := marshal(target) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{Headers: `{"X-Test":"ok-value"}`} + res := checker.Response{Status: 200} + + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.True(t, ok) + assert.NoError(t, err) + }) + + t.Run("header assertion failed", func(t *testing.T) { + assertion := request.Assertion{AssertionType: request.AssertionHeader} + target := struct { + request.Assertion + Comparator request.StringComparator `json:"compare"` + Key string `json:"key"` + Target string `json:"target"` + }{ + assertion, + request.StringContains, + "X-Test", + "not-ok", + } + rawMsg := marshal(target) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{Headers: `{"X-Test":"ok-value"}`} + res := checker.Response{Status: 200} + + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.False(t, ok) + assert.NoError(t, err) + }) + + // Text body assertion failure + t.Run("text body assertion failure", func(t *testing.T) { + assertion := request.Assertion{AssertionType: request.AssertionTextBody} + target := struct { + request.Assertion + Comparator request.StringComparator `json:"compare"` + Target string `json:"target"` + }{ + assertion, + request.StringEquals, + "fail", + } + rawMsg := marshal(target) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{Body: "ok"} + res := checker.Response{Status: 200} + + + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.False(t, ok) + assert.NoError(t, err) + }) + + // Text body assertion failure + t.Run("text body assertion success", func(t *testing.T) { + assertion := request.Assertion{AssertionType: request.AssertionTextBody} + target := struct { + request.Assertion + Comparator request.StringComparator `json:"compare"` + Target string `json:"target"` + }{ + assertion, + request.StringEquals, + "success", + } + rawMsg := marshal(target) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{Body: "success"} + res := checker.Response{Status: 200} + + + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.True(t, ok) + assert.NoError(t, err) + }) + // Status assertion success + t.Run("status assertion success", func(t *testing.T) { + assertion := request.Assertion{AssertionType: request.AssertionStatus} + target := struct { + request.Assertion + Comparator request.NumberComparator `json:"compare"` + Target int64 `json:"target"` + }{ + assertion, + request.NumberEquals, + 200, + } + rawMsg := marshal(target) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{} + res := checker.Response{Status: 200} + + + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.True(t, ok) + assert.NoError(t, err) + }) + + // Malformed assertion + t.Run("malformed assertion", func(t *testing.T) { + raw := []json.RawMessage{[]byte(`{not valid json}`)} + data := handlers.PingData{} + res := checker.Response{Status: 200} + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.False(t, ok) + assert.Error(t, err) + }) + + // Unknown assertion type + t.Run("unknown assertion type", func(t *testing.T) { + assertion := request.Assertion{AssertionType: "unknown"} + rawMsg := marshal(assertion) + raw := []json.RawMessage{rawMsg} + data := handlers.PingData{} + res := checker.Response{Status: 200} + ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) + assert.True(t, ok) // Should not fail, just skip + assert.NoError(t, err) + }) +} diff --git a/apps/checker/handlers/dns.go b/apps/checker/handlers/dns.go new file mode 100644 index 00000000..5524fac9 --- /dev/null +++ b/apps/checker/handlers/dns.go @@ -0,0 +1,384 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" + "github.com/openstatushq/openstatus/apps/checker/request" + "github.com/rs/zerolog/log" + + "github.com/cenkalti/backoff/v5" +) + +type DNSResponse struct { + ID string `json:"id"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + Assertions string `json:"assertions"` + + Records map[string][]string `json:"records"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + CronTimestamp int64 `json:"cronTimestamp"` + + Error uint8 `json:"error"` +} + +func (h Handler) DNSHandler(c *gin.Context) { + ctx := c.Request.Context() + const defaultRetry = 3 + dataSourceName := "dns_response__v0" + + // Authorization check + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // Fly region forwarding + if h.CloudProvider == "fly" { + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + return + } + } + + // Parse request + var req request.DNSCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"}) + return + } + + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid monitor id"}) + return + } + + trigger := req.Trigger + if trigger == "" { + trigger = "cron" + } + + retry := defaultRetry + if req.Retry != 0 { + retry = int(req.Retry) + } + + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to generate UUID") + return + } + + statusMap := map[string]string{ + "active": "success", + "error": "error", + "degraded": "degraded", + } + requestStatus := statusMap[req.Status] + + data := DNSResponse{ + ID: id.String(), + Region: h.Region, + Trigger: trigger, + URI: req.URI, + WorkspaceID: workspaceId, + MonitorID: monitorId, + CronTimestamp: req.CronTimestamp, + RequestStatus: requestStatus, + Timestamp: time.Now().UTC().UnixMilli(), + } + + var ( + latency int64 + isSuccessful = true + called int + ) + + op := func() (*checker.DnsResponse, error) { + called++ + log.Ctx(ctx).Debug().Msgf("performing dns check for %s (attempt %d/%d)", req.URI, called, retry) + start := time.Now().UTC().UnixMilli() + response, err := checker.Dns(ctx, req.URI) + latency = time.Now().UTC().UnixMilli() - start + + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("dns check failed") + return nil, err + } + if len(req.RawAssertions) > 0 { + log.Ctx(ctx).Debug().Msgf("evaluating %d dns assertions", len(req.RawAssertions)) + isSuccessful, err = EvaluateDNSAssertions(req.RawAssertions, response) + if err != nil { + return nil, backoff.Permanent(err) + } + } + if !isSuccessful && called < retry { + return nil, backoff.RetryAfter(1) + } + if !isSuccessful { + log.Ctx(ctx).Debug().Msg("dns assertions failed") + return response, backoff.Permanent(fmt.Errorf("assertion failed")) + } + return response, nil + } + + result, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) + data.Latency = latency + data.Records = FormatDNSResult(result) + + if len(req.RawAssertions) > 0 { + if j, err := json.Marshal(req.RawAssertions); err == nil { + data.Assertions = string(j) + } else { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") + } + } + + // Status update logic + switch { + case !isSuccessful && req.Status != "error": + log.Ctx(ctx).Debug().Msg("DNS check failed assertions") + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Region: h.Region, + Message: err.Error(), + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "error" + data.Error = 1 + data.ErrorMessage = err.Error() + case isSuccessful && req.DegradedAfter > 0 && latency > req.DegradedAfter && req.Status != "degraded": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "degraded" + case isSuccessful && ((req.DegradedAfter == 0 && req.Status != "active") || (latency < req.DegradedAfter && req.DegradedAfter != 0 && req.Status != "active")): + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "success" + } + + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + + c.JSON(http.StatusOK, data) +} + +func (h Handler) DNSHandlerRegion(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "check_dns_response__v0" + const defaultRetry = 3 + + // Authorization check + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // Fly region forwarding + if h.CloudProvider == "fly" { + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + return + } + } + + // Parse request + var req request.DNSCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"}) + return + } + + retry := defaultRetry + if req.Retry != 0 { + retry = int(req.Retry) + } + + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to generate UUID") + return + } + + statusMap := map[string]string{ + "active": "success", + "error": "error", + "degraded": "degraded", + } + requestStatus := statusMap[req.Status] + + data := DNSResponse{ + ID: id.String(), + Region: h.Region, + URI: req.URI, + WorkspaceID: workspaceId, + CronTimestamp: req.CronTimestamp, + RequestStatus: requestStatus, + Timestamp: time.Now().UTC().UnixMilli(), + } + + var ( + latency int64 + isSuccessful = true + called int + ) + + op := func() (*checker.DnsResponse, error) { + called++ + log.Ctx(ctx).Debug().Msgf("performing dns check for %s (attempt %d/%d)", req.URI, called, retry) + start := time.Now().UTC().UnixMilli() + response, err := checker.Dns(ctx, req.URI) + latency = time.Now().UTC().UnixMilli() - start + + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("dns check failed") + return nil, err + } + if len(req.RawAssertions) > 0 { + log.Ctx(ctx).Debug().Msgf("evaluating %d dns assertions", len(req.RawAssertions)) + isSuccessful, err = EvaluateDNSAssertions(req.RawAssertions, response) + if err != nil { + return nil, backoff.Permanent(err) + } + } + if !isSuccessful && called < retry { + return nil, backoff.RetryAfter(1) + } + if !isSuccessful { + log.Ctx(ctx).Debug().Msg("dns assertions failed") + return response, backoff.Permanent(fmt.Errorf("assertion failed")) + } + return response, nil + } + + result, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) + data.Latency = latency + data.Records = FormatDNSResult(result) + + if len(req.RawAssertions) > 0 { + if j, err := json.Marshal(req.RawAssertions); err == nil { + data.Assertions = string(j) + } else { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") + } + } + + if req.RequestId != 0 { + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + } + c.JSON(http.StatusOK, data) + +} + +func FormatDNSResult(result *checker.DnsResponse) map[string][]string { + r := make(map[string][]string) + a := make([]string, 0) + aaaa := make([]string, 0) + mx := make([]string, 0) + ns := make([]string, 0) + txt := make([]string, 0) + for _, v := range result.A { + a = append(a, v) + } + r["A"] = a + + for _, v := range result.AAAA { + aaaa = append(aaaa, v) + } + r["AAAA"] = aaaa + + r["CNAME"] = []string{result.CNAME} + for _, v := range result.MX { + mx = append(mx, v) + } + r["MX"] = mx + for _, v := range result.NS { + ns = append(ns, v) + } + r["NS"] = ns + for _, v := range result.TXT { + txt = append(txt, v) + } + r["TXT"] = txt + return r +} + +func EvaluateDNSAssertions(rawAssertions []json.RawMessage, response *checker.DnsResponse) (bool, error) { + for _, a := range rawAssertions { + var assert assertions.RecordTarget + if err := json.Unmarshal(a, &assert); err != nil { + return false, fmt.Errorf("unable to parse assertion: %w", err) + } + var isSuccessfull bool + switch assert.Record { + case request.RecordA: + isSuccessfull = assert.RecordEvaluate(response.A) + case request.RecordAAAA: + isSuccessfull = assert.RecordEvaluate(response.AAAA) + case request.RecordCNAME: + isSuccessfull = assert.RecordEvaluate([]string{response.CNAME}) + case request.RecordMX: + isSuccessfull = assert.RecordEvaluate(response.MX) + case request.RecordNS: + isSuccessfull = assert.RecordEvaluate(response.NS) + case request.RecordTXT: + isSuccessfull = assert.RecordEvaluate(response.TXT) + default: + return false, fmt.Errorf("unknown record type in assertion: %s", assert.Record) + } + if !isSuccessfull { + return false, nil + } + } + return true, nil +} diff --git a/apps/checker/handlers/dns_test.go b/apps/checker/handlers/dns_test.go new file mode 100644 index 00000000..57ba56e8 --- /dev/null +++ b/apps/checker/handlers/dns_test.go @@ -0,0 +1,181 @@ +package handlers_test + +import ( + "encoding/json" + "reflect" + "testing" + + // Adjust the import path if necessary to point to the correct package + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/handlers" +) + +// Mock DNSResult struct to match the expected input for FormatDNSResult. +// If the real struct is in another package, import it accordingly. +type DNSResult struct { + A []string + AAAA []string + CNAME string + MX []string + NS []string + TXT []string +} + + + +func TestFormatDNSResult(t *testing.T) { + tests := []struct { + name string + input DNSResult + expected map[string][]string + }{ + { + name: "All fields populated", + input: DNSResult{ + A: []string{"1.2.3.4", "5.6.7.8"}, + AAAA: []string{"::1", "2001:db8::1"}, + CNAME: "example.com", + MX: []string{"mx1.example.com", "mx2.example.com"}, + NS: []string{"ns1.example.com", "ns2.example.com"}, + TXT: []string{"v=spf1", "google-site-verification=abc"}, + }, + expected: map[string][]string{ + "A": {"1.2.3.4", "5.6.7.8"}, + "AAAA": {"::1", "2001:db8::1"}, + "CNAME": {"example.com"}, + "MX": {"mx1.example.com", "mx2.example.com"}, + "NS": {"ns1.example.com", "ns2.example.com"}, + "TXT": {"v=spf1", "google-site-verification=abc"}, + }, + }, + { + name: "Empty fields", + input: DNSResult{ + A: []string{}, + AAAA: []string{}, + CNAME: "", + MX: []string{}, + NS: []string{}, + TXT: []string{}, + }, + expected: map[string][]string{ + "A": {}, + "AAAA": {}, + "CNAME": {""}, + "MX": {}, + "NS": {}, + "TXT": {}, + }, + }, + { + name: "Single values", + input: DNSResult{ + A: []string{"8.8.8.8"}, + AAAA: []string{"fe80::1"}, + CNAME: "single.example.com", + MX: []string{"mx.single.com"}, + NS: []string{"ns.single.com"}, + TXT: []string{"single-txt"}, + }, + expected: map[string][]string{ + "A": {"8.8.8.8"}, + "AAAA": {"fe80::1"}, + "CNAME": {"single.example.com"}, + "MX": {"mx.single.com"}, + "NS": {"ns.single.com"}, + "TXT": {"single-txt"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handlers.FormatDNSResult(&checker.DnsResponse{ + A: tt.input.A, + AAAA: tt.input.AAAA, + CNAME: tt.input.CNAME, + MX: tt.input.MX, + NS: tt.input.NS, + TXT: tt.input.TXT, + }) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("FormatDNSResult() = %v, want %v", got, tt.expected) + } + }) + } +} + + +func TestEvaluateDNSAssertions(t *testing.T) { + type args struct { + rawAssertions []json.RawMessage + response *checker.DnsResponse + } + tests := []struct { + name string + args args + wantSuccess bool + wantErr bool + }{ + { + name: "A record matches", + args: args{ + rawAssertions: []json.RawMessage{ + json.RawMessage(`{"record":"A","compare":"eq","target":"1.2.3.4"}`), + }, + response: &checker.DnsResponse{ + A: []string{"1.2.3.4", "5.6.7.8"}, + }, + }, + wantSuccess: true, + wantErr: false, + }, + { + name: "CNAME does not match", + args: args{ + rawAssertions: []json.RawMessage{ + json.RawMessage(`{"record":"CNAME","compare":"eq","target":"not-example.com"}`), + }, + response: &checker.DnsResponse{ + CNAME: "example.com", + }, + }, + wantSuccess: false, + wantErr: false, + }, + { + name: "Unknown record type", + args: args{ + rawAssertions: []json.RawMessage{ + json.RawMessage(`{"record":"FOO","compare":"eq","target":"bar"}`), + }, + response: &checker.DnsResponse{}, + }, + wantSuccess: false, + wantErr: true, + }, + { + name: "Invalid assertion JSON", + args: args{ + rawAssertions: []json.RawMessage{ + json.RawMessage(`not a json`), + }, + response: &checker.DnsResponse{}, + }, + wantSuccess: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := handlers.EvaluateDNSAssertions(tt.args.rawAssertions, tt.args.response) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.wantSuccess { + t.Errorf("EvaluateDNSAssertions() = %v, want %v", got, tt.wantSuccess) + } + }) + } +} diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index 18d6b2e1..598c6825 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -89,7 +89,7 @@ func (h Handler) TCPHandler(c *gin.Context) { var response checker.TCPResponse var retry int - if req.Retry == 0 { + if req.Retry != 0 { retry = int(req.Retry) } else { retry = 3 diff --git a/apps/checker/pkg/assertions/assertions.go b/apps/checker/pkg/assertions/assertions.go index 2a073660..541f3a23 100644 --- a/apps/checker/pkg/assertions/assertions.go +++ b/apps/checker/pkg/assertions/assertions.go @@ -3,6 +3,7 @@ package assertions import ( "encoding/json" "fmt" + "slices" "strings" "github.com/openstatushq/openstatus/apps/checker/request" @@ -32,6 +33,12 @@ type StringTargetType struct { Target string `json:"target"` } +type RecordTarget struct { + Comparator request.RecordComparator `json:"compare"` + Target string `json:"target"` + Record request.Record `json:"record"` +} + func (target StringTargetType) StringEvaluate(s string) bool { switch target.Comparator { case request.StringContains: @@ -111,3 +118,34 @@ func (target StatusTarget) StatusEvaluate(value int64) bool { } return true } + +func (target RecordTarget) RecordEvaluate(s []string) bool { + switch target.Comparator { + case request.RecordEquals: + if !slices.Contains(s, target.Target) { + return false + } + + case request.RecordNotEquals: + if slices.Contains(s, target.Target) { + return false + } + + case request.RecordContains: + for _, record := range s { + if strings.Contains(record, target.Target) { + return true + } + } + return false + case request.RecordNotContains: + for _, record := range s { + if strings.Contains(record, target.Target) { + return false + } + } + return true + } + + return true +} diff --git a/apps/checker/pkg/assertions/assertions_test.go b/apps/checker/pkg/assertions/assertions_test.go index cdc19279..a876d07b 100644 --- a/apps/checker/pkg/assertions/assertions_test.go +++ b/apps/checker/pkg/assertions/assertions_test.go @@ -118,3 +118,85 @@ func TestHeaderTarget_HeaderEvaluate(t *testing.T) { }) } } + +func TestRecordTarget_RecordEvaluate(t *testing.T) { + type fields struct { + Comparator request.RecordComparator + Target string + } + type args struct { + s []string + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "RecordEquals true", + fields: fields{Comparator: request.RecordEquals, Target: "foo"}, + args: args{s: []string{"foo", "bar"}}, + want: true, + }, + { + name: "RecordEquals false", + fields: fields{Comparator: request.RecordEquals, Target: "baz"}, + args: args{s: []string{"foo", "bar"}}, + want: false, + }, + { + name: "RecordNotEquals true", + fields: fields{Comparator: request.RecordNotEquals, Target: "baz"}, + args: args{s: []string{"foo", "bar"}}, + want: true, + }, + { + name: "RecordNotEquals false", + fields: fields{Comparator: request.RecordNotEquals, Target: "foo"}, + args: args{s: []string{"foo", "bar"}}, + want: false, + }, + { + name: "RecordContains true", + fields: fields{Comparator: request.RecordContains, Target: "ba"}, + args: args{s: []string{"foo", "bar"}}, + want: true, + }, + { + name: "RecordContains false", + fields: fields{Comparator: request.RecordContains, Target: "baz"}, + args: args{s: []string{"foo", "bar"}}, + want: false, + }, + { + name: "RecordNotContains true", + fields: fields{Comparator: request.RecordNotContains, Target: "baz"}, + args: args{s: []string{"foo", "bar"}}, + want: true, + }, + { + name: "RecordNotContains false", + fields: fields{Comparator: request.RecordNotContains, Target: "ba"}, + args: args{s: []string{"foo", "bar"}}, + want: false, + }, + { + name: "Empty slice", + fields: fields{Comparator: request.RecordEquals, Target: "foo"}, + args: args{s: []string{}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := RecordTarget{ + Comparator: tt.fields.Comparator, + Target: tt.fields.Target, + } + if got := target.RecordEvaluate(tt.args.s); got != tt.want { + t.Errorf("RecordTarget.RecordEvaluate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/apps/checker/pkg/job/dns_job.go b/apps/checker/pkg/job/dns_job.go new file mode 100644 index 00000000..8cac0fff --- /dev/null +++ b/apps/checker/pkg/job/dns_job.go @@ -0,0 +1,14 @@ +package job + +import ( + "context" + + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" +) + +type DNSPrivateRegionData struct {} + +func (jobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) ( *DNSPrivateRegionData, error) { + + return nil,nil +} diff --git a/apps/checker/pkg/job/job.go b/apps/checker/pkg/job/job.go index 72154169..07353437 100644 --- a/apps/checker/pkg/job/job.go +++ b/apps/checker/pkg/job/job.go @@ -30,6 +30,7 @@ type HttpPrivateRegionData struct { type JobRunner interface { TCPJob(ctx context.Context, monitor *v1.TCPMonitor) (*TCPPrivateRegionData, error) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor) (*HttpPrivateRegionData, error) + DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*DNSPrivateRegionData, error) } type jobRunner struct{} diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index e64a22e3..cd59fad0 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -100,16 +100,7 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { } - // // Stop jobs for monitors that no longer exist - // for id := range mm.HttpMonitors { - // if _, stillExists := currentIDs[id]; !stillExists { - // mm.Scheduler.Del(id) - // mm.mu.Lock() - // delete(mm.HttpMonitors, id) - // mm.mu.Unlock() - // } - // } // TCP monitors: start jobs for new monitors for _, m := range res.Msg.TcpMonitors { @@ -163,20 +154,65 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { } } + for _, m := range res.Msg.DnsMonitors { + currentIDs[m.Id] = struct{}{} + _, err := mm.Scheduler.Lookup(m.Id) + if err != nil { + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + // StartAfter: time.Now().Add(5 * time.Millisecond), + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) + _, err := mm.JobRunner.DNSJob(ctx.Context, monitor) + if err != nil { + log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + } + resp, ingestErr := mm.Client.IngestDNS(ctx.Context, &connect.Request[v1.IngestDNSRequest]{ + Msg: &v1.IngestDNSRequest{ + MonitorId: monitor.Id, + + // Id: data.ID, + // Uri: monitor.Uri, + // Message: data.Message, + // Latency: data.Latency, + // RequestStatus: data.RequestStatus, + // Error: int64(data.Error), + // CronTimestamp: data.CronTimestamp, + // Timestamp: data.Timestamp, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) + + return nil + }, + } + err := mm.Scheduler.AddWithID(m.Id, &task) + + if err != nil { + log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) + } + } + + for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { mm.Scheduler.Del(id) } } - // Stop jobs for TCP monitors that no longer exist - // for id := range mm.TcpMonitors { - // if _, stillExists := currentIDs[id]; !stillExists { - // mm.Scheduler.Del(id) - // mm.mu.Lock() - // delete(mm.TcpMonitors, id) - // mm.mu.Unlock() - // } - // } + } func intervalToSecond(interval string) int { @@ -199,93 +235,3 @@ func intervalToSecond(interval string) int { return 0 } } - -// func (mm *MonitorManager) ScheduleHTTPJob(ctx context.Context, monitor *v1.HTTPMonitor, done chan bool) { -// interval := intervalToSecond(monitor.Periodicity) -// if interval == 0 { -// log.Printf("Invalid interval for monitor %s (%s): %s", monitor.Id, monitor.Url, monitor.Periodicity) -// return -// } - -// ticker := time.NewTicker(time.Duration(1) * time.Second) -// defer ticker.Stop() - -// for { -// select { -// case <-ticker.C: -// log.Printf("Starting job for monitor %s (%s)", monitor.Id, monitor.Url) -// data, err := mm.JobRunner.HTTPJob(ctx, monitor) -// if err != nil { -// log.Printf("Monitor check failed for %s (%s): %v", monitor.Id, monitor.Url, err) -// continue -// } -// resp, ingestErr := mm.Client.IngestHTTP(ctx, &connect.Request[v1.IngestHTTPRequest]{ -// Msg: &v1.IngestHTTPRequest{ -// Id: monitor.Id, -// Url: monitor.Url, -// Message: data.Message, -// Latency: data.Latency, -// Timing: data.Timing, -// Headers: data.Headers, -// Body: data.Body, -// RequestStatus: data.RequestStatus, -// StatusCode: int64(data.StatusCode), -// Error: int64(data.Error), -// CronTimestamp: data.CronTimestamp, -// Timestamp: data.Timestamp, -// }, -// }) -// if ingestErr != nil { -// log.Printf("Failed to ingest HTTP result for %s (%s): %v", monitor.Id, monitor.Url, ingestErr) -// } else { -// log.Printf("Monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Url, resp) -// } -// case <-done: -// log.Printf("Shutting down job for monitor %s (%s)", monitor.Id, monitor.Url) -// return -// } -// } -// } - -// func (mm *MonitorManager) ScheduleTCPJob(ctx context.Context, monitor *v1.TCPMonitor, done chan bool) { -// interval := intervalToSecond(monitor.Periodicity) -// if interval == 0 { -// log.Printf("Invalid interval for TCP monitor %s (%s): %s", monitor.Id, monitor.Uri, monitor.Periodicity) -// return -// } - -// ticker := time.NewTicker(time.Duration(1) * time.Second) -// defer ticker.Stop() - -// for { -// select { -// case <-ticker.C: -// log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) -// data, err := mm.JobRunner.TCPJob(ctx, monitor) -// if err != nil { -// log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) -// continue -// } -// resp, ingestErr := mm.Client.IngestTCP(ctx, &connect.Request[v1.IngestTCPRequest]{ -// Msg: &v1.IngestTCPRequest{ -// Id: monitor.Id, -// Uri: monitor.Uri, -// Message: data.Message, -// Latency: data.Latency, -// RequestStatus: data.RequestStatus, -// Error: int64(data.Error), -// CronTimestamp: data.CronTimestamp, -// Timestamp: data.Timestamp, -// }, -// }) -// if ingestErr != nil { -// log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) -// } else { -// log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) -// } -// case <-done: -// log.Printf("Shutting down TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) -// return -// } -// } -// } diff --git a/apps/checker/pkg/scheduler/scheduler_test.go b/apps/checker/pkg/scheduler/scheduler_test.go index be118865..9642707e 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -19,6 +19,7 @@ import ( type mockJobRunner struct { HTTPJobCalled atomic.Bool TCPJobCalled atomic.Bool + DNSJobCalled atomic.Bool mu sync.Mutex } @@ -32,11 +33,17 @@ func (m *mockJobRunner) TCPJob(ctx context.Context, monitor *v1.TCPMonitor) (*jo return &job.TCPPrivateRegionData{}, nil } +func (m *mockJobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*job.DNSPrivateRegionData, error) { + + m.TCPJobCalled.Store(true) + return &job.DNSPrivateRegionData{}, nil +} // mockClient implements v1.PrivateLocationServiceClient for testing type mockClient struct { MonitorsFunc func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) IngestHTTPFunc func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) IngestTCPFunc func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) + IngestDNSFunc func(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { @@ -48,6 +55,9 @@ func (m *mockClient) IngestHTTP(ctx context.Context, req *connect.Request[v1.Ing func (m *mockClient) IngestTCP(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) { return m.IngestTCPFunc(ctx, req) } +func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) { + return m.IngestDNSFunc(ctx, req) +} func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() @@ -68,6 +78,7 @@ func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { IngestTCPFunc: func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) { return connect.NewResponse(&v1.IngestTCPResponse{}), nil }, + } jobRunner := &mockJobRunner{} diff --git a/apps/checker/proto/private_location/v1/assertions.pb.go b/apps/checker/proto/private_location/v1/assertions.pb.go index c33032b4..0fd81a8d 100644 --- a/apps/checker/proto/private_location/v1/assertions.pb.go +++ b/apps/checker/proto/private_location/v1/assertions.pb.go @@ -155,6 +155,61 @@ func (StringComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } +type RecordComparator int32 + +const ( + RecordComparator_RECORD_COMPARATOR_UNSPECIFIED RecordComparator = 0 + RecordComparator_RECORD_COMPARATOR_EQUAL RecordComparator = 1 + RecordComparator_RECORD_COMPARATOR_NOT_EQUAL RecordComparator = 2 + RecordComparator_RECORD_COMPARATOR_CONTAINS RecordComparator = 3 + RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS RecordComparator = 4 +) + +// Enum value maps for RecordComparator. +var ( + RecordComparator_name = map[int32]string{ + 0: "RECORD_COMPARATOR_UNSPECIFIED", + 1: "RECORD_COMPARATOR_EQUAL", + 2: "RECORD_COMPARATOR_NOT_EQUAL", + 3: "RECORD_COMPARATOR_CONTAINS", + 4: "RECORD_COMPARATOR_NOT_CONTAINS", + } + RecordComparator_value = map[string]int32{ + "RECORD_COMPARATOR_UNSPECIFIED": 0, + "RECORD_COMPARATOR_EQUAL": 1, + "RECORD_COMPARATOR_NOT_EQUAL": 2, + "RECORD_COMPARATOR_CONTAINS": 3, + "RECORD_COMPARATOR_NOT_CONTAINS": 4, + } +) + +func (x RecordComparator) Enum() *RecordComparator { + p := new(RecordComparator) + *p = x + return p +} + +func (x RecordComparator) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RecordComparator) Descriptor() protoreflect.EnumDescriptor { + return file_private_location_v1_assertions_proto_enumTypes[2].Descriptor() +} + +func (RecordComparator) Type() protoreflect.EnumType { + return &file_private_location_v1_assertions_proto_enumTypes[2] +} + +func (x RecordComparator) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RecordComparator.Descriptor instead. +func (RecordComparator) EnumDescriptor() ([]byte, []int) { + return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} +} + type StatusCodeAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target int64 `protobuf:"varint,1,opt,name=target,proto3" json:"target,omitempty"` @@ -319,6 +374,66 @@ func (x *HeaderAssertion) GetKey() string { return "" } +type RecordAssertion struct { + state protoimpl.MessageState `protogen:"open.v1"` + Record string `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` + Comparator RecordComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.RecordComparator" json:"comparator,omitempty"` + Targert string `protobuf:"bytes,3,opt,name=targert,proto3" json:"targert,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordAssertion) Reset() { + *x = RecordAssertion{} + mi := &file_private_location_v1_assertions_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordAssertion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordAssertion) ProtoMessage() {} + +func (x *RecordAssertion) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_assertions_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordAssertion.ProtoReflect.Descriptor instead. +func (*RecordAssertion) Descriptor() ([]byte, []int) { + return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{3} +} + +func (x *RecordAssertion) GetRecord() string { + if x != nil { + return x.Record + } + return "" +} + +func (x *RecordAssertion) GetComparator() RecordComparator { + if x != nil { + return x.Comparator + } + return RecordComparator_RECORD_COMPARATOR_UNSPECIFIED +} + +func (x *RecordAssertion) GetTargert() string { + if x != nil { + return x.Targert + } + return "" +} + var File_private_location_v1_assertions_proto protoreflect.FileDescriptor const file_private_location_v1_assertions_proto_rawDesc = "" + @@ -339,7 +454,13 @@ const file_private_location_v1_assertions_proto_rawDesc = "" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\x12\x10\n" + - "\x03key\x18\x03 \x01(\tR\x03key*\x8f\x02\n" + + "\x03key\x18\x03 \x01(\tR\x03key\"\x8a\x01\n" + + "\x0fRecordAssertion\x12\x16\n" + + "\x06record\x18\x01 \x01(\tR\x06record\x12E\n" + + "\n" + + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.RecordComparatorR\n" + + "comparator\x12\x18\n" + + "\atargert\x18\x03 \x01(\tR\atargert*\x8f\x02\n" + "\x10NumberComparator\x12!\n" + "\x1dNUMBER_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17NUMBER_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + @@ -360,7 +481,13 @@ const file_private_location_v1_assertions_proto_rawDesc = "" + "'STRING_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\b\x12\x1f\n" + "\x1bSTRING_COMPARATOR_LESS_THAN\x10\t\x12(\n" + "$STRING_COMPARATOR_LESS_THAN_OR_EQUAL\x10\n" + - "BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "*\xb7\x01\n" + + "\x10RecordComparator\x12!\n" + + "\x1dRECORD_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17RECORD_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + + "\x1bRECORD_COMPARATOR_NOT_EQUAL\x10\x02\x12\x1e\n" + + "\x1aRECORD_COMPARATOR_CONTAINS\x10\x03\x12\"\n" + + "\x1eRECORD_COMPARATOR_NOT_CONTAINS\x10\x04BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_assertions_proto_rawDescOnce sync.Once @@ -374,24 +501,27 @@ func file_private_location_v1_assertions_proto_rawDescGZIP() []byte { return file_private_location_v1_assertions_proto_rawDescData } -var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_private_location_v1_assertions_proto_goTypes = []any{ (NumberComparator)(0), // 0: private_location.v1.NumberComparator (StringComparator)(0), // 1: private_location.v1.StringComparator - (*StatusCodeAssertion)(nil), // 2: private_location.v1.StatusCodeAssertion - (*BodyAssertion)(nil), // 3: private_location.v1.BodyAssertion - (*HeaderAssertion)(nil), // 4: private_location.v1.HeaderAssertion + (RecordComparator)(0), // 2: private_location.v1.RecordComparator + (*StatusCodeAssertion)(nil), // 3: private_location.v1.StatusCodeAssertion + (*BodyAssertion)(nil), // 4: private_location.v1.BodyAssertion + (*HeaderAssertion)(nil), // 5: private_location.v1.HeaderAssertion + (*RecordAssertion)(nil), // 6: private_location.v1.RecordAssertion } var file_private_location_v1_assertions_proto_depIdxs = []int32{ 0, // 0: private_location.v1.StatusCodeAssertion.comparator:type_name -> private_location.v1.NumberComparator 1, // 1: private_location.v1.BodyAssertion.comparator:type_name -> private_location.v1.StringComparator 1, // 2: private_location.v1.HeaderAssertion.comparator:type_name -> private_location.v1.StringComparator - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 2, // 3: private_location.v1.RecordAssertion.comparator:type_name -> private_location.v1.RecordComparator + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_assertions_proto_init() } @@ -404,8 +534,8 @@ func file_private_location_v1_assertions_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc)), - NumEnums: 2, - NumMessages: 3, + NumEnums: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/apps/checker/proto/private_location/v1/dns_monitor.pb.go b/apps/checker/proto/private_location/v1/dns_monitor.pb.go new file mode 100644 index 00000000..6a86da10 --- /dev/null +++ b/apps/checker/proto/private_location/v1/dns_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: private_location/v1/dns_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DNSMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + RecordAssertions []*RecordAssertion `protobuf:"bytes,13,rep,name=record_assertions,json=recordAssertions,proto3" json:"record_assertions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DNSMonitor) Reset() { + *x = DNSMonitor{} + mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DNSMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DNSMonitor) ProtoMessage() {} + +func (x *DNSMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DNSMonitor.ProtoReflect.Descriptor instead. +func (*DNSMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_dns_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *DNSMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *DNSMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *DNSMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *DNSMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *DNSMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *DNSMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *DNSMonitor) GetRecordAssertions() []*RecordAssertion { + if x != nil { + return x.RecordAssertions + } + return nil +} + +var File_private_location_v1_dns_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_dns_monitor_proto_rawDesc = "" + + "\n" + + "%private_location/v1/dns_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"\x89\x02\n" + + "\n" + + "DNSMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12Q\n" + + "\x11record_assertions\x18\r \x03(\v2$.private_location.v1.RecordAssertionR\x10recordAssertionsB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_dns_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_dns_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_dns_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_dns_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_dns_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc))) + }) + return file_private_location_v1_dns_monitor_proto_rawDescData +} + +var file_private_location_v1_dns_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_dns_monitor_proto_goTypes = []any{ + (*DNSMonitor)(nil), // 0: private_location.v1.DNSMonitor + (*RecordAssertion)(nil), // 1: private_location.v1.RecordAssertion +} +var file_private_location_v1_dns_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.DNSMonitor.record_assertions:type_name -> private_location.v1.RecordAssertion + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_dns_monitor_proto_init() } +func file_private_location_v1_dns_monitor_proto_init() { + if File_private_location_v1_dns_monitor_proto != nil { + return + } + file_private_location_v1_assertions_proto_init() + file_private_location_v1_dns_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_dns_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_dns_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_dns_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_dns_monitor_proto = out.File + file_private_location_v1_dns_monitor_proto_goTypes = nil + file_private_location_v1_dns_monitor_proto_depIdxs = nil +} diff --git a/apps/checker/proto/private_location/v1/private_location.connect.go b/apps/checker/proto/private_location/v1/private_location.connect.go index 25b3e700..40235485 100644 --- a/apps/checker/proto/private_location/v1/private_location.connect.go +++ b/apps/checker/proto/private_location/v1/private_location.connect.go @@ -41,6 +41,9 @@ const ( // PrivateLocationServiceIngestHTTPProcedure is the fully-qualified name of the // PrivateLocationService's IngestHTTP RPC. PrivateLocationServiceIngestHTTPProcedure = "/private_location.v1.PrivateLocationService/IngestHTTP" + // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the + // PrivateLocationService's IngestDNS RPC. + PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -49,6 +52,7 @@ type PrivateLocationServiceClient interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) + IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -80,6 +84,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithClientOptions(opts...), ), + ingestDNS: connect.NewClient[IngestDNSRequest, IngestDNSResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestDNSProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), + connect.WithClientOptions(opts...), + ), } } @@ -88,6 +98,7 @@ type privateLocationServiceClient struct { monitors *connect.Client[MonitorsRequest, MonitorsResponse] ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] + ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -105,12 +116,18 @@ func (c *privateLocationServiceClient) IngestHTTP(ctx context.Context, req *conn return c.ingestHTTP.CallUnary(ctx, req) } +// IngestDNS calls private_location.v1.PrivateLocationService.IngestDNS. +func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { + return c.ingestDNS.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) + IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -138,6 +155,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestDNSHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestDNSProcedure, + svc.IngestDNS, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -146,6 +169,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestTCPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestHTTPProcedure: privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestDNSProcedure: + privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -166,3 +191,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestTCP(context.Context, *co func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestHTTP is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) +} diff --git a/apps/checker/proto/private_location/v1/private_location.pb.go b/apps/checker/proto/private_location/v1/private_location.pb.go index af801e36..29804740 100644 --- a/apps/checker/proto/private_location/v1/private_location.pb.go +++ b/apps/checker/proto/private_location/v1/private_location.pb.go @@ -9,6 +9,7 @@ package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + _ "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -61,6 +62,7 @@ type MonitorsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` + DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +111,13 @@ func (x *MonitorsResponse) GetTcpMonitors() []*TCPMonitor { return nil } +func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { + if x != nil { + return x.DnsMonitors + } + return nil +} + type IngestTCPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -437,15 +446,220 @@ func (*IngestHTTPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{5} } +type Records struct { + state protoimpl.MessageState `protogen:"open.v1"` + Record []string `protobuf:"bytes,1,rep,name=record,proto3" json:"record,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Records) Reset() { + *x = Records{} + mi := &file_private_location_v1_private_location_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Records) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Records) ProtoMessage() {} + +func (x *Records) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Records.ProtoReflect.Descriptor instead. +func (*Records) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{6} +} + +func (x *Records) GetRecord() []string { + if x != nil { + return x.Record + } + return nil +} + +type IngestDNSRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` + Records map[string]*Records `protobuf:"bytes,9,rep,name=records,proto3" json:"records,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` + Error int64 `protobuf:"varint,11,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestDNSRequest) Reset() { + *x = IngestDNSRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestDNSRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestDNSRequest) ProtoMessage() {} + +func (x *IngestDNSRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestDNSRequest.ProtoReflect.Descriptor instead. +func (*IngestDNSRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{7} +} + +func (x *IngestDNSRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestDNSRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestDNSRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestDNSRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestDNSRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestDNSRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestDNSRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestDNSRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestDNSRequest) GetRecords() map[string]*Records { + if x != nil { + return x.Records + } + return nil +} + +func (x *IngestDNSRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +func (x *IngestDNSRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +type IngestDNSResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestDNSResponse) Reset() { + *x = IngestDNSResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestDNSResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestDNSResponse) ProtoMessage() {} + +func (x *IngestDNSResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestDNSResponse.ProtoReflect.Descriptor instead. +func (*IngestDNSResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\x9d\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xe1\x01\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + - "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\"\x9e\x02\n" + + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + @@ -476,12 +690,32 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "statusCode\x18\f \x01(\x03R\n" + "statusCode\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\"\x14\n" + - "\x12IngestHTTPResponse2\xb2\x02\n" + + "\x12IngestHTTPResponse\"!\n" + + "\aRecords\x12\x16\n" + + "\x06record\x18\x01 \x03(\tR\x06record\"\xc6\x03\n" + + "\x10IngestDNSRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12$\n" + + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + + "\amessage\x18\b \x01(\tR\amessage\x12L\n" + + "\arecords\x18\t \x03(\v22.private_location.v1.IngestDNSRequest.RecordsEntryR\arecords\x12\x16\n" + + "\x06timing\x18\n" + + " \x01(\tR\x06timing\x12\x14\n" + + "\x05error\x18\v \x01(\x03R\x05error\x1aX\n" + + "\fRecordsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + + "\x11IngestDNSResponse2\x90\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + - "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -495,7 +729,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -503,23 +737,33 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestTCPResponse)(nil), // 3: private_location.v1.IngestTCPResponse (*IngestHTTPRequest)(nil), // 4: private_location.v1.IngestHTTPRequest (*IngestHTTPResponse)(nil), // 5: private_location.v1.IngestHTTPResponse - (*HTTPMonitor)(nil), // 6: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 7: private_location.v1.TCPMonitor + (*Records)(nil), // 6: private_location.v1.Records + (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest + (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse + nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 6, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 7, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 0, // 2: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 3: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 4: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 1, // 5: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 6: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 7: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 5, // [5:8] is the sub-list for method output_type - 2, // [2:5] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 9, // [9:13] is the sub-list for method output_type + 5, // [5:9] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -527,6 +771,7 @@ func file_private_location_v1_private_location_proto_init() { if File_private_location_v1_private_location_proto != nil { return } + file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} @@ -535,7 +780,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/checker/request/request.go b/apps/checker/request/request.go index 39bb409a..2828e334 100644 --- a/apps/checker/request/request.go +++ b/apps/checker/request/request.go @@ -7,10 +7,11 @@ import ( type AssertionType string const ( - AssertionHeader AssertionType = "header" - AssertionTextBody AssertionType = "textBody" - AssertionStatus AssertionType = "status" - AssertionJsonBody AssertionType = "jsonBody" + AssertionHeader AssertionType = "header" + AssertionTextBody AssertionType = "textBody" + AssertionStatus AssertionType = "status" + AssertionJsonBody AssertionType = "jsonBody" + AssertionDnsRecord AssertionType = "dnsRecord" ) type StringComparator string @@ -39,6 +40,26 @@ const ( NumberLowerThanEqual NumberComparator = "lte" ) +type RecordComparator string + +const ( + RecordEquals RecordComparator = "eq" + RecordNotEquals RecordComparator = "not_eq" + RecordContains RecordComparator = "contains" + RecordNotContains RecordComparator = "not_contains" +) + +type Record string + +const ( + RecordA Record = "A" + RecordAAAA Record = "AAAA" + RecordCNAME Record = "CNAME" + RecordMX Record = "MX" + RecordNS Record = "NS" + RecordTXT Record = "TXT" +) + type Assertion struct { AssertionType AssertionType `json:"type"` Comparator json.RawMessage `json:"compare"` @@ -103,3 +124,21 @@ type PingRequest struct { RequestId int64 `json:"requestId"` WorkspaceId int64 `json:"workspaceId"` } + +type DNSCheckerRequest struct { + Status string `json:"status"` + WorkspaceID string `json:"workspaceId"` + URI string `json:"uri"` + MonitorID string `json:"monitorId"` + Trigger string `json:"trigger,omitempty"` + RawAssertions []json.RawMessage `json:"assertions,omitempty"` + RequestId int64 `json:"requestId,omitempty"` + CronTimestamp int64 `json:"cronTimestamp"` + Timeout int64 `json:"timeout"` + DegradedAfter int64 `json:"degradedAfter,omitempty"` + Retry int64 `json:"retry,omitempty"` + OtelConfig struct { + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers,omitempty"` + } `json:"otelConfig"` +} diff --git a/apps/private-location/internal/server/ingest_dns.go b/apps/private-location/internal/server/ingest_dns.go new file mode 100644 index 00000000..e871e9d8 --- /dev/null +++ b/apps/private-location/internal/server/ingest_dns.go @@ -0,0 +1,93 @@ +package server + +import ( + "context" + "errors" + "strconv" + "time" + + "connectrpc.com/connect" + "github.com/openstatushq/openstatus/apps/private-location/internal/database" + private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" + "github.com/rs/zerolog/log" +) + +type DNSResponse struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + Records map[string][]string `json:"records"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + CronTimestamp int64 `json:"cronTimestamp"` + + Error uint8 `json:"error"` +} + +func (h *privateLocationHandler) IngestDNS(ctx context.Context, req *connect.Request[private_locationv1.IngestDNSRequest]) (*connect.Response[private_locationv1.IngestDNSResponse], error) { + token := req.Header().Get("openstatus-token") + if token == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("missing token")) + } + + dataSourceName := "tcp_dns__v0" + + var monitors database.Monitor + err := h.db.Get(&monitors, "SELECT monitor.* FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.id = ?", token, req.Msg.Id) + + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var region database.PrivateLocation + err = h.db.Get(®ion, "SELECT private_location.id FROM private_location join private_location_to_monitor a ON private_location.id = a.private_location_id WHERE a.monitor_id = ? and private_location.token = ?", monitors.ID, token) + + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + records := make(map[string][]string) + for _, record := range req.Msg.Records { + r := []string{} + for _, value := range record.GetRecord() { + r = append(r, value) + } + records[record.String()] = r + } + + data := DNSResponse{ + ID: req.Msg.Id, + WorkspaceID: int64(monitors.WorkspaceID), + Timestamp: req.Msg.Timestamp, + Error: uint8(req.Msg.Error), + // ErrorMessage: req.Msg.ErrorMessage, + Region: strconv.Itoa(region.ID), + MonitorID: int64(monitors.ID), + Timing: req.Msg.Timing, + Latency: req.Msg.Latency, + CronTimestamp: req.Msg.CronTimestamp, + Trigger: "cron", + URI: req.Msg.Uri, + RequestStatus: req.Msg.RequestStatus, + + Records: records, + } + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + _, err = h.db.NamedExec("UPDATE private_location SET last_seen_at = :last_seen_at WHERE id = :id", map[string]any{ + "last_seen_at": time.Now().Unix(), + "id": region.ID, + }) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to update private location") + } + return connect.NewResponse(&private_locationv1.IngestDNSResponse{}), nil +} diff --git a/apps/private-location/proto/private_location/v1/assertions.pb.go b/apps/private-location/proto/private_location/v1/assertions.pb.go index c33032b4..0fd81a8d 100644 --- a/apps/private-location/proto/private_location/v1/assertions.pb.go +++ b/apps/private-location/proto/private_location/v1/assertions.pb.go @@ -155,6 +155,61 @@ func (StringComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } +type RecordComparator int32 + +const ( + RecordComparator_RECORD_COMPARATOR_UNSPECIFIED RecordComparator = 0 + RecordComparator_RECORD_COMPARATOR_EQUAL RecordComparator = 1 + RecordComparator_RECORD_COMPARATOR_NOT_EQUAL RecordComparator = 2 + RecordComparator_RECORD_COMPARATOR_CONTAINS RecordComparator = 3 + RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS RecordComparator = 4 +) + +// Enum value maps for RecordComparator. +var ( + RecordComparator_name = map[int32]string{ + 0: "RECORD_COMPARATOR_UNSPECIFIED", + 1: "RECORD_COMPARATOR_EQUAL", + 2: "RECORD_COMPARATOR_NOT_EQUAL", + 3: "RECORD_COMPARATOR_CONTAINS", + 4: "RECORD_COMPARATOR_NOT_CONTAINS", + } + RecordComparator_value = map[string]int32{ + "RECORD_COMPARATOR_UNSPECIFIED": 0, + "RECORD_COMPARATOR_EQUAL": 1, + "RECORD_COMPARATOR_NOT_EQUAL": 2, + "RECORD_COMPARATOR_CONTAINS": 3, + "RECORD_COMPARATOR_NOT_CONTAINS": 4, + } +) + +func (x RecordComparator) Enum() *RecordComparator { + p := new(RecordComparator) + *p = x + return p +} + +func (x RecordComparator) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RecordComparator) Descriptor() protoreflect.EnumDescriptor { + return file_private_location_v1_assertions_proto_enumTypes[2].Descriptor() +} + +func (RecordComparator) Type() protoreflect.EnumType { + return &file_private_location_v1_assertions_proto_enumTypes[2] +} + +func (x RecordComparator) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RecordComparator.Descriptor instead. +func (RecordComparator) EnumDescriptor() ([]byte, []int) { + return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} +} + type StatusCodeAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target int64 `protobuf:"varint,1,opt,name=target,proto3" json:"target,omitempty"` @@ -319,6 +374,66 @@ func (x *HeaderAssertion) GetKey() string { return "" } +type RecordAssertion struct { + state protoimpl.MessageState `protogen:"open.v1"` + Record string `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` + Comparator RecordComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.RecordComparator" json:"comparator,omitempty"` + Targert string `protobuf:"bytes,3,opt,name=targert,proto3" json:"targert,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordAssertion) Reset() { + *x = RecordAssertion{} + mi := &file_private_location_v1_assertions_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordAssertion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordAssertion) ProtoMessage() {} + +func (x *RecordAssertion) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_assertions_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordAssertion.ProtoReflect.Descriptor instead. +func (*RecordAssertion) Descriptor() ([]byte, []int) { + return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{3} +} + +func (x *RecordAssertion) GetRecord() string { + if x != nil { + return x.Record + } + return "" +} + +func (x *RecordAssertion) GetComparator() RecordComparator { + if x != nil { + return x.Comparator + } + return RecordComparator_RECORD_COMPARATOR_UNSPECIFIED +} + +func (x *RecordAssertion) GetTargert() string { + if x != nil { + return x.Targert + } + return "" +} + var File_private_location_v1_assertions_proto protoreflect.FileDescriptor const file_private_location_v1_assertions_proto_rawDesc = "" + @@ -339,7 +454,13 @@ const file_private_location_v1_assertions_proto_rawDesc = "" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\x12\x10\n" + - "\x03key\x18\x03 \x01(\tR\x03key*\x8f\x02\n" + + "\x03key\x18\x03 \x01(\tR\x03key\"\x8a\x01\n" + + "\x0fRecordAssertion\x12\x16\n" + + "\x06record\x18\x01 \x01(\tR\x06record\x12E\n" + + "\n" + + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.RecordComparatorR\n" + + "comparator\x12\x18\n" + + "\atargert\x18\x03 \x01(\tR\atargert*\x8f\x02\n" + "\x10NumberComparator\x12!\n" + "\x1dNUMBER_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17NUMBER_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + @@ -360,7 +481,13 @@ const file_private_location_v1_assertions_proto_rawDesc = "" + "'STRING_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\b\x12\x1f\n" + "\x1bSTRING_COMPARATOR_LESS_THAN\x10\t\x12(\n" + "$STRING_COMPARATOR_LESS_THAN_OR_EQUAL\x10\n" + - "BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "*\xb7\x01\n" + + "\x10RecordComparator\x12!\n" + + "\x1dRECORD_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17RECORD_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + + "\x1bRECORD_COMPARATOR_NOT_EQUAL\x10\x02\x12\x1e\n" + + "\x1aRECORD_COMPARATOR_CONTAINS\x10\x03\x12\"\n" + + "\x1eRECORD_COMPARATOR_NOT_CONTAINS\x10\x04BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_assertions_proto_rawDescOnce sync.Once @@ -374,24 +501,27 @@ func file_private_location_v1_assertions_proto_rawDescGZIP() []byte { return file_private_location_v1_assertions_proto_rawDescData } -var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_private_location_v1_assertions_proto_goTypes = []any{ (NumberComparator)(0), // 0: private_location.v1.NumberComparator (StringComparator)(0), // 1: private_location.v1.StringComparator - (*StatusCodeAssertion)(nil), // 2: private_location.v1.StatusCodeAssertion - (*BodyAssertion)(nil), // 3: private_location.v1.BodyAssertion - (*HeaderAssertion)(nil), // 4: private_location.v1.HeaderAssertion + (RecordComparator)(0), // 2: private_location.v1.RecordComparator + (*StatusCodeAssertion)(nil), // 3: private_location.v1.StatusCodeAssertion + (*BodyAssertion)(nil), // 4: private_location.v1.BodyAssertion + (*HeaderAssertion)(nil), // 5: private_location.v1.HeaderAssertion + (*RecordAssertion)(nil), // 6: private_location.v1.RecordAssertion } var file_private_location_v1_assertions_proto_depIdxs = []int32{ 0, // 0: private_location.v1.StatusCodeAssertion.comparator:type_name -> private_location.v1.NumberComparator 1, // 1: private_location.v1.BodyAssertion.comparator:type_name -> private_location.v1.StringComparator 1, // 2: private_location.v1.HeaderAssertion.comparator:type_name -> private_location.v1.StringComparator - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 2, // 3: private_location.v1.RecordAssertion.comparator:type_name -> private_location.v1.RecordComparator + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_assertions_proto_init() } @@ -404,8 +534,8 @@ func file_private_location_v1_assertions_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc)), - NumEnums: 2, - NumMessages: 3, + NumEnums: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/apps/private-location/proto/private_location/v1/dns_monitor.pb.go b/apps/private-location/proto/private_location/v1/dns_monitor.pb.go new file mode 100644 index 00000000..6a86da10 --- /dev/null +++ b/apps/private-location/proto/private_location/v1/dns_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: private_location/v1/dns_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DNSMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + RecordAssertions []*RecordAssertion `protobuf:"bytes,13,rep,name=record_assertions,json=recordAssertions,proto3" json:"record_assertions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DNSMonitor) Reset() { + *x = DNSMonitor{} + mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DNSMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DNSMonitor) ProtoMessage() {} + +func (x *DNSMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DNSMonitor.ProtoReflect.Descriptor instead. +func (*DNSMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_dns_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *DNSMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *DNSMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *DNSMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *DNSMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *DNSMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *DNSMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *DNSMonitor) GetRecordAssertions() []*RecordAssertion { + if x != nil { + return x.RecordAssertions + } + return nil +} + +var File_private_location_v1_dns_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_dns_monitor_proto_rawDesc = "" + + "\n" + + "%private_location/v1/dns_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"\x89\x02\n" + + "\n" + + "DNSMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12Q\n" + + "\x11record_assertions\x18\r \x03(\v2$.private_location.v1.RecordAssertionR\x10recordAssertionsB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_dns_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_dns_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_dns_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_dns_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_dns_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc))) + }) + return file_private_location_v1_dns_monitor_proto_rawDescData +} + +var file_private_location_v1_dns_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_dns_monitor_proto_goTypes = []any{ + (*DNSMonitor)(nil), // 0: private_location.v1.DNSMonitor + (*RecordAssertion)(nil), // 1: private_location.v1.RecordAssertion +} +var file_private_location_v1_dns_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.DNSMonitor.record_assertions:type_name -> private_location.v1.RecordAssertion + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_dns_monitor_proto_init() } +func file_private_location_v1_dns_monitor_proto_init() { + if File_private_location_v1_dns_monitor_proto != nil { + return + } + file_private_location_v1_assertions_proto_init() + file_private_location_v1_dns_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_dns_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_dns_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_dns_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_dns_monitor_proto = out.File + file_private_location_v1_dns_monitor_proto_goTypes = nil + file_private_location_v1_dns_monitor_proto_depIdxs = nil +} diff --git a/apps/private-location/proto/private_location/v1/private_location.connect.go b/apps/private-location/proto/private_location/v1/private_location.connect.go index 25b3e700..40235485 100644 --- a/apps/private-location/proto/private_location/v1/private_location.connect.go +++ b/apps/private-location/proto/private_location/v1/private_location.connect.go @@ -41,6 +41,9 @@ const ( // PrivateLocationServiceIngestHTTPProcedure is the fully-qualified name of the // PrivateLocationService's IngestHTTP RPC. PrivateLocationServiceIngestHTTPProcedure = "/private_location.v1.PrivateLocationService/IngestHTTP" + // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the + // PrivateLocationService's IngestDNS RPC. + PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -49,6 +52,7 @@ type PrivateLocationServiceClient interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) + IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -80,6 +84,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithClientOptions(opts...), ), + ingestDNS: connect.NewClient[IngestDNSRequest, IngestDNSResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestDNSProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), + connect.WithClientOptions(opts...), + ), } } @@ -88,6 +98,7 @@ type privateLocationServiceClient struct { monitors *connect.Client[MonitorsRequest, MonitorsResponse] ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] + ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -105,12 +116,18 @@ func (c *privateLocationServiceClient) IngestHTTP(ctx context.Context, req *conn return c.ingestHTTP.CallUnary(ctx, req) } +// IngestDNS calls private_location.v1.PrivateLocationService.IngestDNS. +func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { + return c.ingestDNS.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) + IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -138,6 +155,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestDNSHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestDNSProcedure, + svc.IngestDNS, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -146,6 +169,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestTCPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestHTTPProcedure: privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestDNSProcedure: + privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -166,3 +191,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestTCP(context.Context, *co func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestHTTP is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) +} diff --git a/apps/private-location/proto/private_location/v1/private_location.pb.go b/apps/private-location/proto/private_location/v1/private_location.pb.go index af801e36..29804740 100644 --- a/apps/private-location/proto/private_location/v1/private_location.pb.go +++ b/apps/private-location/proto/private_location/v1/private_location.pb.go @@ -9,6 +9,7 @@ package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + _ "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -61,6 +62,7 @@ type MonitorsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` + DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +111,13 @@ func (x *MonitorsResponse) GetTcpMonitors() []*TCPMonitor { return nil } +func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { + if x != nil { + return x.DnsMonitors + } + return nil +} + type IngestTCPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -437,15 +446,220 @@ func (*IngestHTTPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{5} } +type Records struct { + state protoimpl.MessageState `protogen:"open.v1"` + Record []string `protobuf:"bytes,1,rep,name=record,proto3" json:"record,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Records) Reset() { + *x = Records{} + mi := &file_private_location_v1_private_location_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Records) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Records) ProtoMessage() {} + +func (x *Records) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Records.ProtoReflect.Descriptor instead. +func (*Records) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{6} +} + +func (x *Records) GetRecord() []string { + if x != nil { + return x.Record + } + return nil +} + +type IngestDNSRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` + Records map[string]*Records `protobuf:"bytes,9,rep,name=records,proto3" json:"records,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` + Error int64 `protobuf:"varint,11,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestDNSRequest) Reset() { + *x = IngestDNSRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestDNSRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestDNSRequest) ProtoMessage() {} + +func (x *IngestDNSRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestDNSRequest.ProtoReflect.Descriptor instead. +func (*IngestDNSRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{7} +} + +func (x *IngestDNSRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestDNSRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestDNSRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestDNSRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestDNSRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestDNSRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestDNSRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestDNSRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestDNSRequest) GetRecords() map[string]*Records { + if x != nil { + return x.Records + } + return nil +} + +func (x *IngestDNSRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +func (x *IngestDNSRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +type IngestDNSResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestDNSResponse) Reset() { + *x = IngestDNSResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestDNSResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestDNSResponse) ProtoMessage() {} + +func (x *IngestDNSResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestDNSResponse.ProtoReflect.Descriptor instead. +func (*IngestDNSResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\x9d\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xe1\x01\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + - "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\"\x9e\x02\n" + + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + @@ -476,12 +690,32 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "statusCode\x18\f \x01(\x03R\n" + "statusCode\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\"\x14\n" + - "\x12IngestHTTPResponse2\xb2\x02\n" + + "\x12IngestHTTPResponse\"!\n" + + "\aRecords\x12\x16\n" + + "\x06record\x18\x01 \x03(\tR\x06record\"\xc6\x03\n" + + "\x10IngestDNSRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12$\n" + + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + + "\amessage\x18\b \x01(\tR\amessage\x12L\n" + + "\arecords\x18\t \x03(\v22.private_location.v1.IngestDNSRequest.RecordsEntryR\arecords\x12\x16\n" + + "\x06timing\x18\n" + + " \x01(\tR\x06timing\x12\x14\n" + + "\x05error\x18\v \x01(\x03R\x05error\x1aX\n" + + "\fRecordsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + + "\x11IngestDNSResponse2\x90\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + - "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -495,7 +729,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -503,23 +737,33 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestTCPResponse)(nil), // 3: private_location.v1.IngestTCPResponse (*IngestHTTPRequest)(nil), // 4: private_location.v1.IngestHTTPRequest (*IngestHTTPResponse)(nil), // 5: private_location.v1.IngestHTTPResponse - (*HTTPMonitor)(nil), // 6: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 7: private_location.v1.TCPMonitor + (*Records)(nil), // 6: private_location.v1.Records + (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest + (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse + nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 6, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 7, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 0, // 2: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 3: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 4: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 1, // 5: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 6: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 7: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 5, // [5:8] is the sub-list for method output_type - 2, // [2:5] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 9, // [9:13] is the sub-list for method output_type + 5, // [5:9] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -527,6 +771,7 @@ func file_private_location_v1_private_location_proto_init() { if File_private_location_v1_private_location_proto != nil { return } + file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} @@ -535,7 +780,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/server/src/routes/v1/monitors/index.ts b/apps/server/src/routes/v1/monitors/index.ts index 57479bb9..6788b91d 100644 --- a/apps/server/src/routes/v1/monitors/index.ts +++ b/apps/server/src/routes/v1/monitors/index.ts @@ -6,9 +6,11 @@ import { registerDeleteMonitor } from "./delete"; import { registerGetMonitor } from "./get"; import { registerGetAllMonitors } from "./get_all"; import { registerPostMonitor } from "./post"; +import { registerPostMonitorDNS } from "./post_dns"; import { registerPostMonitorHTTP } from "./post_http"; import { registerPostMonitorTCP } from "./post_tcp"; import { registerPutMonitor } from "./put"; +import { registerPutDNSMonitor } from "./put_dns"; import { registerPutHTTPMonitor } from "./put_http"; import { registerPutTCPMonitor } from "./put_tcp"; import { registerGetMonitorResult } from "./results/get"; @@ -27,8 +29,10 @@ registerDeleteMonitor(monitorsApi); registerPostMonitor(monitorsApi); registerPostMonitorHTTP(monitorsApi); registerPostMonitorTCP(monitorsApi); +registerPostMonitorDNS(monitorsApi); registerPutHTTPMonitor(monitorsApi); registerPutTCPMonitor(monitorsApi); +registerPutDNSMonitor(monitorsApi); // registerGetMonitorSummary(monitorsApi); registerTriggerMonitor(monitorsApi); diff --git a/apps/server/src/routes/v1/monitors/post_dns.test.ts b/apps/server/src/routes/v1/monitors/post_dns.test.ts new file mode 100644 index 00000000..8e436b80 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_dns.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("create a valid monitor", async () => { + const res = await app.request("/v1/monitor/dns", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "10m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + uri: "openstatus.dev", + }, + active: true, + public: true, + }), + }); + + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a status report with invalid payload should return 400", async () => { + const res = await app.request("/v1/monitor/dns", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "21m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + url: "openstatus.dev", + }, + active: true, + public: true, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/dns", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/routes/v1/monitors/post_dns.ts b/apps/server/src/routes/v1/monitors/post_dns.ts new file mode 100644 index 00000000..a3e41b37 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_dns.ts @@ -0,0 +1,144 @@ +import { createRoute, z } from "@hono/zod-openapi"; + +import { Events } from "@openstatus/analytics"; +import { and, db, eq, isNull, sql } from "@openstatus/db"; +import { monitor } from "@openstatus/db/src/schema"; + +// import { serialize } from "@openstatus/assertions"; + +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; +import type { monitorsApi } from "./index"; +import { DNSMonitorSchema, MonitorSchema } from "./schema"; +// import { getAssertionNew } from "./utils"; + +const postRoute = createRoute({ + method: "post", + tags: ["monitor"], + summary: "Create a http monitor", + path: "/dns", + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], + request: { + body: { + description: "The monitor to create", + content: { + "application/json": { + schema: DNSMonitorSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: MonitorSchema, + }, + }, + description: "Create a monitor", + }, + ...openApiErrorResponses, + }, +}); + +export function registerPostMonitorDNS(api: typeof monitorsApi) { + return api.openapi(postRoute, async (c) => { + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; + const input = c.req.valid("json"); + const count = ( + await db + .select({ count: sql`count(*)` }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ) + .all() + )[0].count; + + if (count >= limits.monitors) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more monitors", + }); + } + + if (!limits.periodicity.includes(input.frequency)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); + } + + if (limits["max-regions"] < input.regions.length) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } + + for (const region of input.regions) { + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } + } + + const { request, regions, assertions, openTelemetry, ...rest } = input; + + const otelHeadersEntries = openTelemetry?.headers + ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ + key: key, + value: value, + })) + : undefined; + + // const assert = assertions ? getAssertionNew(assertions) : []; + + const _newMonitor = await db + .insert(monitor) + .values({ + ...rest, + periodicity: input.frequency, + jobType: "dns", + url: input.request.uri, + workspaceId: workspaceId, + regions: regions ? regions.join(",") : undefined, + // assertions: assert.length > 0 ? serialize(assert) : undefined, + timeout: input.timeout || 45000, + otelEndpoint: openTelemetry?.endpoint, + otelHeaders: otelHeadersEntries + ? JSON.stringify(otelHeadersEntries) + : undefined, + }) + .returning() + .get(); + + const otelHeader = _newMonitor.otelHeaders + ? z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .parse(JSON.parse(_newMonitor.otelHeaders)) + // biome-ignore lint/performance/noAccumulatingSpread: + .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) + : undefined; + + const data = MonitorSchema.parse({ + ..._newMonitor, + openTelemetry: _newMonitor.otelEndpoint + ? { + headers: otelHeader, + endpoint: _newMonitor.otelEndpoint ?? undefined, + } + : undefined, + }); + + return c.json(data, 200); + }); +} diff --git a/apps/server/src/routes/v1/monitors/put_dns.test.ts b/apps/server/src/routes/v1/monitors/put_dns.test.ts new file mode 100644 index 00000000..e985b8af --- /dev/null +++ b/apps/server/src/routes/v1/monitors/put_dns.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; + +test("update the monitor", async () => { + const res = await app.request("/v1/monitor/dns/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "New Name", + }), + }); + + expect(res.status).toBe(400); +}); + +test("invalid monitor id should return 404", async () => { + const res = await app.request("/v1/monitor/dns/404", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + frequency: "10m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + uri: "openstatus.dev", + }, + active: true, + public: true, + }), + }); + + expect(res.status).toBe(404); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/dns/2", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + /* */ + }), + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/routes/v1/monitors/put_dns.ts b/apps/server/src/routes/v1/monitors/put_dns.ts new file mode 100644 index 00000000..fd533cf0 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/put_dns.ts @@ -0,0 +1,143 @@ +import { createRoute, z } from "@hono/zod-openapi"; + +import { and, db, eq, isNull } from "@openstatus/db"; +import { monitor } from "@openstatus/db/src/schema"; + +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; +import { Events } from "@openstatus/analytics"; +import type { monitorsApi } from "./index"; +import { DNSMonitorSchema, MonitorSchema, ParamsSchema } from "./schema"; + +const putRoute = createRoute({ + method: "put", + tags: ["monitor"], + summary: "Update an DNS monitor", + path: "/dns/{id}", + middleware: [trackMiddleware(Events.UpdateMonitor)], + request: { + params: ParamsSchema, + body: { + description: "The monitor to update", + content: { + "application/json": { + schema: DNSMonitorSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: MonitorSchema, + }, + }, + description: "Update a monitor", + }, + ...openApiErrorResponses, + }, +}); + +export function registerPutDNSMonitor(api: typeof monitorsApi) { + return api.openapi(putRoute, async (c) => { + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; + const { id } = c.req.valid("param"); + const input = c.req.valid("json"); + + if (input.frequency && !limits.periodicity.includes(input.frequency)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); + } + + if (input.regions) { + for (const region of input.regions) { + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } + } + } + + const _monitor = await db + .select() + .from(monitor) + .where( + and( + eq(monitor.id, Number(id)), + isNull(monitor.deletedAt), + eq(monitor.workspaceId, workspaceId), + ), + ) + .get(); + + if (!_monitor) { + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); + } + + if (_monitor.jobType !== "tcp") { + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); + } + + const { request, regions, openTelemetry, assertions, ...rest } = input; + + const otelHeadersEntries = openTelemetry?.headers + ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ + key: key, + value: value, + })) + : undefined; + + const _newMonitor = await db + .update(monitor) + .set({ + ...rest, + periodicity: input.frequency, + url: input.request.uri, + regions: regions ? regions.join(",") : undefined, + otelHeaders: otelHeadersEntries + ? JSON.stringify(otelHeadersEntries) + : undefined, + otelEndpoint: openTelemetry?.endpoint, + timeout: input.timeout || 45000, + updatedAt: new Date(), + }) + .where(eq(monitor.id, Number(_monitor.id))) + .returning() + .get(); + const otelHeader = _newMonitor.otelHeaders + ? z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .parse(JSON.parse(_newMonitor.otelHeaders)) + // biome-ignore lint/performance/noAccumulatingSpread: + .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) + : undefined; + + const data = MonitorSchema.parse({ + ..._newMonitor, + openTelemetry: _newMonitor.otelEndpoint + ? { + headers: otelHeader, + endpoint: _newMonitor.otelEndpoint ?? undefined, + } + : undefined, + }); + return c.json(data, 200); + }); +} diff --git a/apps/server/src/routes/v1/monitors/schema.ts b/apps/server/src/routes/v1/monitors/schema.ts index 249fdf6f..d584d6e1 100644 --- a/apps/server/src/routes/v1/monitors/schema.ts +++ b/apps/server/src/routes/v1/monitors/schema.ts @@ -1,6 +1,10 @@ import { z } from "@hono/zod-openapi"; -import { numberCompare, stringCompare } from "@openstatus/assertions"; +import { + numberCompare, + recordCompare, + stringCompare, +} from "@openstatus/assertions"; import { monitorJobTypes, monitorMethods } from "@openstatus/db/src/schema"; import { monitorPeriodicitySchema, @@ -454,6 +458,13 @@ const tcpRequestSchema = z.object({ }), }); +const dnsRequestSchema = z.object({ + uri: z.string().openapi({ + description: "The DNS server to query", + examples: ["openstatus.dev"], + }), +}); + const statusCodeAssertion = z .object({ kind: z.literal("statusCode"), @@ -514,6 +525,21 @@ const textBodyAssertions = z.object({ }), }); +const dnsRecordAssertion = z.object({ + kind: z.literal("dnsRecord"), + recordType: z.enum(["A", "AAAA", "CNAME", "MX", "TXT"]).openapi({ + description: "Type of DNS record to check", + examples: ["A", "CNAME"], + }), + compare: recordCompare.openapi({ + description: "Comparison operator", + examples: ["eq", "not_eq", "contains", "not_contains"], + }), + target: z.string().openapi({ + description: "DNS record value to assert", + examples: ["example.com"], + }), +}); export const assertionsSchema = z.discriminatedUnion("kind", [ statusCodeAssertion, headerAssertions, @@ -542,3 +568,16 @@ export const TCPMonitorSchema = baseRequest .openapi({ title: "TCP Monitor Schema", }); + +export const DNSMonitorSchema = baseRequest + .extend({ + request: dnsRequestSchema.openapi({ + description: "The DNS Request we are sending", + }), + assertions: z.array(dnsRecordAssertion).optional().openapi({ + description: "Assertions to run on the DNS response", + }), + }) + .openapi({ + title: "DNS Monitor Schema", + }); diff --git a/apps/ssh-server/main.go b/apps/ssh-server/main.go index 9e59c11a..d98b12d7 100644 --- a/apps/ssh-server/main.go +++ b/apps/ssh-server/main.go @@ -114,7 +114,6 @@ func main() { } ssh.HostKeyFile("/data/id_rsa") - // server.AddHostKey(ssh.HostKeyFile(filepath string)) log.Println("starting ssh server on port 2222...") log.Fatal(server.ListenAndServe()) diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index 3636293f..b210930f 100644 --- a/apps/workflows/src/cron/checker.ts +++ b/apps/workflows/src/cron/checker.ts @@ -20,6 +20,7 @@ import { getSentry } from "@hono/sentry"; import { getLogger } from "@logtape/logtape"; import type { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants"; import { + type DNSPayloadSchema, type httpPayloadSchema, type tpcPayloadSchema, transformHeaders, @@ -193,6 +194,7 @@ const createCronTask = async ({ let payload: | z.infer | z.infer + | z.infer | null = null; // @@ -240,6 +242,26 @@ const createCronTask = async ({ : undefined, }; } + if (row.jobType === "dns") { + payload = { + workspaceId: String(row.workspaceId), + monitorId: String(row.id), + url: row.url, + cronTimestamp: timestamp, + status: status, + assertions: row.assertions ? JSON.parse(row.assertions) : null, + degradedAfter: row.degradedAfter, + timeout: row.timeout, + trigger: "cron", + otelConfig: row.otelEndpoint + ? { + endpoint: row.otelEndpoint, + headers: transformHeaders(row.otelHeaders), + } + : undefined, + retry: row.retry || 3, + }; + } if (!payload) { throw new Error("Invalid jobType"); diff --git a/packages/assertions/src/v1.ts b/packages/assertions/src/v1.ts index 92a08850..879dd0ce 100644 --- a/packages/assertions/src/v1.ts +++ b/packages/assertions/src/v1.ts @@ -17,6 +17,13 @@ export const stringCompare = z.enum([ ]); export const numberCompare = z.enum(["eq", "not_eq", "gt", "gte", "lt", "lte"]); +export const recordCompare = z.enum([ + "contains", + "not_contains", + "eq", + "not_eq", +]); + function evaluateNumber( value: number, compare: z.infer, @@ -199,6 +206,15 @@ export const jsonBodyAssertion = base.merge( }), ); +export const recordAssertion = base.merge( + z.object({ + type: z.literal("dnsRecord"), + record: z.enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"]), + compare: recordCompare, + target: z.string(), + }), +); + export const assertion = z.discriminatedUnion("type", [ statusAssertion, headerAssertion, diff --git a/packages/proto/private_location/v1/assertions.proto b/packages/proto/private_location/v1/assertions.proto index 0f494b66..6c7d8c9b 100644 --- a/packages/proto/private_location/v1/assertions.proto +++ b/packages/proto/private_location/v1/assertions.proto @@ -1,47 +1,59 @@ - syntax = "proto3"; package private_location.v1; option go_package = "github.com/openstatushq/openstatus/packages/proto/private_location/v1;v1"; - enum NumberComparator { - NUMBER_COMPARATOR_UNSPECIFIED = 0; - NUMBER_COMPARATOR_EQUAL = 1; - NUMBER_COMPARATOR_NOT_EQUAL = 2; - NUMBER_COMPARATOR_GREATER_THAN = 3; - NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL = 4; - NUMBER_COMPARATOR_LESS_THAN = 5; - NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL = 6; + NUMBER_COMPARATOR_UNSPECIFIED = 0; + NUMBER_COMPARATOR_EQUAL = 1; + NUMBER_COMPARATOR_NOT_EQUAL = 2; + NUMBER_COMPARATOR_GREATER_THAN = 3; + NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL = 4; + NUMBER_COMPARATOR_LESS_THAN = 5; + NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL = 6; } enum StringComparator { - STRING_COMPARATOR_UNSPECIFIED = 0; - STRING_COMPARATOR_CONTAINS = 1; - STRING_COMPARATOR_NOT_CONTAINS = 2; - STRING_COMPARATOR_EQUAL = 3; - STRING_COMPARATOR_NOT_EQUAL = 4; - STRING_COMPARATOR_EMPTY = 5; - STRING_COMPARATOR_NOT_EMPTY = 6; - STRING_COMPARATOR_GREATER_THAN = 7; - STRING_COMPARATOR_GREATER_THAN_OR_EQUAL = 8; - STRING_COMPARATOR_LESS_THAN = 9; - STRING_COMPARATOR_LESS_THAN_OR_EQUAL = 10; + STRING_COMPARATOR_UNSPECIFIED = 0; + STRING_COMPARATOR_CONTAINS = 1; + STRING_COMPARATOR_NOT_CONTAINS = 2; + STRING_COMPARATOR_EQUAL = 3; + STRING_COMPARATOR_NOT_EQUAL = 4; + STRING_COMPARATOR_EMPTY = 5; + STRING_COMPARATOR_NOT_EMPTY = 6; + STRING_COMPARATOR_GREATER_THAN = 7; + STRING_COMPARATOR_GREATER_THAN_OR_EQUAL = 8; + STRING_COMPARATOR_LESS_THAN = 9; + STRING_COMPARATOR_LESS_THAN_OR_EQUAL = 10; +} + +enum RecordComparator { + RECORD_COMPARATOR_UNSPECIFIED = 0; + RECORD_COMPARATOR_EQUAL = 1; + RECORD_COMPARATOR_NOT_EQUAL = 2; + RECORD_COMPARATOR_CONTAINS = 3; + RECORD_COMPARATOR_NOT_CONTAINS = 4; } message StatusCodeAssertion { - int64 target = 1; - NumberComparator comparator = 2; + int64 target = 1; + NumberComparator comparator = 2; } message BodyAssertion { - string target = 1; - StringComparator comparator = 2; + string target = 1; + StringComparator comparator = 2; } message HeaderAssertion { - string target = 1; - StringComparator comparator = 2; - string key = 3; + string target = 1; + StringComparator comparator = 2; + string key = 3; +} + +message RecordAssertion { + string record = 1; + RecordComparator comparator = 2; + string targert = 3; } diff --git a/packages/proto/private_location/v1/dns_monitor.proto b/packages/proto/private_location/v1/dns_monitor.proto new file mode 100644 index 00000000..a1794f0b --- /dev/null +++ b/packages/proto/private_location/v1/dns_monitor.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package private_location.v1; + +import "private_location/v1/assertions.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/private_location/v1;v1"; + +message DNSMonitor { + string id = 1; + string uri = 2; + int64 timeout = 3; + optional int64 degraded_at = 4; + string periodicity = 5; + int64 retry = 6; + + repeated RecordAssertion record_assertions = 13; + +} diff --git a/packages/proto/private_location/v1/private_location.proto b/packages/proto/private_location/v1/private_location.proto index 8d420852..cafeea79 100644 --- a/packages/proto/private_location/v1/private_location.proto +++ b/packages/proto/private_location/v1/private_location.proto @@ -2,6 +2,9 @@ syntax = "proto3"; package private_location.v1; +import "google/protobuf/struct.proto"; + +import "private_location/v1/dns_monitor.proto"; import "private_location/v1/http_monitor.proto"; import "private_location/v1/tcp_monitor.proto"; @@ -12,6 +15,8 @@ service PrivateLocationService { rpc Monitors(MonitorsRequest) returns (MonitorsResponse) {} rpc IngestTCP(IngestTCPRequest) returns (IngestTCPResponse) {} rpc IngestHTTP(IngestHTTPRequest) returns (IngestHTTPResponse) {} + rpc IngestDNS(IngestDNSRequest) returns (IngestDNSResponse) {} + } message MonitorsRequest {} @@ -19,6 +24,7 @@ message MonitorsRequest {} message MonitorsResponse { repeated HTTPMonitor http_monitors = 1; repeated TCPMonitor tcp_monitors = 2; + repeated DNSMonitor dns_monitors = 3; } @@ -59,3 +65,24 @@ message IngestHTTPRequest { message IngestHTTPResponse { } + + +message Records { + repeated string record = 1; +} +message IngestDNSRequest { + string id = 1; + string monitorId = 2; + int64 latency = 3; + int64 timestamp = 4; + int64 cronTimestamp = 5; + string uri = 6; + string requestStatus = 7; + string message = 8; + map records = 9; + string timing = 10; + int64 error = 11; +} +message IngestDNSResponse { + +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 0eb6863a..8350a591 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -49,6 +49,27 @@ export const tpcPayloadSchema = z.object({ export type TcpPayload = z.infer; +export const DNSPayloadSchema = z.object({ + status: z.enum(monitorStatus), + workspaceId: z.string(), + uri: z.string(), + monitorId: z.string(), + assertions: z.array(base).nullable(), + cronTimestamp: z.number(), + timeout: z.number().default(45000), + degradedAfter: z.number().nullable(), + trigger: z.enum(["cron", "api"]).optional().nullable().default("cron"), + otelConfig: z + .object({ + endpoint: z.string(), + headers: z.record(z.string()), + }) + .optional(), + retry: z.number().default(3), +}); + +export type DNSPayload = z.infer; + export function transformHeaders(headers: { key: string; value: string }[]) { return headers.length > 0 ? headers.reduce( -- 2.51.2