package support import ( "bytes" "context" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "tailscale.com/ipn/ipnstate" "tailscale.com/net/netcheck" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/coderd/healthcheck/derphealth" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/healthsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/tailnet" ) // Bundle is a set of information discovered about a deployment. // Even though we do attempt to sanitize data, it may still contain // sensitive information and should thus be treated as secret. type Bundle struct { Deployment Deployment `json:"deployment"` Network Network `json:"network"` Workspace Workspace `json:"workspace"` Agent Agent `json:"agent"` Logs []string `json:"logs"` CLILogs []byte `json:"cli_logs"` } type Deployment struct { BuildInfo *codersdk.BuildInfoResponse `json:"build"` Config *codersdk.DeploymentConfig `json:"config"` Experiments codersdk.Experiments `json:"experiments"` HealthReport *healthsdk.HealthcheckReport `json:"health_report"` } type Network struct { ConnectionInfo workspacesdk.AgentConnectionInfo CoordinatorDebug string `json:"coordinator_debug"` Netcheck *derphealth.Report `json:"netcheck"` TailnetDebug string `json:"tailnet_debug"` Interfaces healthsdk.InterfacesReport `json:"interfaces"` } type Netcheck struct { Report *netcheck.Report `json:"report"` Error string `json:"error"` Logs []string `json:"logs"` } type Workspace struct { Workspace codersdk.Workspace `json:"workspace"` Parameters []codersdk.WorkspaceBuildParameter `json:"parameters"` Template codersdk.Template `json:"template"` TemplateVersion codersdk.TemplateVersion `json:"template_version"` TemplateFileBase64 string `json:"template_file_base64"` BuildLogs []codersdk.ProvisionerJobLog `json:"build_logs"` } type Agent struct { Agent *codersdk.WorkspaceAgent `json:"agent"` ConnectionInfo *workspacesdk.AgentConnectionInfo `json:"connection_info"` ListeningPorts *codersdk.WorkspaceAgentListeningPortsResponse `json:"listening_ports"` Logs []byte `json:"logs"` ClientMagicsockHTML []byte `json:"client_magicsock_html"` AgentMagicsockHTML []byte `json:"agent_magicsock_html"` Manifest *agentsdk.Manifest `json:"manifest"` PeerDiagnostics *tailnet.PeerDiagnostics `json:"peer_diagnostics"` PingResult *ipnstate.PingResult `json:"ping_result"` Prometheus []byte `json:"prometheus"` StartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"` } // Deps is a set of dependencies for discovering information type Deps struct { // Source from which to obtain information. Client *codersdk.Client // Log is where to log any informational or warning messages. Log slog.Logger // WorkspaceID is the optional workspace against which to run connection tests. WorkspaceID uuid.UUID // AgentID is the optional agent ID against which to run connection tests. // Defaults to the first agent of the workspace, if not specified. AgentID uuid.UUID } func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) Deployment { // Note: each goroutine assigns to a different struct field, hence no mutex. var ( d Deployment eg errgroup.Group ) eg.Go(func() error { bi, err := client.BuildInfo(ctx) if err != nil { return xerrors.Errorf("fetch build info: %w", err) } d.BuildInfo = &bi return nil }) eg.Go(func() error { dc, err := client.DeploymentConfig(ctx) if err != nil { return xerrors.Errorf("fetch deployment config: %w", err) } d.Config = dc return nil }) eg.Go(func() error { hr, err := healthsdk.New(client).DebugHealth(ctx) if err != nil { return xerrors.Errorf("fetch health report: %w", err) } d.HealthReport = &hr return nil }) eg.Go(func() error { exp, err := client.Experiments(ctx) if err != nil { return xerrors.Errorf("fetch experiments: %w", err) } d.Experiments = exp return nil }) if err := eg.Wait(); err != nil { log.Error(ctx, "fetch deployment information", slog.Error(err)) } return d } func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) Network { var ( n Network eg errgroup.Group ) eg.Go(func() error { coordResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/coordinator", nil) if err != nil { return xerrors.Errorf("fetch coordinator debug page: %w", err) } defer coordResp.Body.Close() bs, err := io.ReadAll(coordResp.Body) if err != nil { return xerrors.Errorf("read coordinator debug page: %w", err) } n.CoordinatorDebug = string(bs) return nil }) eg.Go(func() error { tailResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/tailnet", nil) if err != nil { return xerrors.Errorf("fetch tailnet debug page: %w", err) } defer tailResp.Body.Close() bs, err := io.ReadAll(tailResp.Body) if err != nil { return xerrors.Errorf("read tailnet debug page: %w", err) } n.TailnetDebug = string(bs) return nil }) eg.Go(func() error { // Need connection info to get DERP map for netcheck connInfo, err := workspacesdk.New(client).AgentConnectionInfoGeneric(ctx) if err != nil { log.Warn(ctx, "unable to fetch generic agent connection info") return nil } n.ConnectionInfo = connInfo var rpt derphealth.Report rpt.Run(ctx, &derphealth.ReportOptions{ DERPMap: connInfo.DERPMap, }) n.Netcheck = &rpt return nil }) eg.Go(func() error { rpt, err := healthsdk.RunInterfacesReport() if err != nil { return xerrors.Errorf("run interfaces report: %w", err) } n.Interfaces = rpt return nil }) if err := eg.Wait(); err != nil { log.Error(ctx, "fetch network information", slog.Error(err)) } return n } func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, workspaceID uuid.UUID) Workspace { var ( w Workspace eg errgroup.Group ) if workspaceID == uuid.Nil { log.Error(ctx, "no workspace id specified") return w } // dependency, cannot fetch concurrently ws, err := client.Workspace(ctx, workspaceID) if err != nil { log.Error(ctx, "fetch workspace", slog.Error(err), slog.F("workspace_id", workspaceID)) return w } for _, res := range ws.LatestBuild.Resources { for _, agt := range res.Agents { sanitizeEnv(agt.EnvironmentVariables) } } w.Workspace = ws eg.Go(func() error { buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0) if err != nil { return xerrors.Errorf("fetch provisioner job logs: %w", err) } defer closer.Close() var logs []codersdk.ProvisionerJobLog for log := range buildLogCh { logs = append(w.BuildLogs, log) } w.BuildLogs = logs return nil }) eg.Go(func() error { if w.Workspace.TemplateActiveVersionID == uuid.Nil { return xerrors.Errorf("workspace has nil template active version id") } tv, err := client.TemplateVersion(ctx, w.Workspace.TemplateActiveVersionID) if err != nil { return xerrors.Errorf("fetch template active version id") } w.TemplateVersion = tv if tv.Job.FileID == uuid.Nil { return xerrors.Errorf("template file id is nil") } raw, ctype, err := client.DownloadWithFormat(ctx, tv.Job.FileID, codersdk.FormatZip) if err != nil { return err } if ctype != codersdk.ContentTypeZip { return xerrors.Errorf("expected content-type %s, got %s", codersdk.ContentTypeZip, ctype) } b64encoded := base64.StdEncoding.EncodeToString(raw) w.TemplateFileBase64 = b64encoded return nil }) eg.Go(func() error { if w.Workspace.TemplateID == uuid.Nil { return xerrors.Errorf("workspace has nil version id") } tpl, err := client.Template(ctx, w.Workspace.TemplateID) if err != nil { return xerrors.Errorf("fetch template") } w.Template = tpl return nil }) eg.Go(func() error { if ws.LatestBuild.ID == uuid.Nil { return xerrors.Errorf("workspace has nil latest build id") } params, err := client.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID) if err != nil { return xerrors.Errorf("fetch workspace build parameters: %w", err) } w.Parameters = params return nil }) if err := eg.Wait(); err != nil { log.Error(ctx, "fetch workspace information", slog.Error(err)) } return w } func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID) Agent { var ( a Agent eg errgroup.Group ) if agentID == uuid.Nil { log.Error(ctx, "no agent id specified") return a } eg.Go(func() error { agt, err := client.WorkspaceAgent(ctx, agentID) if err != nil { return xerrors.Errorf("fetch workspace agent: %w", err) } sanitizeEnv(agt.EnvironmentVariables) a.Agent = &agt return nil }) eg.Go(func() error { agentLogCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agentID, 0, false) if err != nil { return xerrors.Errorf("fetch agent startup logs: %w", err) } defer closer.Close() var logs []codersdk.WorkspaceAgentLog for logChunk := range agentLogCh { logs = append(logs, logChunk...) } a.StartupLogs = logs return nil }) // to simplify control flow, fetching information directly from // the agent is handled in a separate function closer := connectedAgentInfo(ctx, client, log, agentID, &eg, &a) defer closer() if err := eg.Wait(); err != nil { log.Error(ctx, "fetch agent information", slog.Error(err)) } return a } func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, eg *errgroup.Group, a *Agent) (closer func()) { conn, err := workspacesdk.New(client). DialAgent(ctx, agentID, &workspacesdk.DialAgentOptions{ Logger: log.Named("dial-agent"), BlockEndpoints: false, }) closer = func() {} if err != nil { log.Error(ctx, "dial agent", slog.Error(err)) return closer } if !conn.AwaitReachable(ctx) { log.Error(ctx, "timed out waiting for agent") return closer } closer = func() { if err := conn.Close(); err != nil { log.Error(ctx, "failed to close agent connection", slog.Error(err)) } <-conn.Closed() } eg.Go(func() error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/", nil) if err != nil { return xerrors.Errorf("create request: %w", err) } rr := httptest.NewRecorder() conn.MagicsockServeHTTPDebug(rr, req) a.ClientMagicsockHTML = rr.Body.Bytes() return nil }) eg.Go(func() error { promRes, err := conn.PrometheusMetrics(ctx) if err != nil { return xerrors.Errorf("fetch agent prometheus metrics: %w", err) } a.Prometheus = promRes return nil }) eg.Go(func() error { _, _, pingRes, err := conn.Ping(ctx) if err != nil { return xerrors.Errorf("ping agent: %w", err) } a.PingResult = pingRes return nil }) eg.Go(func() error { pds := conn.GetPeerDiagnostics() a.PeerDiagnostics = &pds return nil }) eg.Go(func() error { msBytes, err := conn.DebugMagicsock(ctx) if err != nil { return xerrors.Errorf("get agent magicsock page: %w", err) } a.AgentMagicsockHTML = msBytes return nil }) eg.Go(func() error { manifestRes, err := conn.DebugManifest(ctx) if err != nil { return xerrors.Errorf("fetch manifest: %w", err) } if err := json.NewDecoder(bytes.NewReader(manifestRes)).Decode(&a.Manifest); err != nil { return xerrors.Errorf("decode agent manifest: %w", err) } sanitizeEnv(a.Manifest.EnvironmentVariables) return nil }) eg.Go(func() error { logBytes, err := conn.DebugLogs(ctx) if err != nil { return xerrors.Errorf("fetch coder agent logs: %w", err) } a.Logs = logBytes return nil }) eg.Go(func() error { lps, err := conn.ListeningPorts(ctx) if err != nil { return xerrors.Errorf("get listening ports: %w", err) } a.ListeningPorts = &lps return nil }) return closer } // Run generates a support bundle with the given dependencies. func Run(ctx context.Context, d *Deps) (*Bundle, error) { var b Bundle if d.Client == nil { return nil, xerrors.Errorf("developer error: missing client!") } authChecks := map[string]codersdk.AuthorizationCheck{ "Read DeploymentValues": { Object: codersdk.AuthorizationObject{ ResourceType: codersdk.ResourceDeploymentConfig, }, Action: codersdk.ActionRead, }, } // Ensure we capture logs from the client. var logw strings.Builder d.Log = d.Log.AppendSinks(sloghuman.Sink(&logw)) d.Client.SetLogger(d.Log) defer func() { b.Logs = strings.Split(logw.String(), "\n") }() authResp, err := d.Client.AuthCheck(ctx, codersdk.AuthorizationRequest{Checks: authChecks}) if err != nil { return &b, xerrors.Errorf("check authorization: %w", err) } for k, v := range authResp { if !v { return &b, xerrors.Errorf("failed authorization check: cannot %s", k) } } var eg errgroup.Group eg.Go(func() error { di := DeploymentInfo(ctx, d.Client, d.Log) b.Deployment = di return nil }) eg.Go(func() error { wi := WorkspaceInfo(ctx, d.Client, d.Log, d.WorkspaceID) b.Workspace = wi return nil }) eg.Go(func() error { ni := NetworkInfo(ctx, d.Client, d.Log) b.Network = ni return nil }) eg.Go(func() error { ai := AgentInfo(ctx, d.Client, d.Log, d.AgentID) b.Agent = ai return nil }) _ = eg.Wait() return &b, nil } // sanitizeEnv modifies kvs in place and replaces the values all non-empty keys // with the string ***REDACTED*** func sanitizeEnv(kvs map[string]string) { for k, v := range kvs { if v != "" { kvs[k] = "***REDACTED***" } } }