From ca13285546ea29ffe36acd6a29a8ed3374451474 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 29 Mar 2023 14:00:37 -0500 Subject: [PATCH 1/6] feat(coderd): add DERP healthcheck --- coderd/coderd.go | 24 +- coderd/coderdtest/coderdtest.go | 8 + coderd/debug.go | 44 +++- coderd/debug_test.go | 71 ++++++ coderd/healthcheck/accessurl.go | 48 ++++ coderd/healthcheck/derp.go | 383 ++++++++++++++++++++++++++++++ coderd/healthcheck/derp_test.go | 195 +++++++++++++++ coderd/healthcheck/healthcheck.go | 37 +++ coderd/healthcheck/websocket.go | 3 + go.mod | 2 +- go.sum | 6 +- 11 files changed, 814 insertions(+), 7 deletions(-) create mode 100644 coderd/debug_test.go create mode 100644 coderd/healthcheck/accessurl.go create mode 100644 coderd/healthcheck/derp.go create mode 100644 coderd/healthcheck/derp_test.go create mode 100644 coderd/healthcheck/healthcheck.go create mode 100644 coderd/healthcheck/websocket.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 9573d9b3cf889..a97a5a8213f76 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -33,6 +33,7 @@ import ( "tailscale.com/derp/derphttp" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/util/singleflight" "cdr.dev/slog" "github.com/coder/coder/buildinfo" @@ -46,6 +47,7 @@ import ( "github.com/coder/coder/coderd/database/dbtype" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/healthcheck" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/metricscache" @@ -123,7 +125,10 @@ type Options struct { TemplateScheduleStore schedule.TemplateScheduleStore // AppSigningKey denotes the symmetric key to use for signing app tickets. // The key must be 64 bytes long. - AppSigningKey []byte + AppSigningKey []byte + HealthcheckFunc func(ctx context.Context) (*healthcheck.Report, error) + HealthcheckTimeout time.Duration + HealthcheckRefresh time.Duration // APIRateLimit is the minutely throughput rate limit per user or ip. // Setting a rate limit <0 will disable the rate limiter across the entire @@ -235,6 +240,19 @@ func New(options *Options) *API { if len(options.AppSigningKey) != 64 { panic("coderd: AppSigningKey must be 64 bytes long") } + if options.HealthcheckFunc == nil { + options.HealthcheckFunc = func(ctx context.Context) (*healthcheck.Report, error) { + return healthcheck.Run(ctx, &healthcheck.ReportOptions{ + DERPMap: options.DERPMap.Clone(), + }) + } + } + if options.HealthcheckTimeout == 0 { + options.HealthcheckTimeout = 30 * time.Second + } + if options.HealthcheckRefresh == 0 { + options.HealthcheckRefresh = 10 * time.Minute + } siteCacheDir := options.CacheDir if siteCacheDir != "" { @@ -293,6 +311,7 @@ func New(options *Options) *API { Auditor: atomic.Pointer[audit.Auditor]{}, TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{}, Experiments: experiments, + healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -718,6 +737,7 @@ func New(options *Options) *API { ) r.Get("/coordinator", api.debugCoordinator) + r.Get("/health", api.debugDeploymentHealth) }) }) @@ -773,6 +793,8 @@ type API struct { // Experiments contains the list of experiments currently enabled. // This is used to gate features that are not yet ready for production. Experiments codersdk.Experiments + + healthCheckGroup *singleflight.Group[string, *healthcheck.Report] } // Close waits for all WebSocket connections to drain before returning. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index b4ccfb9816bc8..92541893ffe43 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -60,6 +60,7 @@ import ( "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/healthcheck" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" @@ -105,6 +106,10 @@ type Options struct { TrialGenerator func(context.Context, string) error TemplateScheduleStore schedule.TemplateScheduleStore + HealthcheckFunc func(ctx context.Context) (*healthcheck.Report, error) + HealthcheckTimeout time.Duration + HealthcheckRefresh time.Duration + // All rate limits default to -1 (unlimited) in tests if not set. APIRateLimit int LoginRateLimit int @@ -335,6 +340,9 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can SwaggerEndpoint: options.SwaggerEndpoint, AppSigningKey: AppSigningKey, SSHConfig: options.ConfigSSH, + HealthcheckFunc: options.HealthcheckFunc, + HealthcheckTimeout: options.HealthcheckTimeout, + HealthcheckRefresh: options.HealthcheckRefresh, } } diff --git a/coderd/debug.go b/coderd/debug.go index c22b77e564648..20c6bb1243335 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -1,6 +1,14 @@ package coderd -import "net/http" +import ( + "context" + "net/http" + "time" + + "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) // @Summary Debug Info Wireguard Coordinator // @ID debug-info-wireguard-coordinator @@ -12,3 +20,37 @@ import "net/http" func (api *API) debugCoordinator(rw http.ResponseWriter, r *http.Request) { (*api.TailnetCoordinator.Load()).ServeHTTPDebug(rw, r) } + +// @Summary Debug Info Deployment Health +// @ID debug-info-deployment-health +// @Security CoderSessionToken +// @Produce text/html +// @Produce json +// @Tags Debug +// @Success 200 +// @Router /debug/health [get] +func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), api.HealthcheckTimeout) + defer cancel() + + resChan := api.healthCheckGroup.DoChan("", func() (*healthcheck.Report, error) { + return api.HealthcheckFunc(ctx) + }) + + select { + case <-ctx.Done(): + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Healthcheck is in progress and did not complete in time. Try again in a few seconds.", + }) + return + case res := <-resChan: + if time.Since(res.Val.Time) > api.HealthcheckRefresh { + api.healthCheckGroup.Forget("") + api.debugDeploymentHealth(rw, r) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, res.Val) + return + } +} diff --git a/coderd/debug_test.go b/coderd/debug_test.go new file mode 100644 index 0000000000000..7b6c8ef35d1b6 --- /dev/null +++ b/coderd/debug_test.go @@ -0,0 +1,71 @@ +package coderd_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httputil" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/testutil" +) + +func TestDebug(t *testing.T) { + t.Parallel() + t.Run("Health/OK", func(t *testing.T) { + t.Parallel() + + var ( + ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) + client = coderdtest.New(t, &coderdtest.Options{ + HealthcheckFunc: func(context.Context) (*healthcheck.Report, error) { + return &healthcheck.Report{}, nil + }, + }) + _ = coderdtest.CreateFirstUser(t, client) + ) + defer cancel() + + res, err := client.Request(ctx, "GET", "/debug/health", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + }) + + t.Run("Health/Timeout", func(t *testing.T) { + t.Parallel() + + var ( + ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) + client = coderdtest.New(t, &coderdtest.Options{ + HealthcheckTimeout: time.Microsecond, + HealthcheckFunc: func(context.Context) (*healthcheck.Report, error) { + t := time.NewTimer(time.Second) + defer t.Stop() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.C: + return &healthcheck.Report{}, nil + } + }, + }) + _ = coderdtest.CreateFirstUser(t, client) + ) + defer cancel() + + res, err := client.Request(ctx, "GET", "/api/v2/debug/health", nil) + require.NoError(t, err) + defer res.Body.Close() + dump, _ := httputil.DumpResponse(res, true) + fmt.Println(string(dump)) + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/coderd/healthcheck/accessurl.go b/coderd/healthcheck/accessurl.go new file mode 100644 index 0000000000000..a81d1cdc6481c --- /dev/null +++ b/coderd/healthcheck/accessurl.go @@ -0,0 +1,48 @@ +package healthcheck + +import ( + "context" + "io" + "net/http" + "net/url" + + "golang.org/x/xerrors" +) + +type AccessURLReport struct { + Reachable bool + StatusCode int + HealthzResponse string + Err error +} + +func (r *AccessURLReport) Run(ctx context.Context, accessURL *url.URL) { + accessURL, err := accessURL.Parse("/healthz") + if err != nil { + r.Err = xerrors.Errorf("parse healthz endpoint: %w", err) + return + } + + req, err := http.NewRequestWithContext(ctx, "GET", accessURL.String(), nil) + if err != nil { + r.Err = xerrors.Errorf("create healthz request: %w", err) + return + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + r.Err = xerrors.Errorf("get healthz endpoint: %w", err) + return + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + r.Err = xerrors.Errorf("read healthz response: %w", err) + return + } + + r.Reachable = true + r.StatusCode = res.StatusCode + r.HealthzResponse = string(body) +} diff --git a/coderd/healthcheck/derp.go b/coderd/healthcheck/derp.go new file mode 100644 index 0000000000000..ae75c5e7abf34 --- /dev/null +++ b/coderd/healthcheck/derp.go @@ -0,0 +1,383 @@ +package healthcheck + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/netip" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/net/netcheck" + "tailscale.com/net/portmapper" + "tailscale.com/prober" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + tslogger "tailscale.com/types/logger" +) + +type DERPReport struct { + mu sync.Mutex + + Regions map[int]*DERPRegionReport `json:"regions"` + + Netcheck *netcheck.Report `json:"netcheck"` + NetcheckLogs []string `json:"netcheck_logs"` +} + +type DERPRegionReport struct { + mu sync.Mutex + Region *tailcfg.DERPRegion `json:"region"` + + NodeReports []*DERPNodeReport `json:"node_reports"` +} +type DERPNodeReport struct { + Node *tailcfg.DERPNode `json:"node"` + + mu sync.Mutex + clientCounter int + + CanExchangeMessages bool `json:"can_exchange_messages"` + RoundTripPing time.Duration `json:"round_trip_ping"` + UsesWebsocket bool `json:"uses_websocket"` + ClientLogs [][]string `json:"client_logs"` + ClientErrs [][]error `json:"client_errs"` + + STUN DERPStunReport `json:"stun"` +} + +type DERPStunReport struct { + Enabled bool + CanSTUN bool + Error error +} + +type DERPReportOptions struct { + DERPMap *tailcfg.DERPMap +} + +func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) error { + r.Regions = map[int]*DERPRegionReport{} + + eg, ctx := errgroup.WithContext(ctx) + + for _, region := range opts.DERPMap.Regions { + region := region + eg.Go(func() error { + regionReport := DERPRegionReport{ + Region: region, + } + + err := regionReport.Run(ctx) + if err != nil { + return xerrors.Errorf("run region report: %w", err) + } + + r.mu.Lock() + r.Regions[region.RegionID] = ®ionReport + r.mu.Unlock() + return nil + }) + } + + ncLogf := func(format string, args ...interface{}) { + r.mu.Lock() + r.NetcheckLogs = append(r.NetcheckLogs, fmt.Sprintf(format, args...)) + r.mu.Unlock() + } + nc := &netcheck.Client{ + PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil), + Logf: tslogger.WithPrefix(ncLogf, "netcheck: "), + } + ncReport, err := nc.GetReport(ctx, opts.DERPMap) + if err != nil { + return xerrors.Errorf("run netcheck: %w", err) + } + r.Netcheck = ncReport + + return eg.Wait() +} + +func (r *DERPRegionReport) Run(ctx context.Context) error { + r.NodeReports = []*DERPNodeReport{} + eg, ctx := errgroup.WithContext(ctx) + + for _, node := range r.Region.Nodes { + node := node + eg.Go(func() error { + nodeReport := DERPNodeReport{ + Node: node, + } + + err := nodeReport.Run(ctx) + if err != nil { + return xerrors.Errorf("run node report: %w", err) + } + + r.mu.Lock() + r.NodeReports = append(r.NodeReports, &nodeReport) + r.mu.Unlock() + return nil + }) + } + + return eg.Wait() +} + +func (r *DERPNodeReport) derpURL() *url.URL { + derpURL := &url.URL{ + Scheme: "https", + Host: r.Node.HostName, + Path: "/derp", + } + if r.Node.ForceHTTP { + derpURL.Scheme = "http" + } + if r.Node.HostName == "" { + derpURL.Host = fmt.Sprintf("%s:%d", r.Node.IPv4, r.Node.DERPPort) + } + + return derpURL +} + +func (r *DERPNodeReport) Run(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + r.ClientLogs = [][]string{} + r.ClientErrs = [][]error{} + + r.doExchangeMessage(ctx) + r.doSTUNTest(ctx) + + return nil +} + +func (r *DERPNodeReport) doExchangeMessage(ctx context.Context) { + if r.Node.STUNOnly { + return + } + + var peerKey atomic.Pointer[key.NodePublic] + eg, ctx := errgroup.WithContext(ctx) + + receive, receiveID, err := r.derpClient(ctx, r.derpURL()) + if err != nil { + return + } + defer receive.Close() + + eg.Go(func() error { + defer receive.Close() + + pkt, err := r.recvData(receive) + if err != nil { + r.writeClientErr(receiveID, xerrors.Errorf("recv derp message: %w", err)) + return err + } + + if *peerKey.Load() != pkt.Source { + r.writeClientErr(receiveID, xerrors.Errorf("received pkt from unknown peer: %s", pkt.Source.ShortString())) + return err + } + + t, err := time.Parse(time.RFC3339Nano, string(pkt.Data)) + if err != nil { + r.writeClientErr(receiveID, xerrors.Errorf("parse time from peer: %w", err)) + return err + } + + r.mu.Lock() + r.CanExchangeMessages = true + r.RoundTripPing = time.Since(t) + r.mu.Unlock() + return nil + }) + eg.Go(func() error { + send, sendID, err := r.derpClient(ctx, r.derpURL()) + if err != nil { + return err + } + defer send.Close() + + key := send.SelfPublicKey() + peerKey.Store(&key) + + err = send.Send(receive.SelfPublicKey(), []byte(time.Now().Format(time.RFC3339Nano))) + if err != nil { + r.writeClientErr(sendID, xerrors.Errorf("send derp message: %w", err)) + return err + } + return nil + }) + + _ = eg.Wait() +} + +func (r *DERPNodeReport) doSTUNTest(ctx context.Context) { + if r.Node.STUNPort == -1 { + return + } + r.mu.Lock() + r.STUN.Enabled = true + r.mu.Unlock() + + addr, port, err := r.stunAddr(ctx) + if err != nil { + r.STUN.Error = xerrors.Errorf("get stun addr: %w", err) + return + } + + // We only create a prober to call ProbeUDP manually. + p, err := prober.DERP(prober.New(), "", time.Second, time.Second, time.Second) + if err != nil { + r.STUN.Error = xerrors.Errorf("create prober: %w", err) + return + } + + err = p.ProbeUDP(addr, port)(ctx) + if err != nil { + r.STUN.Error = xerrors.Errorf("probe stun: %w", err) + return + } + + r.mu.Lock() + r.STUN.CanSTUN = true + r.mu.Unlock() +} + +func (r *DERPNodeReport) stunAddr(ctx context.Context) (string, int, error) { + port := r.Node.STUNPort + if port == 0 { + port = 3478 + } + if port < 0 || port > 1<<16-1 { + return "", 0, xerrors.Errorf("invalid stun port %d", port) + } + + if r.Node.STUNTestIP != "" { + ip, err := netip.ParseAddr(r.Node.STUNTestIP) + if err != nil { + return "", 0, xerrors.Errorf("invalid stun test ip %q: %w", r.Node.STUNTestIP, err) + } + + return ip.String(), port, nil + } + + if r.Node.HostName != "" { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, r.Node.HostName) + if err != nil { + return "", 0, xerrors.Errorf("lookup ip addr: %w", err) + } + for _, a := range addrs { + return a.String(), port, nil + } + } + + if r.Node.IPv4 != "" { + ip, err := netip.ParseAddr(r.Node.IPv4) + if err != nil { + return "", 0, xerrors.Errorf("invalid ipv4 %q: %w", r.Node.IPv4, err) + } + + if !ip.Is4() { + return "", 0, xerrors.Errorf("provided node ipv4 is not v4 %q: %w", r.Node.IPv4, err) + } + + return ip.String(), port, nil + } + if r.Node.IPv6 != "" { + ip, err := netip.ParseAddr(r.Node.IPv6) + if err != nil { + return "", 0, xerrors.Errorf("invalid ipv6 %q: %w", r.Node.IPv6, err) + } + + if !ip.Is6() { + return "", 0, xerrors.Errorf("provided node ipv6 is not v6 %q: %w", r.Node.IPv6, err) + } + + return ip.String(), port, nil + } + + return "", 0, xerrors.New("no stun ips provided") +} + +func (r *DERPNodeReport) writeClientErr(clientID int, err error) { + r.mu.Lock() + r.ClientErrs[clientID] = append(r.ClientErrs[clientID], err) + r.mu.Unlock() +} + +func (r *DERPNodeReport) derpClient(ctx context.Context, derpURL *url.URL) (*derphttp.Client, int, error) { + r.mu.Lock() + id := r.clientCounter + r.clientCounter++ + r.ClientLogs = append(r.ClientLogs, []string{}) + r.ClientErrs = append(r.ClientErrs, []error{}) + r.mu.Unlock() + + client, err := derphttp.NewClient(key.NewNode(), derpURL.String(), func(format string, args ...any) { + r.mu.Lock() + defer r.mu.Unlock() + + msg := fmt.Sprintf(format, args...) + if strings.Contains(msg, "We'll use WebSockets on the next connection attempt") { + r.UsesWebsocket = true + } + r.ClientLogs[id] = append(r.ClientLogs[id], msg) + }) + if err != nil { + err := xerrors.Errorf("create derp client: %w", err) + r.writeClientErr(id, err) + return nil, id, err + } + + go func() { + <-ctx.Done() + _ = client.Close() + }() + + i := 0 + for ; i < 5; i++ { + err = client.Connect(ctx) + if err != nil { + r.writeClientErr(id, xerrors.Errorf("connect to derp: %w", err)) + continue + } + break + } + if i == 5 { + err := xerrors.Errorf("couldn't connect after 5 tries, last error: %w", err) + r.writeClientErr(id, xerrors.Errorf("couldn't connect after 5 tries, last error: %w", err)) + return nil, id, err + } + + return client, id, nil +} + +func (*DERPNodeReport) recvData(client *derphttp.Client) (derp.ReceivedPacket, error) { + for { + msg, err := client.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return derp.ReceivedPacket{}, nil + } + } + + switch msg := msg.(type) { + case derp.ReceivedPacket: + return msg, nil + default: + // Drop all others! + } + } +} diff --git a/coderd/healthcheck/derp_test.go b/coderd/healthcheck/derp_test.go new file mode 100644 index 0000000000000..c86969e4b75a3 --- /dev/null +++ b/coderd/healthcheck/derp_test.go @@ -0,0 +1,195 @@ +package healthcheck_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/ipn" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + + "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/tailnet" +) + +func TestDERP(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + derpSrv := derp.NewServer(key.NewNode(), func(format string, args ...any) { t.Logf(format, args...) }) + defer derpSrv.Close() + srv := httptest.NewServer(derphttp.Handler(derpSrv)) + defer srv.Close() + + var ( + ctx = context.Background() + report = healthcheck.DERPReport{} + derpURL, _ = url.Parse(srv.URL) + opts = &healthcheck.DERPReportOptions{ + DERPMap: &tailcfg.DERPMap{Regions: map[int]*tailcfg.DERPRegion{ + 1: { + EmbeddedRelay: true, + RegionID: 999, + Nodes: []*tailcfg.DERPNode{{ + Name: "1a", + RegionID: 999, + HostName: derpURL.Host, + IPv4: derpURL.Host, + STUNPort: -1, + InsecureForTests: true, + ForceHTTP: true, + }}, + }, + }}, + } + ) + + err := report.Run(ctx, opts) + require.NoError(t, err) + + for _, region := range report.Regions { + for _, node := range region.NodeReports { + assert.True(t, node.CanExchangeMessages) + assert.Positive(t, node.RoundTripPing) + assert.Len(t, node.ClientLogs, 2) + assert.Len(t, node.ClientLogs[0], 1) + assert.Len(t, node.ClientErrs[0], 0) + assert.Len(t, node.ClientLogs[1], 1) + assert.Len(t, node.ClientErrs[1], 0) + + assert.False(t, node.STUN.Enabled) + assert.False(t, node.STUN.CanSTUN) + assert.NoError(t, node.STUN.Error) + } + } + }) + + t.Run("OK/Tailscale/Dallas", func(t *testing.T) { + t.Parallel() + + derpSrv := derp.NewServer(key.NewNode(), func(format string, args ...any) { t.Logf(format, args...) }) + defer derpSrv.Close() + srv := httptest.NewServer(derphttp.Handler(derpSrv)) + defer srv.Close() + + var ( + ctx = context.Background() + report = healthcheck.DERPReport{} + opts = &healthcheck.DERPReportOptions{ + DERPMap: tsDERPMap(ctx, t), + } + ) + // Only include the Dallas region + opts.DERPMap.Regions = map[int]*tailcfg.DERPRegion{9: opts.DERPMap.Regions[9]} + + err := report.Run(ctx, opts) + require.NoError(t, err) + + for _, region := range report.Regions { + for _, node := range region.NodeReports { + assert.True(t, node.CanExchangeMessages) + assert.Positive(t, node.RoundTripPing) + assert.Len(t, node.ClientLogs, 2) + assert.Len(t, node.ClientLogs[0], 1) + assert.Len(t, node.ClientErrs[0], 0) + assert.Len(t, node.ClientLogs[1], 1) + assert.Len(t, node.ClientErrs[1], 0) + + assert.True(t, node.STUN.Enabled) + assert.True(t, node.STUN.CanSTUN) + assert.NoError(t, node.STUN.Error) + } + } + }) + + t.Run("ForceWebsockets", func(t *testing.T) { + t.Parallel() + + derpSrv := derp.NewServer(key.NewNode(), func(format string, args ...any) { t.Logf(format, args...) }) + defer derpSrv.Close() + handler, closeHandler := tailnet.WithWebsocketSupport(derpSrv, derphttp.Handler(derpSrv)) + defer closeHandler() + + first := true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if first { + first = false + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("bad request")) + return + } + + handler.ServeHTTP(w, r) + })) + + var ( + ctx = context.Background() + report = healthcheck.DERPReport{} + derpURL, _ = url.Parse(srv.URL) + opts = &healthcheck.DERPReportOptions{ + DERPMap: &tailcfg.DERPMap{Regions: map[int]*tailcfg.DERPRegion{ + 1: { + EmbeddedRelay: true, + RegionID: 999, + Nodes: []*tailcfg.DERPNode{{ + Name: "1a", + RegionID: 999, + HostName: derpURL.Host, + IPv4: derpURL.Host, + STUNPort: -1, + InsecureForTests: true, + ForceHTTP: true, + }}, + }, + }}, + } + ) + + report.Run(ctx, opts) + + for _, region := range report.Regions { + for _, node := range region.NodeReports { + assert.True(t, node.CanExchangeMessages) + assert.Positive(t, node.RoundTripPing) + assert.Len(t, node.ClientLogs, 2) + assert.Len(t, node.ClientLogs[0], 3) + assert.Len(t, node.ClientLogs[1], 3) + assert.Len(t, node.ClientErrs, 2) + assert.Len(t, node.ClientErrs[0], 1) + assert.Len(t, node.ClientErrs[1], 1) + assert.True(t, node.UsesWebsocket) + + assert.False(t, node.STUN.Enabled) + assert.False(t, node.STUN.CanSTUN) + assert.NoError(t, node.STUN.Error) + } + } + }) +} + +func tsDERPMap(ctx context.Context, t testing.TB) *tailcfg.DERPMap { + req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil) + require.NoError(t, err) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + var derpMap tailcfg.DERPMap + err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&derpMap) + require.NoError(t, err) + + return &derpMap +} diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go new file mode 100644 index 0000000000000..7c47bf5abd7d0 --- /dev/null +++ b/coderd/healthcheck/healthcheck.go @@ -0,0 +1,37 @@ +package healthcheck + +import ( + "context" + "time" + + "golang.org/x/xerrors" + "tailscale.com/tailcfg" +) + +type Report struct { + Time time.Time `json:"time"` + DERP DERPReport `json:"derp"` + + // TODO + // AccessURL AccessURLReport + // Websocket WebsocketReport +} + +type ReportOptions struct { + // TODO: support getting this over HTTP? + DERPMap *tailcfg.DERPMap +} + +func Run(ctx context.Context, opts *ReportOptions) (*Report, error) { + var report Report + + err := report.DERP.Run(ctx, &DERPReportOptions{ + DERPMap: opts.DERPMap, + }) + if err != nil { + return nil, xerrors.Errorf("run derp: %w", err) + } + + report.Time = time.Now() + return &report, nil +} diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go new file mode 100644 index 0000000000000..628a2723982fe --- /dev/null +++ b/coderd/healthcheck/websocket.go @@ -0,0 +1,3 @@ +package healthcheck + +type WebsocketReport struct{} diff --git a/go.mod b/go.mod index 8484188b3c270..d4a5a0c3b2672 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230327205451-058fa46a3723 +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230329230537-76a675d945b7 // Switch to our fork that imports fixes from http://github.com/tailscale/ssh. // See: https://github.com/coder/coder/issues/3371 diff --git a/go.sum b/go.sum index 45a0e9f010fc7..ff75cfc0c3122 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= bazil.org/fuse v0.0.0-20200407214033-5883e5a4b512/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM= bitbucket.org/creachadair/shell v0.0.6/go.mod h1:8Qqi/cYk7vPnsOePHroKXDJYmb5x7ENhtiFtfZq8K+M= -cdr.dev/slog v1.4.2-0.20230228204227-60d22dceaf04 h1:d5MQ+iI2zk7t0HrHwBP9p7k2XfRsXnRclSe8Kpp3xOo= -cdr.dev/slog v1.4.2-0.20230228204227-60d22dceaf04/go.mod h1:YPVZsUbRMaLaPgme0RzlPWlC7fI7YmDj/j/kZLuvICs= cdr.dev/slog v1.4.2 h1:fIfiqASYQFJBZiASwL825atyzeA96NsqSxx2aL61P8I= cdr.dev/slog v1.4.2/go.mod h1:0EkH+GkFNxizNR+GAXUEdUHanxUH5t9zqPILmPM/Vn8= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -380,8 +378,8 @@ github.com/coder/retry v1.3.1-0.20230210155434-e90a2e1e091d h1:09JG37IgTB6n3ouX9 github.com/coder/retry v1.3.1-0.20230210155434-e90a2e1e091d/go.mod h1:r+1J5i/989wt6CUeNSuvFKKA9hHuKKPMxdzDbTuvwwk= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338 h1:tN5GKFT68YLVzJoA8AHuiMNJ0qlhoD3pGN3JY9gxSko= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= -github.com/coder/tailscale v1.1.1-0.20230327205451-058fa46a3723 h1:TIEdewTbti0kTXo6kgKpF1MD3w+RYH+BZfkD1BqVB8c= -github.com/coder/tailscale v1.1.1-0.20230327205451-058fa46a3723/go.mod h1:jpg+77g19FpXL43U1VoIqoSg1K/Vh5CVxycGldQ8KhA= +github.com/coder/tailscale v1.1.1-0.20230329230537-76a675d945b7 h1:07rCDmnCKGG666bR0WI1grlI/RnI54xj0C+2d2gWeE4= +github.com/coder/tailscale v1.1.1-0.20230329230537-76a675d945b7/go.mod h1:jpg+77g19FpXL43U1VoIqoSg1K/Vh5CVxycGldQ8KhA= github.com/coder/terraform-provider-coder v0.6.21 h1:TIH6+/VQFreT8q/CkRvpHtbIeM5cOAhuDS5Sh1Nm21Q= github.com/coder/terraform-provider-coder v0.6.21/go.mod h1:UIfU3bYNeSzJJvHyJ30tEKjD6Z9utloI+HUM/7n94CY= github.com/coder/wgtunnel v0.1.5 h1:WP3sCj/3iJ34eKvpMQEp1oJHvm24RYh0NHbj1kfUKfs= From 98f37bd19cb0458dab48798183096e1594c8ae80 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 31 Mar 2023 16:21:56 -0500 Subject: [PATCH 2/6] fixup! feat(coderd): add DERP healthcheck --- coderd/apidoc/docs.go | 208 ++++++++++++++++ coderd/apidoc/swagger.json | 204 +++++++++++++++ coderd/debug.go | 3 +- coderd/debug_test.go | 8 +- docs/api/debug.md | 172 +++++++++++++ docs/api/schemas.md | 499 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1087 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e18f37d5348d4..ab739b6e96321 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -359,6 +359,31 @@ const docTemplate = `{ } } }, + "/debug/health": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Debug" + ], + "summary": "Debug Info Deployment Health", + "operationId": "debug-info-deployment-health", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/healthcheck.Report" + } + } + } + } + }, "/deployment/config": { "get": { "security": [ @@ -9487,6 +9512,189 @@ const docTemplate = `{ "ParameterSourceSchemeData" ] }, + "healthcheck.DERPNodeReport": { + "type": "object", + "properties": { + "can_exchange_messages": { + "type": "boolean" + }, + "client_errs": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "client_logs": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "node": { + "$ref": "#/definitions/tailcfg.DERPNode" + }, + "round_trip_ping": { + "type": "integer" + }, + "stun": { + "$ref": "#/definitions/healthcheck.DERPStunReport" + }, + "uses_websocket": { + "type": "boolean" + } + } + }, + "healthcheck.DERPRegionReport": { + "type": "object", + "properties": { + "node_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/healthcheck.DERPNodeReport" + } + }, + "region": { + "$ref": "#/definitions/tailcfg.DERPRegion" + } + } + }, + "healthcheck.DERPReport": { + "type": "object", + "properties": { + "netcheck": { + "$ref": "#/definitions/netcheck.Report" + }, + "netcheck_logs": { + "type": "array", + "items": { + "type": "string" + } + }, + "regions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/healthcheck.DERPRegionReport" + } + } + } + }, + "healthcheck.DERPStunReport": { + "type": "object", + "properties": { + "canSTUN": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "error": {} + } + }, + "healthcheck.Report": { + "type": "object", + "properties": { + "derp": { + "$ref": "#/definitions/healthcheck.DERPReport" + }, + "time": { + "type": "string" + } + } + }, + "netcheck.Report": { + "type": "object", + "properties": { + "captivePortal": { + "description": "CaptivePortal is set when we think there's a captive portal that is\nintercepting HTTP traffic.", + "type": "string" + }, + "globalV4": { + "description": "ip:port of global IPv4", + "type": "string" + }, + "globalV6": { + "description": "[ip]:port of global IPv6", + "type": "string" + }, + "hairPinning": { + "description": "HairPinning is whether the router supports communicating\nbetween two local devices through the NATted public IP address\n(on IPv4).", + "type": "string" + }, + "icmpv4": { + "description": "an ICMPv4 round trip completed", + "type": "boolean" + }, + "ipv4": { + "description": "an IPv4 STUN round trip completed", + "type": "boolean" + }, + "ipv4CanSend": { + "description": "an IPv4 packet was able to be sent", + "type": "boolean" + }, + "ipv6": { + "description": "an IPv6 STUN round trip completed", + "type": "boolean" + }, + "ipv6CanSend": { + "description": "an IPv6 packet was able to be sent", + "type": "boolean" + }, + "mappingVariesByDestIP": { + "description": "MappingVariesByDestIP is whether STUN results depend which\nSTUN server you're talking to (on IPv4).", + "type": "string" + }, + "oshasIPv6": { + "description": "could bind a socket to ::1", + "type": "boolean" + }, + "pcp": { + "description": "PCP is whether PCP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + }, + "pmp": { + "description": "PMP is whether NAT-PMP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + }, + "preferredDERP": { + "description": "or 0 for unknown", + "type": "integer" + }, + "regionLatency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "regionV4Latency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "regionV6Latency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "udp": { + "description": "a UDP STUN round trip completed", + "type": "boolean" + }, + "upnP": { + "description": "UPnP is whether UPnP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + } + } + }, "parameter.ComputedValue": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index da7128998340f..3f03155fa4051 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -305,6 +305,27 @@ } } }, + "/debug/health": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Debug"], + "summary": "Debug Info Deployment Health", + "operationId": "debug-info-deployment-health", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/healthcheck.Report" + } + } + } + } + }, "/deployment/config": { "get": { "security": [ @@ -8564,6 +8585,189 @@ "ParameterSourceSchemeData" ] }, + "healthcheck.DERPNodeReport": { + "type": "object", + "properties": { + "can_exchange_messages": { + "type": "boolean" + }, + "client_errs": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "client_logs": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "node": { + "$ref": "#/definitions/tailcfg.DERPNode" + }, + "round_trip_ping": { + "type": "integer" + }, + "stun": { + "$ref": "#/definitions/healthcheck.DERPStunReport" + }, + "uses_websocket": { + "type": "boolean" + } + } + }, + "healthcheck.DERPRegionReport": { + "type": "object", + "properties": { + "node_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/healthcheck.DERPNodeReport" + } + }, + "region": { + "$ref": "#/definitions/tailcfg.DERPRegion" + } + } + }, + "healthcheck.DERPReport": { + "type": "object", + "properties": { + "netcheck": { + "$ref": "#/definitions/netcheck.Report" + }, + "netcheck_logs": { + "type": "array", + "items": { + "type": "string" + } + }, + "regions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/healthcheck.DERPRegionReport" + } + } + } + }, + "healthcheck.DERPStunReport": { + "type": "object", + "properties": { + "canSTUN": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "error": {} + } + }, + "healthcheck.Report": { + "type": "object", + "properties": { + "derp": { + "$ref": "#/definitions/healthcheck.DERPReport" + }, + "time": { + "type": "string" + } + } + }, + "netcheck.Report": { + "type": "object", + "properties": { + "captivePortal": { + "description": "CaptivePortal is set when we think there's a captive portal that is\nintercepting HTTP traffic.", + "type": "string" + }, + "globalV4": { + "description": "ip:port of global IPv4", + "type": "string" + }, + "globalV6": { + "description": "[ip]:port of global IPv6", + "type": "string" + }, + "hairPinning": { + "description": "HairPinning is whether the router supports communicating\nbetween two local devices through the NATted public IP address\n(on IPv4).", + "type": "string" + }, + "icmpv4": { + "description": "an ICMPv4 round trip completed", + "type": "boolean" + }, + "ipv4": { + "description": "an IPv4 STUN round trip completed", + "type": "boolean" + }, + "ipv4CanSend": { + "description": "an IPv4 packet was able to be sent", + "type": "boolean" + }, + "ipv6": { + "description": "an IPv6 STUN round trip completed", + "type": "boolean" + }, + "ipv6CanSend": { + "description": "an IPv6 packet was able to be sent", + "type": "boolean" + }, + "mappingVariesByDestIP": { + "description": "MappingVariesByDestIP is whether STUN results depend which\nSTUN server you're talking to (on IPv4).", + "type": "string" + }, + "oshasIPv6": { + "description": "could bind a socket to ::1", + "type": "boolean" + }, + "pcp": { + "description": "PCP is whether PCP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + }, + "pmp": { + "description": "PMP is whether NAT-PMP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + }, + "preferredDERP": { + "description": "or 0 for unknown", + "type": "integer" + }, + "regionLatency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "regionV4Latency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "regionV6Latency": { + "description": "keyed by DERP Region ID", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "udp": { + "description": "a UDP STUN round trip completed", + "type": "boolean" + }, + "upnP": { + "description": "UPnP is whether UPnP appears present on the LAN.\nEmpty means not checked.", + "type": "string" + } + } + }, "parameter.ComputedValue": { "type": "object", "properties": { diff --git a/coderd/debug.go b/coderd/debug.go index 20c6bb1243335..3bad452bdd296 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -24,10 +24,9 @@ func (api *API) debugCoordinator(rw http.ResponseWriter, r *http.Request) { // @Summary Debug Info Deployment Health // @ID debug-info-deployment-health // @Security CoderSessionToken -// @Produce text/html // @Produce json // @Tags Debug -// @Success 200 +// @Success 200 {object} healthcheck.Report // @Router /debug/health [get] func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), api.HealthcheckTimeout) diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 7b6c8ef35d1b6..81022a10c25b1 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -2,9 +2,8 @@ package coderd_test import ( "context" - "fmt" + "io" "net/http" - "net/http/httputil" "testing" "time" @@ -34,8 +33,8 @@ func TestDebug(t *testing.T) { res, err := client.Request(ctx, "GET", "/debug/health", nil) require.NoError(t, err) defer res.Body.Close() + _, _ = io.ReadAll(res.Body) require.Equal(t, http.StatusOK, res.StatusCode) - }) t.Run("Health/Timeout", func(t *testing.T) { @@ -64,8 +63,7 @@ func TestDebug(t *testing.T) { res, err := client.Request(ctx, "GET", "/api/v2/debug/health", nil) require.NoError(t, err) defer res.Body.Close() - dump, _ := httputil.DumpResponse(res, true) - fmt.Println(string(dump)) + _, _ = io.ReadAll(res.Body) require.Equal(t, http.StatusNotFound, res.StatusCode) }) } diff --git a/docs/api/debug.md b/docs/api/debug.md index a32c9f6416a19..e5655e9a1c26b 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -19,3 +19,175 @@ curl -X GET http://coder-server:8080/api/v2/debug/coordinator \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Debug Info Deployment Health + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/debug/health \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /debug/health` + +### Example responses + +> 200 Response + +```json +{ + "derp": { + "netcheck": { + "captivePortal": "string", + "globalV4": "string", + "globalV6": "string", + "hairPinning": "string", + "icmpv4": true, + "ipv4": true, + "ipv4CanSend": true, + "ipv6": true, + "ipv6CanSend": true, + "mappingVariesByDestIP": "string", + "oshasIPv6": true, + "pcp": "string", + "pmp": "string", + "preferredDERP": 0, + "regionLatency": { + "property1": 0, + "property2": 0 + }, + "regionV4Latency": { + "property1": 0, + "property2": 0 + }, + "regionV6Latency": { + "property1": 0, + "property2": 0 + }, + "udp": true, + "upnP": "string" + }, + "netcheck_logs": ["string"], + "regions": { + "property1": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + }, + "property2": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + } + } + }, + "time": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [healthcheck.Report](schemas.md#healthcheckreport) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 0cde0757057eb..5b0c9857f5369 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5456,6 +5456,505 @@ Parameter represents a set value for the scope. | `none` | | `data` | +## healthcheck.DERPNodeReport + +```json +{ + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------------- | -------------------------------------------------------- | -------- | ------------ | ----------- | +| `can_exchange_messages` | boolean | false | | | +| `client_errs` | array of array | false | | | +| `client_logs` | array of array | false | | | +| `node` | [tailcfg.DERPNode](#tailcfgderpnode) | false | | | +| `round_trip_ping` | integer | false | | | +| `stun` | [healthcheck.DERPStunReport](#healthcheckderpstunreport) | false | | | +| `uses_websocket` | boolean | false | | | + +## healthcheck.DERPRegionReport + +```json +{ + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `node_reports` | array of [healthcheck.DERPNodeReport](#healthcheckderpnodereport) | false | | | +| `region` | [tailcfg.DERPRegion](#tailcfgderpregion) | false | | | + +## healthcheck.DERPReport + +```json +{ + "netcheck": { + "captivePortal": "string", + "globalV4": "string", + "globalV6": "string", + "hairPinning": "string", + "icmpv4": true, + "ipv4": true, + "ipv4CanSend": true, + "ipv6": true, + "ipv6CanSend": true, + "mappingVariesByDestIP": "string", + "oshasIPv6": true, + "pcp": "string", + "pmp": "string", + "preferredDERP": 0, + "regionLatency": { + "property1": 0, + "property2": 0 + }, + "regionV4Latency": { + "property1": 0, + "property2": 0 + }, + "regionV6Latency": { + "property1": 0, + "property2": 0 + }, + "udp": true, + "upnP": "string" + }, + "netcheck_logs": ["string"], + "regions": { + "property1": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + }, + "property2": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + } + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- | +| `netcheck` | [netcheck.Report](#netcheckreport) | false | | | +| `netcheck_logs` | array of string | false | | | +| `regions` | object | false | | | +| » `[any property]` | [healthcheck.DERPRegionReport](#healthcheckderpregionreport) | false | | | + +## healthcheck.DERPStunReport + +```json +{ + "canSTUN": true, + "enabled": true, + "error": null +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ------- | -------- | ------------ | ----------- | +| `canSTUN` | boolean | false | | | +| `enabled` | boolean | false | | | +| `error` | any | false | | | + +## healthcheck.Report + +```json +{ + "derp": { + "netcheck": { + "captivePortal": "string", + "globalV4": "string", + "globalV6": "string", + "hairPinning": "string", + "icmpv4": true, + "ipv4": true, + "ipv4CanSend": true, + "ipv6": true, + "ipv6CanSend": true, + "mappingVariesByDestIP": "string", + "oshasIPv6": true, + "pcp": "string", + "pmp": "string", + "preferredDERP": 0, + "regionLatency": { + "property1": 0, + "property2": 0 + }, + "regionV4Latency": { + "property1": 0, + "property2": 0 + }, + "regionV6Latency": { + "property1": 0, + "property2": 0 + }, + "udp": true, + "upnP": "string" + }, + "netcheck_logs": ["string"], + "regions": { + "property1": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + }, + "property2": { + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "node": { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + }, + "round_trip_ping": 0, + "stun": { + "canSTUN": true, + "enabled": true, + "error": null + }, + "uses_websocket": true + } + ], + "region": { + "avoid": true, + "embeddedRelay": true, + "nodes": [ + { + "certName": "string", + "derpport": 0, + "forceHTTP": true, + "hostName": "string", + "insecureForTests": true, + "ipv4": "string", + "ipv6": "string", + "name": "string", + "regionID": 0, + "stunonly": true, + "stunport": 0, + "stuntestIP": "string" + } + ], + "regionCode": "string", + "regionID": 0, + "regionName": "string" + } + } + } + }, + "time": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------ | ------------------------------------------------ | -------- | ------------ | ----------- | +| `derp` | [healthcheck.DERPReport](#healthcheckderpreport) | false | | | +| `time` | string | false | | | + +## netcheck.Report + +```json +{ + "captivePortal": "string", + "globalV4": "string", + "globalV6": "string", + "hairPinning": "string", + "icmpv4": true, + "ipv4": true, + "ipv4CanSend": true, + "ipv6": true, + "ipv6CanSend": true, + "mappingVariesByDestIP": "string", + "oshasIPv6": true, + "pcp": "string", + "pmp": "string", + "preferredDERP": 0, + "regionLatency": { + "property1": 0, + "property2": 0 + }, + "regionV4Latency": { + "property1": 0, + "property2": 0 + }, + "regionV6Latency": { + "property1": 0, + "property2": 0 + }, + "udp": true, + "upnP": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------------- | ------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `captivePortal` | string | false | | Captiveportal is set when we think there's a captive portal that is intercepting HTTP traffic. | +| `globalV4` | string | false | | ip:port of global IPv4 | +| `globalV6` | string | false | | [ip]:port of global IPv6 | +| `hairPinning` | string | false | | Hairpinning is whether the router supports communicating between two local devices through the NATted public IP address (on IPv4). | +| `icmpv4` | boolean | false | | an ICMPv4 round trip completed | +| `ipv4` | boolean | false | | an IPv4 STUN round trip completed | +| `ipv4CanSend` | boolean | false | | an IPv4 packet was able to be sent | +| `ipv6` | boolean | false | | an IPv6 STUN round trip completed | +| `ipv6CanSend` | boolean | false | | an IPv6 packet was able to be sent | +| `mappingVariesByDestIP` | string | false | | Mappingvariesbydestip is whether STUN results depend which STUN server you're talking to (on IPv4). | +| `oshasIPv6` | boolean | false | | could bind a socket to ::1 | +| `pcp` | string | false | | Pcp is whether PCP appears present on the LAN. Empty means not checked. | +| `pmp` | string | false | | Pmp is whether NAT-PMP appears present on the LAN. Empty means not checked. | +| `preferredDERP` | integer | false | | or 0 for unknown | +| `regionLatency` | object | false | | keyed by DERP Region ID | +| » `[any property]` | integer | false | | | +| `regionV4Latency` | object | false | | keyed by DERP Region ID | +| » `[any property]` | integer | false | | | +| `regionV6Latency` | object | false | | keyed by DERP Region ID | +| » `[any property]` | integer | false | | | +| `udp` | boolean | false | | a UDP STUN round trip completed | +| `upnP` | string | false | | Upnp is whether UPnP appears present on the LAN. Empty means not checked. | + ## parameter.ComputedValue ```json From 94e00ff300e72913262d3331d9fa630850a1eff0 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 31 Mar 2023 16:59:45 -0500 Subject: [PATCH 3/6] fixup! feat(coderd): add DERP healthcheck --- coderd/healthcheck/derp_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/coderd/healthcheck/derp_test.go b/coderd/healthcheck/derp_test.go index c86969e4b75a3..7c64f4a19d238 100644 --- a/coderd/healthcheck/derp_test.go +++ b/coderd/healthcheck/derp_test.go @@ -121,10 +121,8 @@ func TestDERP(t *testing.T) { handler, closeHandler := tailnet.WithWebsocketSupport(derpSrv, derphttp.Handler(derpSrv)) defer closeHandler() - first := true srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if first { - first = false + if r.Header.Get("Upgrade") == "DERP" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("bad request")) return From 7ef02175e90c7049c2fee23ba5e4392cba3f6a49 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 31 Mar 2023 17:19:01 -0500 Subject: [PATCH 4/6] remove access url report --- coderd/healthcheck/accessurl.go | 48 --------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 coderd/healthcheck/accessurl.go diff --git a/coderd/healthcheck/accessurl.go b/coderd/healthcheck/accessurl.go deleted file mode 100644 index a81d1cdc6481c..0000000000000 --- a/coderd/healthcheck/accessurl.go +++ /dev/null @@ -1,48 +0,0 @@ -package healthcheck - -import ( - "context" - "io" - "net/http" - "net/url" - - "golang.org/x/xerrors" -) - -type AccessURLReport struct { - Reachable bool - StatusCode int - HealthzResponse string - Err error -} - -func (r *AccessURLReport) Run(ctx context.Context, accessURL *url.URL) { - accessURL, err := accessURL.Parse("/healthz") - if err != nil { - r.Err = xerrors.Errorf("parse healthz endpoint: %w", err) - return - } - - req, err := http.NewRequestWithContext(ctx, "GET", accessURL.String(), nil) - if err != nil { - r.Err = xerrors.Errorf("create healthz request: %w", err) - return - } - - res, err := http.DefaultClient.Do(req) - if err != nil { - r.Err = xerrors.Errorf("get healthz endpoint: %w", err) - return - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - r.Err = xerrors.Errorf("read healthz response: %w", err) - return - } - - r.Reachable = true - r.StatusCode = res.StatusCode - r.HealthzResponse = string(body) -} From 9f62a8cbe598ae23270d01ccd249a25a1f15fee1 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Apr 2023 01:07:46 -0500 Subject: [PATCH 5/6] add healthy bool --- coderd/healthcheck/derp.go | 29 ++++++++++++++++++++++------- coderd/healthcheck/derp_test.go | 9 +++++++++ coderd/healthcheck/healthcheck.go | 7 ++++++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/coderd/healthcheck/derp.go b/coderd/healthcheck/derp.go index ae75c5e7abf34..6c1426f600ba1 100644 --- a/coderd/healthcheck/derp.go +++ b/coderd/healthcheck/derp.go @@ -26,7 +26,8 @@ import ( ) type DERPReport struct { - mu sync.Mutex + mu sync.Mutex + Healthy bool `json:"healthy"` Regions map[int]*DERPRegionReport `json:"regions"` @@ -35,17 +36,19 @@ type DERPReport struct { } type DERPRegionReport struct { - mu sync.Mutex - Region *tailcfg.DERPRegion `json:"region"` + mu sync.Mutex + Healthy bool `json:"healthy"` - NodeReports []*DERPNodeReport `json:"node_reports"` + Region *tailcfg.DERPRegion `json:"region"` + NodeReports []*DERPNodeReport `json:"node_reports"` } type DERPNodeReport struct { - Node *tailcfg.DERPNode `json:"node"` - mu sync.Mutex clientCounter int + Healthy bool `json:"healthy"` + Node *tailcfg.DERPNode `json:"node"` + CanExchangeMessages bool `json:"can_exchange_messages"` RoundTripPing time.Duration `json:"round_trip_ping"` UsesWebsocket bool `json:"uses_websocket"` @@ -66,6 +69,7 @@ type DERPReportOptions struct { } func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) error { + r.Healthy = true r.Regions = map[int]*DERPRegionReport{} eg, ctx := errgroup.WithContext(ctx) @@ -84,6 +88,9 @@ func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) error { r.mu.Lock() r.Regions[region.RegionID] = ®ionReport + if !regionReport.Healthy { + r.Healthy = false + } r.mu.Unlock() return nil }) @@ -108,6 +115,7 @@ func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) error { } func (r *DERPRegionReport) Run(ctx context.Context) error { + r.Healthy = true r.NodeReports = []*DERPNodeReport{} eg, ctx := errgroup.WithContext(ctx) @@ -115,7 +123,8 @@ func (r *DERPRegionReport) Run(ctx context.Context) error { node := node eg.Go(func() error { nodeReport := DERPNodeReport{ - Node: node, + Node: node, + Healthy: true, } err := nodeReport.Run(ctx) @@ -125,6 +134,9 @@ func (r *DERPRegionReport) Run(ctx context.Context) error { r.mu.Lock() r.NodeReports = append(r.NodeReports, &nodeReport) + if !nodeReport.Healthy { + r.Healthy = false + } r.mu.Unlock() return nil }) @@ -159,6 +171,9 @@ func (r *DERPNodeReport) Run(ctx context.Context) error { r.doExchangeMessage(ctx) r.doSTUNTest(ctx) + if !r.CanExchangeMessages || r.UsesWebsocket || r.STUN.Error != nil { + r.Healthy = false + } return nil } diff --git a/coderd/healthcheck/derp_test.go b/coderd/healthcheck/derp_test.go index 7c64f4a19d238..43c848e304f2c 100644 --- a/coderd/healthcheck/derp_test.go +++ b/coderd/healthcheck/derp_test.go @@ -58,8 +58,11 @@ func TestDERP(t *testing.T) { err := report.Run(ctx, opts) require.NoError(t, err) + assert.True(t, report.Healthy) for _, region := range report.Regions { + assert.True(t, region.Healthy) for _, node := range region.NodeReports { + assert.True(t, node.Healthy) assert.True(t, node.CanExchangeMessages) assert.Positive(t, node.RoundTripPing) assert.Len(t, node.ClientLogs, 2) @@ -96,8 +99,11 @@ func TestDERP(t *testing.T) { err := report.Run(ctx, opts) require.NoError(t, err) + assert.True(t, report.Healthy) for _, region := range report.Regions { + assert.True(t, region.Healthy) for _, node := range region.NodeReports { + assert.True(t, node.Healthy) assert.True(t, node.CanExchangeMessages) assert.Positive(t, node.RoundTripPing) assert.Len(t, node.ClientLogs, 2) @@ -156,8 +162,11 @@ func TestDERP(t *testing.T) { report.Run(ctx, opts) + assert.False(t, report.Healthy) for _, region := range report.Regions { + assert.False(t, region.Healthy) for _, node := range region.NodeReports { + assert.False(t, node.Healthy) assert.True(t, node.CanExchangeMessages) assert.Positive(t, node.RoundTripPing) assert.Len(t, node.ClientLogs, 2) diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index 7c47bf5abd7d0..e48ac4fa72712 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -9,7 +9,11 @@ import ( ) type Report struct { - Time time.Time `json:"time"` + // Time is the time the report was generated at. + Time time.Time `json:"time"` + // Healthy is true if the report returns no errors. + Healthy bool `json:"pass"` + DERP DERPReport `json:"derp"` // TODO @@ -33,5 +37,6 @@ func Run(ctx context.Context, opts *ReportOptions) (*Report, error) { } report.Time = time.Now() + report.Healthy = report.DERP.Healthy return &report, nil } From f045e6e7f4129cd6de67bc74eb5bae83201aa9cd Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Apr 2023 01:16:20 -0500 Subject: [PATCH 6/6] fixup! add healthy bool --- coderd/apidoc/docs.go | 14 ++++++++++++++ coderd/apidoc/swagger.json | 14 ++++++++++++++ docs/api/debug.md | 6 ++++++ docs/api/schemas.md | 26 ++++++++++++++++++++++---- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ab739b6e96321..4e582950f8e66 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9534,6 +9534,9 @@ const docTemplate = `{ } } }, + "healthy": { + "type": "boolean" + }, "node": { "$ref": "#/definitions/tailcfg.DERPNode" }, @@ -9551,6 +9554,9 @@ const docTemplate = `{ "healthcheck.DERPRegionReport": { "type": "object", "properties": { + "healthy": { + "type": "boolean" + }, "node_reports": { "type": "array", "items": { @@ -9565,6 +9571,9 @@ const docTemplate = `{ "healthcheck.DERPReport": { "type": "object", "properties": { + "healthy": { + "type": "boolean" + }, "netcheck": { "$ref": "#/definitions/netcheck.Report" }, @@ -9600,7 +9609,12 @@ const docTemplate = `{ "derp": { "$ref": "#/definitions/healthcheck.DERPReport" }, + "pass": { + "description": "Healthy is true if the report returns no errors.", + "type": "boolean" + }, "time": { + "description": "Time is the time the report was generated at.", "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3f03155fa4051..513cd81a1bf3e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8607,6 +8607,9 @@ } } }, + "healthy": { + "type": "boolean" + }, "node": { "$ref": "#/definitions/tailcfg.DERPNode" }, @@ -8624,6 +8627,9 @@ "healthcheck.DERPRegionReport": { "type": "object", "properties": { + "healthy": { + "type": "boolean" + }, "node_reports": { "type": "array", "items": { @@ -8638,6 +8644,9 @@ "healthcheck.DERPReport": { "type": "object", "properties": { + "healthy": { + "type": "boolean" + }, "netcheck": { "$ref": "#/definitions/netcheck.Report" }, @@ -8673,7 +8682,12 @@ "derp": { "$ref": "#/definitions/healthcheck.DERPReport" }, + "pass": { + "description": "Healthy is true if the report returns no errors.", + "type": "boolean" + }, "time": { + "description": "Time is the time the report was generated at.", "type": "string" } } diff --git a/docs/api/debug.md b/docs/api/debug.md index e5655e9a1c26b..88946156d88d3 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -40,6 +40,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ ```json { "derp": { + "healthy": true, "netcheck": { "captivePortal": "string", "globalV4": "string", @@ -73,11 +74,13 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "netcheck_logs": ["string"], "regions": { "property1": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -126,11 +129,13 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ } }, "property2": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -180,6 +185,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ } } }, + "pass": true, "time": "string" } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5b0c9857f5369..4dc6f9caee4e0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5463,6 +5463,7 @@ Parameter represents a set value for the scope. "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5494,6 +5495,7 @@ Parameter represents a set value for the scope. | `can_exchange_messages` | boolean | false | | | | `client_errs` | array of array | false | | | | `client_logs` | array of array | false | | | +| `healthy` | boolean | false | | | | `node` | [tailcfg.DERPNode](#tailcfgderpnode) | false | | | | `round_trip_ping` | integer | false | | | | `stun` | [healthcheck.DERPStunReport](#healthcheckderpstunreport) | false | | | @@ -5503,11 +5505,13 @@ Parameter represents a set value for the scope. ```json { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5561,6 +5565,7 @@ Parameter represents a set value for the scope. | Name | Type | Required | Restrictions | Description | | -------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `healthy` | boolean | false | | | | `node_reports` | array of [healthcheck.DERPNodeReport](#healthcheckderpnodereport) | false | | | | `region` | [tailcfg.DERPRegion](#tailcfgderpregion) | false | | | @@ -5568,6 +5573,7 @@ Parameter represents a set value for the scope. ```json { + "healthy": true, "netcheck": { "captivePortal": "string", "globalV4": "string", @@ -5601,11 +5607,13 @@ Parameter represents a set value for the scope. "netcheck_logs": ["string"], "regions": { "property1": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5654,11 +5662,13 @@ Parameter represents a set value for the scope. } }, "property2": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5714,6 +5724,7 @@ Parameter represents a set value for the scope. | Name | Type | Required | Restrictions | Description | | ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- | +| `healthy` | boolean | false | | | | `netcheck` | [netcheck.Report](#netcheckreport) | false | | | | `netcheck_logs` | array of string | false | | | | `regions` | object | false | | | @@ -5742,6 +5753,7 @@ Parameter represents a set value for the scope. ```json { "derp": { + "healthy": true, "netcheck": { "captivePortal": "string", "globalV4": "string", @@ -5775,11 +5787,13 @@ Parameter represents a set value for the scope. "netcheck_logs": ["string"], "regions": { "property1": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5828,11 +5842,13 @@ Parameter represents a set value for the scope. } }, "property2": { + "healthy": true, "node_reports": [ { "can_exchange_messages": true, "client_errs": [[null]], "client_logs": [["string"]], + "healthy": true, "node": { "certName": "string", "derpport": 0, @@ -5882,16 +5898,18 @@ Parameter represents a set value for the scope. } } }, + "pass": true, "time": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------------------------------------------------ | -------- | ------------ | ----------- | -| `derp` | [healthcheck.DERPReport](#healthcheckderpreport) | false | | | -| `time` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------ | ------------------------------------------------ | -------- | ------------ | ------------------------------------------------ | +| `derp` | [healthcheck.DERPReport](#healthcheckderpreport) | false | | | +| `pass` | boolean | false | | Healthy is true if the report returns no errors. | +| `time` | string | false | | Time is the time the report was generated at. | ## netcheck.Report