diff --git a/cmd/client/diagnostics.go b/cmd/client/diagnostics.go new file mode 100644 index 0000000..84c91c5 --- /dev/null +++ b/cmd/client/diagnostics.go @@ -0,0 +1,330 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strings" + "time" +) + +func writeDiagnosticsZip(outPath, configPath, version string) (string, error) { + if strings.TrimSpace(outPath) == "" { + outPath = fmt.Sprintf("goose-diagnostics-%s.zip", time.Now().Format("20060102-150405")) + } + outPath = filepath.Clean(outPath) + + f, err := os.Create(outPath) + if err != nil { + return "", err + } + zw := zip.NewWriter(f) + cleanup := true + defer func() { + if cleanup { + _ = zw.Close() + _ = f.Close() + } + }() + + addText := func(name, body string) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = io.WriteString(w, body) + return err + } + + if err := addText("README.txt", diagnosticsReadme(configPath)); err != nil { + return "", err + } + if err := addText("runtime.txt", diagnosticsRuntime(configPath, version)); err != nil { + return "", err + } + if err := addDiagnosticsSummary(zw, configPath, version); err != nil { + return "", err + } + if err := addProfile(zw, "goroutine.txt", "goroutine", 2); err != nil { + return "", err + } + if err := addProfile(zw, "heap.txt", "heap", 1); err != nil { + return "", err + } + if err := addRedactedConfig(zw, configPath); err != nil { + return "", err + } + if err := zw.Close(); err != nil { + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + cleanup = false + return outPath, nil +} + +func diagnosticsReadme(configPath string) string { + return fmt.Sprintf(`GooseRelayVPN diagnostics bundle + +This zip is designed to be shared for debugging. + +Included: +- diagnostics.json: structured runtime and non-secret config summary. +- runtime.txt: OS/arch/Go runtime/process summary. +- goroutine.txt: current goroutine profile. +- heap.txt: heap profile summary. +- client_config.redacted.json: parsed client config with secrets and endpoint identifiers redacted. + +Not included: +- raw tunnel keys, SOCKS passwords, Apps Script deployment IDs, or full relay URLs. +- packet captures or user browsing data. + +Config source: %s +Generated at: %s +`, configPath, time.Now().Format(time.RFC3339)) +} + +func diagnosticsRuntime(configPath, version string) string { + cwd, _ := os.Getwd() + exe, _ := os.Executable() + return fmt.Sprintf(`version: %s +generated_at: %s +go_version: %s +goos: %s +goarch: %s +num_cpu: %d +gomaxprocs: %d +num_goroutine: %d +cwd: %s +executable: %s +config_path: %s +`, version, time.Now().Format(time.RFC3339), runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), runtime.GOMAXPROCS(0), runtime.NumGoroutine(), cwd, exe, configPath) +} + +func addDiagnosticsSummary(zw *zip.Writer, configPath, version string) error { + w, err := zw.Create("diagnostics.json") + if err != nil { + return err + } + raw, err := os.ReadFile(configPath) + if err != nil { + summary := map[string]any{ + "version": version, + "generated_at": time.Now().Format(time.RFC3339), + "config_error": err.Error(), + } + body, marshalErr := json.MarshalIndent(summary, "", " ") + if marshalErr != nil { + return marshalErr + } + _, err = w.Write(append(body, '\n')) + return err + } + redacted, err := redactConfigJSON(raw) + if err != nil { + summary := map[string]any{ + "version": version, + "generated_at": time.Now().Format(time.RFC3339), + "config_error": err.Error(), + } + body, marshalErr := json.MarshalIndent(summary, "", " ") + if marshalErr != nil { + return marshalErr + } + _, err = w.Write(append(body, '\n')) + return err + } + + var cfg map[string]any + if err := json.Unmarshal(redacted, &cfg); err != nil { + return err + } + summary := map[string]any{ + "version": version, + "generated_at": time.Now().Format(time.RFC3339), + "go_version": runtime.Version(), + "goos": runtime.GOOS, + "goarch": runtime.GOARCH, + "config": diagnosticsConfigSummary(cfg), + } + body, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return err + } + _, err = w.Write(append(body, '\n')) + return err +} + +func diagnosticsConfigSummary(cfg map[string]any) map[string]any { + out := make(map[string]any) + for _, key := range []string{ + "debug_timing", + "socks_host", + "socks_port", + "google_host", + "coalesce_step_ms", + "idle_slots_per_bucket", + } { + if v, ok := cfg[key]; ok { + out[key] = v + } + } + if v, ok := cfg["sni"].([]any); ok { + out["sni_count"] = len(v) + } + if v, ok := cfg["script_keys"].([]any); ok { + out["script_key_count"] = len(v) + } + if v, ok := cfg["relay_urls"].([]any); ok { + out["relay_url_count"] = len(v) + } + return out +} + +func addProfile(zw *zip.Writer, name, profile string, debug int) error { + w, err := zw.Create(name) + if err != nil { + return err + } + p := pprof.Lookup(profile) + if p == nil { + _, err = io.WriteString(w, "profile not available\n") + return err + } + return p.WriteTo(w, debug) +} + +func addRedactedConfig(zw *zip.Writer, configPath string) error { + w, err := zw.Create("client_config.redacted.json") + if err != nil { + return err + } + raw, err := os.ReadFile(configPath) + if err != nil { + _, writeErr := fmt.Fprintf(w, "unable to read config %q: %v\n", configPath, err) + return writeErr + } + redacted, err := redactConfigJSON(raw) + if err != nil { + _, writeErr := fmt.Fprintf(w, "unable to parse config as JSON; raw config omitted: %v\n", err) + return writeErr + } + _, err = w.Write(redacted) + return err +} + +func redactConfigJSON(raw []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + redacted := redactJSONValue("", v) + return json.MarshalIndent(redacted, "", " ") +} + +func redactJSONValue(key string, v any) any { + lowerKey := strings.ToLower(key) + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, child := range x { + out[k] = redactJSONValue(k, child) + } + return out + case []any: + out := make([]any, len(x)) + for i, child := range x { + if lowerKey == "script_keys" { + out[i] = redactScriptKeyEntry(child) + continue + } + out[i] = redactJSONValue(lowerKey, child) + } + return out + case string: + switch { + case lowerKey == "script_keys": + return redactSecretString(x, "deployment") + case lowerKey == "relay_urls" || lowerKey == "upstream_proxy": + return redactEndpoint(x) + case isSensitiveConfigKey(lowerKey): + return redactSecretString(x, "secret") + default: + return x + } + default: + return v + } +} + +func redactScriptKeyEntry(v any) any { + switch x := v.(type) { + case string: + return redactSecretString(x, "deployment") + case map[string]any: + out := make(map[string]any, len(x)) + for k, child := range x { + if strings.EqualFold(k, "id") { + if s, ok := child.(string); ok { + out[k] = redactSecretString(s, "deployment") + } else { + out[k] = "" + } + continue + } + out[k] = redactJSONValue(k, child) + } + return out + default: + return "" + } +} + +func isSensitiveConfigKey(key string) bool { + if key == "" { + return false + } + sensitiveParts := []string{ + "key", + "secret", + "password", + "pass", + "psk", + "token", + "credential", + } + for _, part := range sensitiveParts { + if strings.Contains(key, part) { + return true + } + } + return false +} + +func redactSecretString(s, label string) string { + if s == "" { + return "" + } + return fmt.Sprintf("", label, len(s)) +} + +func redactEndpoint(s string) string { + if s == "" { + return "" + } + u, err := url.Parse(s) + if err != nil || u.Scheme == "" { + return "" + } + return u.Scheme + "://" +} diff --git a/cmd/client/diagnostics_test.go b/cmd/client/diagnostics_test.go new file mode 100644 index 0000000..d432489 --- /dev/null +++ b/cmd/client/diagnostics_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRedactConfigJSONRedactsSecretsAndKeepsUsefulLabels(t *testing.T) { + raw := []byte(`{ + "script_keys": [ + {"id": "AKfycbabcdefghijklmnopqrstuvwxyz", "account": "acct-a"}, + "AKfycbyabcdefghijklmnopqrstuvwxyz" + ], + "relay_urls": ["https://script.google.com/macros/s/SECRET_DEPLOYMENT/exec"], + "tunnel_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "socks_user": "user", + "socks_pass": "pass" + }`) + + out, err := redactConfigJSON(raw) + if err != nil { + t.Fatalf("redactConfigJSON: %v", err) + } + text := string(out) + for _, secret := range []string{ + "AKfycbabcdefghijklmnopqrstuvwxyz", + "AKfycbyabcdefghijklmnopqrstuvwxyz", + "SECRET_DEPLOYMENT", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + `"pass"`, + } { + if strings.Contains(text, secret) { + t.Fatalf("redacted config leaked %q in:\n%s", secret, text) + } + } + if !strings.Contains(text, `"account": "acct-a"`) { + t.Fatalf("redacted config should preserve account labels, got:\n%s", text) + } + if !strings.Contains(text, `"socks_user": "user"`) { + t.Fatalf("redacted config should preserve non-secret SOCKS username, got:\n%s", text) + } +} + +func TestWriteDiagnosticsZipRedactsConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "client_config.json") + secretKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + raw := `{ + "script_keys": [{"id": "AKfycbabcdefghijklmnopqrstuvwxyz", "account": "acct-a"}], + "tunnel_key": "` + secretKey + `" + }` + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + zipPath := filepath.Join(dir, "diag.zip") + out, err := writeDiagnosticsZip(zipPath, configPath, "test-version") + if err != nil { + t.Fatalf("writeDiagnosticsZip: %v", err) + } + if out != zipPath { + t.Fatalf("output path = %q, want %q", out, zipPath) + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + defer zr.Close() + + var sawConfig bool + var sawSummary bool + for _, f := range zr.File { + switch f.Name { + case "client_config.redacted.json": + sawConfig = true + text := readZipFile(t, f) + if strings.Contains(text, secretKey) || strings.Contains(text, "AKfycbabcdefghijklmnopqrstuvwxyz") { + t.Fatalf("redacted config leaked secret:\n%s", text) + } + if !strings.Contains(text, "acct-a") { + t.Fatalf("redacted config should preserve account label:\n%s", text) + } + case "diagnostics.json": + sawSummary = true + text := readZipFile(t, f) + if !strings.Contains(text, `"version": "test-version"`) { + t.Fatalf("diagnostics summary missing version:\n%s", text) + } + if strings.Contains(text, secretKey) || strings.Contains(text, "AKfycbabcdefghijklmnopqrstuvwxyz") { + t.Fatalf("diagnostics summary leaked secret:\n%s", text) + } + } + } + if !sawConfig { + t.Fatalf("diagnostics zip did not include client_config.redacted.json") + } + if !sawSummary { + t.Fatalf("diagnostics zip did not include diagnostics.json") + } +} + +func readZipFile(t *testing.T, f *zip.File) string { + t.Helper() + rc, err := f.Open() + if err != nil { + t.Fatalf("open %s: %v", f.Name, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read %s: %v", f.Name, err) + } + return string(data) +} diff --git a/cmd/client/main.go b/cmd/client/main.go index 1345624..aa80d4e 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -133,12 +133,23 @@ const gooseBanner = ` func main() { configPath := flag.String("config", "client_config.json", "path to client config JSON") showVersion := flag.Bool("version", false, "show version and exit") + dumpDiag := flag.Bool("dump-diag", false, "write a redacted diagnostics zip and exit") + diagOutput := flag.String("diag-output", "", "path for --dump-diag output (default: goose-diagnostics-YYYYMMDD-HHMMSS.zip)") flag.Parse() if *showVersion { fmt.Println(version) return } + if *dumpDiag { + out, err := writeDiagnosticsZip(*diagOutput, *configPath, version) + if err != nil { + fmt.Fprintf(os.Stderr, "diagnostics failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("diagnostics written to %s\n", out) + return + } fmt.Print(gooseBanner) setupClientLogging()