diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e18f37d5348d4..4e582950f8e66 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,203 @@ 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" + } + } + }, + "healthy": { + "type": "boolean" + }, + "node": { + "$ref": "#/definitions/tailcfg.DERPNode" + }, + "round_trip_ping": { + "type": "integer" + }, + "stun": { + "$ref": "#/definitions/healthcheck.DERPStunReport" + }, + "uses_websocket": { + "type": "boolean" + } + } + }, + "healthcheck.DERPRegionReport": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "node_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/healthcheck.DERPNodeReport" + } + }, + "region": { + "$ref": "#/definitions/tailcfg.DERPRegion" + } + } + }, + "healthcheck.DERPReport": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "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" + }, + "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" + } + } + }, + "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..513cd81a1bf3e 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,203 @@ "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" + } + } + }, + "healthy": { + "type": "boolean" + }, + "node": { + "$ref": "#/definitions/tailcfg.DERPNode" + }, + "round_trip_ping": { + "type": "integer" + }, + "stun": { + "$ref": "#/definitions/healthcheck.DERPStunReport" + }, + "uses_websocket": { + "type": "boolean" + } + } + }, + "healthcheck.DERPRegionReport": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "node_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/healthcheck.DERPNodeReport" + } + }, + "region": { + "$ref": "#/definitions/tailcfg.DERPRegion" + } + } + }, + "healthcheck.DERPReport": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "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" + }, + "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" + } + } + }, + "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/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..3bad452bdd296 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,36 @@ 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 json +// @Tags Debug +// @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) + 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..81022a10c25b1 --- /dev/null +++ b/coderd/debug_test.go @@ -0,0 +1,69 @@ +package coderd_test + +import ( + "context" + "io" + "net/http" + "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() + _, _ = io.ReadAll(res.Body) + 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() + _, _ = io.ReadAll(res.Body) + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/coderd/healthcheck/derp.go b/coderd/healthcheck/derp.go new file mode 100644 index 0000000000000..6c1426f600ba1 --- /dev/null +++ b/coderd/healthcheck/derp.go @@ -0,0 +1,398 @@ +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 + Healthy bool `json:"healthy"` + + Regions map[int]*DERPRegionReport `json:"regions"` + + Netcheck *netcheck.Report `json:"netcheck"` + NetcheckLogs []string `json:"netcheck_logs"` +} + +type DERPRegionReport struct { + mu sync.Mutex + Healthy bool `json:"healthy"` + + Region *tailcfg.DERPRegion `json:"region"` + NodeReports []*DERPNodeReport `json:"node_reports"` +} +type DERPNodeReport struct { + 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"` + 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.Healthy = true + 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 + if !regionReport.Healthy { + r.Healthy = false + } + 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.Healthy = true + r.NodeReports = []*DERPNodeReport{} + eg, ctx := errgroup.WithContext(ctx) + + for _, node := range r.Region.Nodes { + node := node + eg.Go(func() error { + nodeReport := DERPNodeReport{ + Node: node, + Healthy: true, + } + + err := nodeReport.Run(ctx) + if err != nil { + return xerrors.Errorf("run node report: %w", err) + } + + r.mu.Lock() + r.NodeReports = append(r.NodeReports, &nodeReport) + if !nodeReport.Healthy { + r.Healthy = false + } + 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) + + if !r.CanExchangeMessages || r.UsesWebsocket || r.STUN.Error != nil { + r.Healthy = false + } + 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..43c848e304f2c --- /dev/null +++ b/coderd/healthcheck/derp_test.go @@ -0,0 +1,202 @@ +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) + + 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) + 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) + + 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) + 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() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") == "DERP" { + 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) + + 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) + 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..e48ac4fa72712 --- /dev/null +++ b/coderd/healthcheck/healthcheck.go @@ -0,0 +1,42 @@ +package healthcheck + +import ( + "context" + "time" + + "golang.org/x/xerrors" + "tailscale.com/tailcfg" +) + +type Report struct { + // 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 + // 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() + report.Healthy = report.DERP.Healthy + 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/docs/api/debug.md b/docs/api/debug.md index a32c9f6416a19..88946156d88d3 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -19,3 +19,181 @@ 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": { + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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" + } + } + } + }, + "pass": true, + "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..4dc6f9caee4e0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5456,6 +5456,523 @@ Parameter represents a set value for the scope. | `none` | | `data` | +## healthcheck.DERPNodeReport + +```json +{ + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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 | | | +| `healthy` | boolean | false | | | +| `node` | [tailcfg.DERPNode](#tailcfgderpnode) | false | | | +| `round_trip_ping` | integer | false | | | +| `stun` | [healthcheck.DERPStunReport](#healthcheckderpstunreport) | false | | | +| `uses_websocket` | boolean | false | | | + +## healthcheck.DERPRegionReport + +```json +{ + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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 | +| -------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `healthy` | boolean | false | | | +| `node_reports` | array of [healthcheck.DERPNodeReport](#healthcheckderpnodereport) | false | | | +| `region` | [tailcfg.DERPRegion](#tailcfgderpregion) | false | | | + +## healthcheck.DERPReport + +```json +{ + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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 | +| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- | +| `healthy` | boolean | false | | | +| `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": { + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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": { + "healthy": true, + "node_reports": [ + { + "can_exchange_messages": true, + "client_errs": [[null]], + "client_logs": [["string"]], + "healthy": true, + "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" + } + } + } + }, + "pass": true, + "time": "string" +} +``` + +### Properties + +| 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 + +```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 diff --git a/go.mod b/go.mod index 34dde5711788b..69ab09cefd137 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 bfe180d2b7529..157fc79909118 100644 --- a/go.sum +++ b/go.sum @@ -380,8 +380,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=