diff --git a/har/har.go b/har/har.go index b0385b3..e122b63 100644 --- a/har/har.go +++ b/har/har.go @@ -12,6 +12,13 @@ const defaultMaxBodySize = 64 * 1024 // 64 KB // -P http.har.maxBodySize=0 to capture full bodies with no cap. const MaxBodySizeProperty = "http.har.maxBodySize" +// SensitiveProperty is the -P/properties key that disables redaction. By +// default credentials in headers, bodies and query strings are masked, so a +// HAR file is safe to share but cannot be replayed. Set -P http.har.sensitive=true +// to capture them verbatim — the resulting file holds live secrets and is +// written with 0600. +const SensitiveProperty = "http.har.sensitive" + // HARConfig controls what the HAR middleware captures and how it redacts. type HARConfig struct { // MaxBodySize is the maximum number of bytes captured per body. @@ -33,6 +40,11 @@ type HARConfig struct { // identifiers (e.g. session ids, national-id fields) that the default // heuristics don't recognise. RedactedBodyKeys []string + + // CaptureSensitive records credentials verbatim instead of masking them, + // so the archive can be replayed against the live API. Honours + // SensitiveProperty; off by default. + CaptureSensitive bool } // DefaultConfig returns a HARConfig with sensible defaults. The per-body @@ -43,6 +55,7 @@ func DefaultConfig() HARConfig { return HARConfig{ MaxBodySize: int64(properties.Int(defaultMaxBodySize, MaxBodySizeProperty)), CaptureContentTypes: []string{"application/json", "application/x-www-form-urlencoded"}, + CaptureSensitive: properties.On(false, SensitiveProperty), } } diff --git a/har/level.go b/har/level.go new file mode 100644 index 0000000..8932d4e --- /dev/null +++ b/har/level.go @@ -0,0 +1,49 @@ +package har + +import ( + "fmt" + "strings" +) + +// Level selects what a HAR collector captures. Borrowed from +// duty/connection/common.go's Debug/Trace split: at Metadata only headers, +// query strings and timings are recorded (no bodies, so no body re-read cost); +// at Full the standard collector middleware captures bodies too. +type Level int + +const ( + Disabled Level = iota + Metadata + Full +) + +func (l Level) String() string { + switch l { + case Metadata: + return "metadata" + case Full: + return "full" + default: + return "disabled" + } +} + +// ParseLevel maps a property value onto a Level. "debug"/"trace" are accepted +// as synonyms for metadata/full, matching the log.level.*.har vocabulary duty +// and commons-db use. An empty string yields def; anything unrecognised is an +// error, so a typo turns into a startup failure rather than silently capturing +// the wrong thing. +func ParseLevel(value string, def Level) (Level, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return def, nil + case "disabled", "off", "none": + return Disabled, nil + case "metadata", "debug": + return Metadata, nil + case "full", "trace", "bodies": + return Full, nil + default: + return def, fmt.Errorf("invalid HAR level %q: expected metadata, full or disabled", value) + } +} diff --git a/har/metadata.go b/har/metadata.go new file mode 100644 index 0000000..f7e2e1c --- /dev/null +++ b/har/metadata.go @@ -0,0 +1,63 @@ +package har + +import ( + "net/http" + "time" + + "github.com/flanksource/commons/http/middlewares" +) + +// NewMetadataMiddleware captures method, URL, sanitized headers, query string, +// status and timings — no request or response bodies. Body sizes use -1 per the +// HAR spec ("size unknown"). Use it when you want a HAR file for traffic +// analysis without paying the body-buffering cost. +// +// Ported from duty/connection/common.go's metadataHARMiddleware, which +// commons/http and commons-db each carried their own copy of. +func NewMetadataMiddleware(cfg HARConfig, handler func(*Entry)) middlewares.Middleware { + if handler == nil { + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + return func(next http.RoundTripper) http.RoundTripper { + return middlewares.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + entry := &Entry{ + StartedDateTime: time.Now().UTC().Format(time.RFC3339), + Request: Request{ + Method: req.Method, + URL: harURL(req.URL, cfg), + HTTPVersion: httpVersion(req.Proto), + Cookies: []Cookie{}, + Headers: harHeaders(req.Header, cfg), + QueryString: harQueryString(req.URL.Query(), cfg), + HeadersSize: -1, + BodySize: -1, + }, + } + + waitStart := time.Now() + resp, err := next.RoundTrip(req) + waitMs := float64(time.Since(waitStart).Microseconds()) / 1000.0 + + entry.Timings = Timings{Wait: waitMs} + entry.Time = waitMs + entry.Response = Response{ + Cookies: []Cookie{}, + Headers: []Header{}, + Content: Content{Size: -1}, + HeadersSize: -1, + BodySize: -1, + } + if resp != nil { + entry.Response.Status = resp.StatusCode + entry.Response.StatusText = resp.Status + entry.Response.HTTPVersion = httpVersion(resp.Proto) + entry.Response.Headers = harHeaders(resp.Header, cfg) + } + + handler(entry) + return resp, err + }) + } +} diff --git a/har/middleware.go b/har/middleware.go index b558301..3e38422 100644 --- a/har/middleware.go +++ b/har/middleware.go @@ -64,11 +64,11 @@ func CaptureRedirect(req *http.Request, resp *http.Response, cfg HARConfig) *Ent func buildRequest(req *http.Request, cfg HARConfig) Request { har := Request{ Method: req.Method, - URL: redactURL(req.URL, cfg.RedactedBodyKeys), + URL: harURL(req.URL, cfg), HTTPVersion: httpVersion(req.Proto), Cookies: []Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(req.Header, cfg.RedactedHeaders...)), - QueryString: toQueryString(req.URL.Query(), cfg.RedactedBodyKeys), + Headers: harHeaders(req.Header, cfg), + QueryString: harQueryString(req.URL.Query(), cfg), HeadersSize: -1, BodySize: -1, } @@ -80,7 +80,7 @@ func buildRequest(req *http.Request, cfg HARConfig) Request { har.BodySize = int64(len(body.raw)) har.PostData = &PostData{ MimeType: ct, - Text: redactBody(body.text, ct, cfg.RedactedBodyKeys), + Text: harBody(body.text, ct, cfg), } } @@ -93,7 +93,7 @@ func buildResponse(resp *http.Response, cfg HARConfig) Response { StatusText: resp.Status, HTTPVersion: httpVersion(resp.Proto), Cookies: []Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(resp.Header, cfg.RedactedHeaders...)), + Headers: harHeaders(resp.Header, cfg), RedirectURL: "", HeadersSize: -1, BodySize: -1, @@ -107,7 +107,7 @@ func buildResponse(resp *http.Response, cfg HARConfig) Response { har.Content = Content{ Size: body.totalSize, MimeType: ct, - Text: redactBody(body.text, ct, cfg.RedactedBodyKeys), + Text: harBody(body.text, ct, cfg), Truncated: body.truncated, } } @@ -154,6 +154,48 @@ func shouldCapture(contentType string, allowed []string) bool { return false } +// harHeaders, harURL, harQueryString and harBody are the single gate through +// which every captured value passes. cfg.CaptureSensitive (-Phttp.har.sensitive) +// bypasses redaction so the archive can be replayed against the live API; by +// default credentials are masked with logger.PrintableSecret. +func harHeaders(headers http.Header, cfg HARConfig) []Header { + if cfg.CaptureSensitive { + return toHARHeaders(headers) + } + return toHARHeaders(logger.SanitizeHeaders(headers, cfg.RedactedHeaders...)) +} + +func harURL(u *url.URL, cfg HARConfig) string { + if u == nil { + return "" + } + if cfg.CaptureSensitive { + return u.String() + } + return redactURL(u, cfg.RedactedBodyKeys) +} + +func harQueryString(query url.Values, cfg HARConfig) []QueryString { + qs := make([]QueryString, 0, len(query)) + for k, vs := range query { + redact := !cfg.CaptureSensitive && isRedactedKey(k, cfg.RedactedBodyKeys) + for _, v := range vs { + if redact { + v = logger.PrintableSecret(v) + } + qs = append(qs, QueryString{Name: k, Value: v}) + } + } + return qs +} + +func harBody(text, contentType string, cfg HARConfig) string { + if cfg.CaptureSensitive { + return text + } + return redactBody(text, contentType, cfg.RedactedBodyKeys) +} + func redactBody(text, contentType string, extraKeys []string) string { ct := strings.ToLower(strings.Split(contentType, ";")[0]) ct = strings.TrimSpace(ct) @@ -282,20 +324,6 @@ func toHARHeaders(h http.Header) []Header { return headers } -func toQueryString(q url.Values, extraKeys []string) []QueryString { - qs := make([]QueryString, 0, len(q)) - for k, vs := range q { - redact := isRedactedKey(k, extraKeys) - for _, v := range vs { - if redact { - v = logger.PrintableSecret(v) - } - qs = append(qs, QueryString{Name: k, Value: v}) - } - } - return qs -} - func httpVersion(proto string) string { if proto == "" { return "HTTP/1.1" diff --git a/har/middleware_test.go b/har/middleware_test.go index d65201f..ecd7e9c 100644 --- a/har/middleware_test.go +++ b/har/middleware_test.go @@ -88,6 +88,55 @@ func TestHAR_AuthorizationHeaderRedacted(t *testing.T) { } } +// TestHAR_CaptureSensitiveKeepsCredentials pins the -Phttp.har.sensitive escape +// hatch at the config layer: with it set, the archive is replayable because +// every value is verbatim, including a query parameter the default heuristics +// would otherwise mask. +func TestHAR_CaptureSensitiveKeepsCredentials(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(204) + })) + defer srv.Close() + + const ( + secret = "Bearer supersecret" + apiKey = "sk-live-1234567890" + jsonBody = `{"api_key":"sk-live-1234567890"}` + ) + cfg := har.DefaultConfig() + cfg.CaptureSensitive = true + + entry := captureOne(t, cfg, srv, http.MethodPost, "/?token="+apiKey, + strings.NewReader(jsonBody), map[string]string{ + "Authorization": secret, + "Content-Type": "application/json", + }) + + if got := headerValue(entry.Request.Headers, "Authorization"); got != secret { + t.Errorf("Authorization = %q, want the verbatim value %q", got, secret) + } + if !strings.Contains(entry.Request.URL, apiKey) { + t.Errorf("URL %q dropped the query credential", entry.Request.URL) + } + if entry.Request.PostData == nil || entry.Request.PostData.Text != jsonBody { + t.Errorf("request body was redacted: %+v", entry.Request.PostData) + } + for _, q := range entry.Request.QueryString { + if q.Name == "token" && q.Value != apiKey { + t.Errorf("query string token = %q, want %q", q.Value, apiKey) + } + } +} + +func headerValue(headers []har.Header, name string) string { + for _, h := range headers { + if strings.EqualFold(h.Name, name) { + return h.Value + } + } + return "" +} + func TestHAR_CookieHeaderRedacted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Set-Cookie", "session=abc123; Path=/") diff --git a/har/registry.go b/har/registry.go new file mode 100644 index 0000000..d2dea45 --- /dev/null +++ b/har/registry.go @@ -0,0 +1,139 @@ +package har + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "sync" + + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" +) + +// PropertyPrefix is the namespace the registry resolves its properties under. +const PropertyPrefix = "http." + +// Registry turns -P properties into HAR capture: it resolves the output path +// and level per feature, owns one collector per output file, and writes them +// all on Flush. +// +// Properties are looked up per-feature first, then globally: +// +// http..har / http.har output path; unset disables capture +// http..har.level / http.har.level "full" (default) or "metadata" +// http.har.sensitive capture credentials verbatim +// http.har.maxBodySize per-body capture cap +// +// Collectors are deduplicated by absolute path, so several features writing to +// the same file share one archive. +type Registry struct { + prefix string + log logger.Logger + collectors sync.Map // absolute path -> *Collector +} + +// NewRegistry returns a registry that announces capture as it is enabled and +// reports flush results on log. Pass nil for the shared "har" logger, whose +// level can be raised on its own with -Plog.level.har=debug. +func NewRegistry(log logger.Logger) *Registry { + if log == nil { + log = logger.GetLogger("har") + } + return &Registry{prefix: PropertyPrefix, log: log} +} + +// For reports the collector, absolute output path and level configured for +// feature. A nil collector means capture is off. The shape matches +// http.CommonsHTTPContext's HARFor apart from the error, which reports an +// unusable http.har.level rather than silently capturing the wrong thing. +func (r *Registry) For(feature string) (*Collector, string, Level, error) { + path := r.lookup(feature, "har") + if path == "" { + return nil, "", Disabled, nil + } + + level, err := ParseLevel(r.lookup(feature, "har.level"), Full) + if err != nil { + return nil, "", Disabled, fmt.Errorf("%s%s: %w", r.prefix, "har.level", err) + } + if level == Disabled { + return nil, "", Disabled, nil + } + + abs, err := filepath.Abs(path) + if err != nil { + return nil, "", Disabled, fmt.Errorf("resolve HAR path %q: %w", path, err) + } + collector, created := r.collectorFor(abs) + if created { + r.log.Infof("capturing HAR to %s (%s)", abs, describeMode(collector.Config, level)) + } + return collector, abs, level, nil +} + +// describeMode renders the capture mode for the announcement, e.g. +// "level=full" or "level=full, sensitive". +func describeMode(cfg HARConfig, level Level) string { + mode := "level=" + level.String() + if cfg.CaptureSensitive { + mode += ", sensitive" + } + return mode +} + +// Transport wraps base with the capture middleware configured for feature, or +// returns base unchanged when capture is off. +func (r *Registry) Transport(feature string, base http.RoundTripper) (http.RoundTripper, error) { + collector, _, level, err := r.For(feature) + if err != nil || collector == nil { + return base, err + } + if base == nil { + base = http.DefaultTransport + } + + if level == Metadata { + return NewMetadataMiddleware(collector.Config, collector.Add)(base), nil + } + return collector.Middleware()(base), nil +} + +// Flush writes every collector to its file. Collectors are kept afterwards, so +// a second call rewrites the same files rather than losing entries. +func (r *Registry) Flush() error { + var errs []error + r.collectors.Range(func(key, value any) bool { + path, collector := key.(string), value.(*Collector) + if err := WriteFile(collector, path); err != nil { + errs = append(errs, fmt.Errorf("write HAR %s: %w", path, err)) + return true + } + r.log.Infof("wrote HAR %s (%d entries)", path, len(collector.Entries())) + if collector.Config.CaptureSensitive { + r.log.Warnf("%s contains unredacted credentials (%s=true)", path, SensitiveProperty) + } + return true + }) + return errors.Join(errs...) +} + +// collectorFor returns the collector for absPath, reporting whether this call +// created it. Only the creating call announces, so one archive logs one line no +// matter how many features resolve to it. +func (r *Registry) collectorFor(absPath string) (*Collector, bool) { + if existing, ok := r.collectors.Load(absPath); ok { + return existing.(*Collector), false + } + actual, loaded := r.collectors.LoadOrStore(absPath, NewCollector(DefaultConfig())) + return actual.(*Collector), !loaded +} + +func (r *Registry) lookup(feature, suffix string) string { + if feature != "" { + if v := properties.String("", r.prefix+feature+"."+suffix); v != "" { + return v + } + } + return properties.String("", r.prefix+suffix) +} diff --git a/har/registry_test.go b/har/registry_test.go new file mode 100644 index 0000000..2941707 --- /dev/null +++ b/har/registry_test.go @@ -0,0 +1,363 @@ +package har_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/commons/har" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" +) + +// newRegistry returns a registry whose log output is captured, so tests can +// assert on the capture announcement and none of them print to the test log. +func newRegistry(t *testing.T) (*har.Registry, *bytes.Buffer) { + t.Helper() + var out bytes.Buffer + return har.NewRegistry(logger.NewWithWriter(&out)), &out +} + +// setProperty sets a -P property for the duration of the test. Properties are +// process-global, so every test that touches one must restore it. +func setProperty(t *testing.T, key, value string) { + t.Helper() + properties.Set(key, value) + t.Cleanup(func() { properties.Set(key, "") }) +} + +// jsonServer answers every request with a fixed JSON body and echoes nothing, +// so assertions are about what the registry captured, not about the server. +func jsonServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + t.Cleanup(srv.Close) + return srv +} + +// get issues one request through transport and drains the response. +func get(t *testing.T, transport http.RoundTripper, url string, headers map[string]string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func readHAR(t *testing.T, path string) har.File { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var file har.File + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("HAR at %s is not valid JSON: %v", path, err) + } + if file.Log.Version != "1.2" { + t.Errorf("expected HAR version 1.2, got %q", file.Log.Version) + } + return file +} + +func TestRegistry_DisabledWithoutProperty(t *testing.T) { + registry, _ := newRegistry(t) + + collector, path, level, err := registry.For("http") + if err != nil { + t.Fatal(err) + } + if collector != nil || path != "" || level != har.Disabled { + t.Fatalf("expected no capture, got collector=%v path=%q level=%v", collector != nil, path, level) + } + + base := http.DefaultTransport + wrapped, err := registry.Transport("http", base) + if err != nil { + t.Fatal(err) + } + if wrapped != base { + t.Error("transport must be returned unchanged when http.har is unset") + } +} + +func TestRegistry_FeaturePropertyOverridesGlobal(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "global.har")) + setProperty(t, "http.chat.har", filepath.Join(dir, "chat.har")) + registry, _ := newRegistry(t) + + for _, tc := range []struct{ feature, want string }{ + {feature: "chat", want: "chat.har"}, + {feature: "models", want: "global.har"}, + {feature: "", want: "global.har"}, + } { + _, path, _, err := registry.For(tc.feature) + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(dir, tc.want) { + t.Errorf("feature %q resolved to %s, want %s", tc.feature, path, tc.want) + } + } +} + +func TestRegistry_DedupesCollectorsByAbsolutePath(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "shared.har")) + registry, _ := newRegistry(t) + + first, _, _, err := registry.For("chat") + if err != nil { + t.Fatal(err) + } + second, _, _, err := registry.For("models") + if err != nil { + t.Fatal(err) + } + if first != second { + t.Error("features writing to the same file must share one collector") + } +} + +func TestRegistry_Level(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "trace.har")) + + for _, tc := range []struct { + name string + value string + want har.Level + wantErr bool + }{ + {name: "defaults to full", value: "", want: har.Full}, + {name: "metadata downgrades", value: "metadata", want: har.Metadata}, + {name: "debug is a metadata synonym", value: "debug", want: har.Metadata}, + {name: "trace is a full synonym", value: "trace", want: har.Full}, + {name: "off disables capture", value: "off", want: har.Disabled}, + {name: "a typo is an error", value: "verbose", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + setProperty(t, "http.har.level", tc.value) + + registry, _ := newRegistry(t) + _, _, level, err := registry.For("http") + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error for level %q", tc.value) + } + return + } + if err != nil { + t.Fatal(err) + } + if level != tc.want { + t.Errorf("level = %v, want %v", level, tc.want) + } + }) + } +} + +// TestRegistry_AnnouncesCaptureOnce pins the signal that capture is on: without +// it, a run gives no indication until the archive is flushed at exit. +func TestRegistry_AnnouncesCaptureOnce(t *testing.T) { + const announcement = "capturing HAR to " + dir := t.TempDir() + + t.Run("silent when capture is off", func(t *testing.T) { + registry, out := newRegistry(t) + if _, _, _, err := registry.For("http"); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), announcement) { + t.Errorf("nothing should be announced when http.har is unset, got:\n%s", out) + } + }) + + for _, tc := range []struct { + name string + level string + sensitive string + wantMode string + }{ + {name: "full is the default mode", wantMode: "level=full"}, + {name: "metadata is named", level: "metadata", wantMode: "level=metadata"}, + {name: "sensitive is called out", sensitive: "true", wantMode: "level=full, sensitive"}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.level", tc.level) + setProperty(t, "http.har.sensitive", tc.sensitive) + registry, out := newRegistry(t) + + // Two resolutions of the same archive, through both entry points. + if _, _, _, err := registry.For("chat"); err != nil { + t.Fatal(err) + } + if _, err := registry.Transport("models", http.DefaultTransport); err != nil { + t.Fatal(err) + } + + if got := strings.Count(out.String(), announcement); got != 1 { + t.Fatalf("announced %d times, want exactly 1:\n%s", got, out) + } + want := fmt.Sprintf("%s%s (%s)", announcement, path, tc.wantMode) + if !strings.Contains(out.String(), want) { + t.Errorf("expected %q, got:\n%s", want, out) + } + }) + } + + t.Run("one line per archive", func(t *testing.T) { + setProperty(t, "http.har", filepath.Join(dir, "global.har")) + setProperty(t, "http.chat.har", filepath.Join(dir, "chat.har")) + registry, out := newRegistry(t) + + for _, feature := range []string{"chat", "models"} { + if _, _, _, err := registry.For(feature); err != nil { + t.Fatal(err) + } + } + if got := strings.Count(out.String(), announcement); got != 2 { + t.Errorf("two distinct archives must announce twice, got %d:\n%s", got, out) + } + }) +} + +func TestRegistry_CapturesFullBodiesAndFlushes(t *testing.T) { + const body = `{"status":"ok"}` + path := filepath.Join(t.TempDir(), "trace.har") + setProperty(t, "http.har", path) + srv := jsonServer(t, body) + registry, _ := newRegistry(t) + + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", nil) + get(t, transport, srv.URL+"/pong", nil) + + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + entries := readHAR(t, path).Log.Entries + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if !strings.HasSuffix(entries[0].Request.URL, "/ping") { + t.Errorf("unexpected first URL: %s", entries[0].Request.URL) + } + if entries[0].Response.Content.Text != body { + t.Errorf("expected the response body to be captured, got %q", entries[0].Response.Content.Text) + } + + if mode := fileMode(t, path); mode != 0o644 { + t.Errorf("expected mode 0644 for a redacted HAR, got %04o", mode) + } +} + +func TestRegistry_MetadataLevelOmitsBodies(t *testing.T) { + path := filepath.Join(t.TempDir(), "meta.har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.level", "metadata") + srv := jsonServer(t, `{"status":"ok"}`) + registry, _ := newRegistry(t) + + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", nil) + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + entries := readHAR(t, path).Log.Entries + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Response.Content.Text != "" { + t.Errorf("metadata level must not capture bodies, got %q", entries[0].Response.Content.Text) + } + if entries[0].Response.BodySize != -1 || entries[0].Request.BodySize != -1 { + t.Errorf("metadata level must report unknown body sizes, got req=%d resp=%d", + entries[0].Request.BodySize, entries[0].Response.BodySize) + } + if entries[0].Response.Status != http.StatusOK { + t.Errorf("metadata level must still record the status, got %d", entries[0].Response.Status) + } +} + +func TestRegistry_SensitiveCapturesCredentialsAndTightensMode(t *testing.T) { + const token = "Bearer sk-live-abcdefghijklmnop" + dir := t.TempDir() + srv := jsonServer(t, `{"status":"ok"}`) + + for _, tc := range []struct { + name string + sensitive string + wantToken bool + wantMode os.FileMode + }{ + {name: "redacted by default", sensitive: "", wantMode: 0o644}, + {name: "verbatim when enabled", sensitive: "true", wantToken: true, wantMode: 0o600}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.sensitive", tc.sensitive) + + registry, _ := newRegistry(t) + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", map[string]string{"Authorization": token}) + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Contains(string(raw), token); got != tc.wantToken { + t.Errorf("token present = %v, want %v", got, tc.wantToken) + } + if mode := fileMode(t, path); mode != tc.wantMode { + t.Errorf("mode = %04o, want %04o", mode, tc.wantMode) + } + }) + } +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() +} diff --git a/har/write.go b/har/write.go new file mode 100644 index 0000000..e70d716 --- /dev/null +++ b/har/write.go @@ -0,0 +1,37 @@ +package har + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" +) + +// CreatorName identifies commons as the producer in the HAR envelope. +const CreatorName = "flanksource-commons" + +// WriteFile serializes collector.Entries() into a HAR 1.2 file at path. A +// collector configured with CaptureSensitive holds unmasked credentials, so its +// file is written 0600 rather than 0644. +func WriteFile(collector *Collector, path string) error { + file := File{ + Log: Log{ + Version: "1.2", + Creator: Creator{Name: CreatorName, Version: "0"}, + Pages: []Page{}, + Entries: collector.Entries(), + }, + } + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshal HAR: %w", err) + } + return os.WriteFile(path, append(data, '\n'), fileMode(collector)) +} + +func fileMode(collector *Collector) fs.FileMode { + if collector != nil && collector.Config.CaptureSensitive { + return 0o600 + } + return 0o644 +} diff --git a/help/format.go b/help/format.go new file mode 100644 index 0000000..9d76231 --- /dev/null +++ b/help/format.go @@ -0,0 +1,18 @@ +package help + +import "github.com/flanksource/clicky/api" + +func formatTopic() api.Text { + return lines( + knob("--format=", "pretty (default), json, yaml, csv, html, markdown, pdf, slack"), + knob("--json, --yaml, --csv, --pdf", "shorthands for the matching --format value"), + knob("--markdown, --html, --pretty", "the remaining --format shorthands"), + knob("--tree, --table", "display structure, additive with the chosen format"), + knob("--filter=", "CEL expression filtering the data before rendering"), + knob("--no-color", "disable ANSI colour in rendered output"), + note("--format also takes a comma separated list of format=file sinks, which writes"), + note("each rendering to its own file instead of stdout:"), + example("--format=json=report.json,markdown=summary.md"), + note("These flags come from clicky; a CLI accepts them only if it binds that group."), + ) +} diff --git a/help/har.go b/help/har.go new file mode 100644 index 0000000..7e98d2e --- /dev/null +++ b/help/har.go @@ -0,0 +1,20 @@ +package help + +import "github.com/flanksource/clicky/api" + +func harTopic() api.Text { + return lines( + note("A HAR file records whole request/response pairs — including OAuth token fetches,"), + note("redirect hops and retries — for import into browser DevTools or any HAR viewer."), + note("Naming an output path is what turns capture on:"), + example("-Phttp.har=trace.har"), + knob("-Phttp.har=", "capture to ; unset means no capture"), + knob("-Phttp..har=", "capture one subsystem to its own file"), + knob("-Phttp.har.level=metadata", "headers, query and timings only; default is full bodies"), + knob("-Phttp.har.sensitive=true", "keep credentials verbatim so the archive replays"), + knob("-Phttp.har.maxBodySize", "bytes captured per body (default 65536, 0 = uncapped)"), + note("Bodies are captured for application/json and application/x-www-form-urlencoded"), + note("only. By default headers, body keys and query strings are redacted with the same"), + note("rules as the wire log; http.har.sensitive turns that off and writes the file 0600."), + ) +} diff --git a/help/help.go b/help/help.go new file mode 100644 index 0000000..03c880b --- /dev/null +++ b/help/help.go @@ -0,0 +1,103 @@ +// Package help renders operator-facing documentation for the runtime knobs +// commons owns — log verbosity, log formatting, HTTP wire tracing, HAR capture +// and output formatting — as a clicky Textable, so every CLI built on commons +// can splice the same block into its --help instead of re-documenting the same +// properties (or, more often, not documenting them at all). +// +// The package deliberately sits outside logger/ and http/: clicky imports +// commons/{logger,text,collections,context}, so rendering help from any of +// those packages would create an import cycle. +package help + +import ( + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/collections" +) + +// Topic is one named section of the help document. +type Topic struct { + // Name is the stable selector used to filter topics, e.g. "http". + Name string + + // Title is the section heading. + Title string + + // Body is the rendered section, excluding its heading. + Body api.Text +} + +// Topics returns every section in display order. +func Topics() []Topic { + return []Topic{ + {Name: "properties", Title: "Runtime properties", Body: propertiesTopic()}, + {Name: "logging", Title: "Logging verbosity", Body: loggingTopic()}, + {Name: "log-format", Title: "Log formatting", Body: logFormatTopic()}, + {Name: "http", Title: "HTTP wire logging", Body: httpTopic()}, + {Name: "har", Title: "HAR capture", Body: harTopic()}, + {Name: "format", Title: "Output formatting", Body: formatTopic()}, + } +} + +// Help composes the selected topics into a single document. With no names every +// topic is included; names are matched with collections.MatchItems, so "http", +// "!har" and "log*" all select as expected. The result is an api.Text, which +// satisfies api.Textable — callers pick String(), ANSI(), Markdown() or HTML(). +func Help(names ...string) api.Text { + doc := api.Text{} + for _, topic := range Topics() { + if !collections.MatchItems(topic.Name, names...) { + continue + } + if !doc.IsEmpty() { + doc = doc.NewLine() + } + doc = doc.AddText(topic.Title, styleTitle).NewLine().Add(topic.Body) + } + return doc +} + +const ( + styleTitle = "font-bold text-blue-400" + styleCode = "font-mono text-yellow-600" + styleMuted = "text-gray-500" + + // knobWidth is the width of the flag/property column, chosen so the widest + // documented knob still leaves room for its description on an 80 column + // terminal. + knobWidth = 32 +) + +// knob renders an aligned " " line. +func knob(name, description string) api.Text { + return api.Text{}. + AddText(" "+rpad(name, knobWidth), styleCode). + AddText(description, styleMuted) +} + +// note renders an indented prose line. +func note(text string) api.Text { + return api.Text{}.AddText(" " + text) +} + +// example renders an indented copy-pasteable command fragment. +func example(text string) api.Text { + return api.Text{}.AddText(" "+text, styleCode) +} + +// lines stacks body lines into a single Text, one per line. +func lines(items ...api.Text) api.Text { + body := api.Text{} + for _, item := range items { + body = body.Add(item).NewLine() + } + return body +} + +func rpad(s string, width int) string { + if len(s) >= width { + return s + " " + } + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/help/help_suite_test.go b/help/help_suite_test.go new file mode 100644 index 0000000..75361c2 --- /dev/null +++ b/help/help_suite_test.go @@ -0,0 +1,13 @@ +package help + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHelp(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Help Suite") +} diff --git a/help/help_test.go b/help/help_test.go new file mode 100644 index 0000000..fd5af9d --- /dev/null +++ b/help/help_test.go @@ -0,0 +1,171 @@ +package help + +import ( + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/http" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// documentedKeys are the knobs the help exists to make discoverable. A section +// that silently stops rendering fails here. +var documentedKeys = []string{ + "-P/--properties", + "--log-level", + "log.level.", + "--json-logs", + "HTTP_LOG_BASE_LEVEL", + "http.log.response.body.length", + "http.har.maxBodySize", + "--format", +} + +// renderings covers every output format api.Textable promises, so a section +// that renders in ANSI but collapses to nothing in Markdown is caught. +func renderings(text api.Text) map[string]string { + return map[string]string{ + "String": text.String(), + "ANSI": text.ANSI(), + "Markdown": text.Markdown(), + "HTML": text.HTML(), + } +} + +var _ = Describe("Topics", func() { + It("returns the documented sections in display order", func() { + var names []string + for _, topic := range Topics() { + names = append(names, topic.Name) + } + Expect(names).To(Equal([]string{"properties", "logging", "log-format", "http", "har", "format"})) + }) + + It("gives every topic a title and a body that renders in every format", func() { + for _, topic := range Topics() { + Expect(topic.Title).ToNot(BeEmpty(), topic.Name) + for format, out := range renderings(topic.Body) { + Expect(strings.TrimSpace(out)).ToNot(BeEmpty(), "%s topic rendered empty as %s", topic.Name, format) + } + } + }) +}) + +var _ = Describe("Help", func() { + It("documents every knob in every rendering", func() { + for format, out := range renderings(Help()) { + for _, key := range documentedKeys { + Expect(out).To(ContainSubstring(key), "%s missing from %s output", key, format) + } + } + }) + + It("includes each topic's title", func() { + out := Help().String() + for _, topic := range Topics() { + Expect(out).To(ContainSubstring(topic.Title)) + } + }) + + It("selects a single topic by name", func() { + out := Help("http").String() + Expect(out).To(ContainSubstring("HTTP_LOG_BASE_LEVEL")) + Expect(out).ToNot(ContainSubstring("http.har.maxBodySize")) + Expect(out).ToNot(ContainSubstring("--json-logs")) + }) + + It("excludes a topic with a negated name", func() { + out := Help("!har").String() + Expect(out).ToNot(ContainSubstring("http.har.maxBodySize")) + Expect(out).To(ContainSubstring("HTTP_LOG_BASE_LEVEL")) + Expect(out).To(ContainSubstring("--json-logs")) + }) +}) + +var _ = Describe("the HTTP trace ladder", func() { + It("describes each level exactly as TraceConfigForLogLevel builds it", func() { + for level := logger.Warn; level <= logger.Trace4; level++ { + config, enabled := http.TraceConfigForLogLevel(level) + captures := strings.Join(traceCaptures(config, enabled), ", ") + + Expect(captures == "").To(Equal(!enabled), "%s claims %q but enabled=%v", level, captures, enabled) + if !enabled { + continue + } + Expect(strings.Contains(captures, "headers")).To(Equal(config.Headers || config.ResponseHeaders), level.String()) + Expect(strings.Contains(captures, "query and form params")).To(Equal(config.QueryParam || config.FormParams), level.String()) + Expect(strings.Contains(captures, "request bodies")).To(Equal(config.Body), level.String()) + Expect(strings.Contains(captures, "response bodies")).To(Equal(config.Response), level.String()) + Expect(strings.Contains(captures, "TLS summary")).To(Equal(config.TLS), level.String()) + } + }) + + It("folds away levels that capture nothing new", func() { + var captures []string + for _, row := range traceLevelRows() { + captures = append(captures, row.Captures) + } + Expect(captures).To(HaveLen(len(dedupeConsecutive(captures))), "consecutive rungs must differ") + }) + + It("renders each extra rung as the delta over the one below it", func() { + rows := traceLevelRows() + deltas := map[logger.LogLevel]string{} + for _, row := range rows { + if strings.HasPrefix(row.Captures, "+ ") { + deltas[row.Level] = strings.TrimPrefix(row.Captures, "+ ") + } + } + Expect(deltas).ToNot(BeEmpty(), "the ladder must have at least one incremental rung") + + for level, delta := range deltas { + below, _ := http.TraceConfigForLogLevel(level - 1) + config, _ := http.TraceConfigForLogLevel(level) + added := traceCaptures(config, true)[len(traceCaptures(below, true)):] + Expect(delta).To(Equal(strings.Join(added, ", ")), level.String()) + } + }) + + It("renders the -v count that reaches each level", func() { + Expect(levelFlag(logger.Info)).To(Equal("--log-level=info")) + Expect(levelFlag(logger.Debug)).To(Equal("-v")) + Expect(levelFlag(logger.Trace)).To(Equal("-vv")) + Expect(levelFlag(logger.Trace2)).To(Equal("-vvvv")) + }) + + It("shifts with the configured base level", func() { + bodiesAt := func() logger.LogLevel { + for _, row := range traceLevelRows() { + if strings.Contains(row.Captures, "request bodies") { + return row.Level + } + } + Fail("no rung captures request bodies") + return logger.Silent + } + + defaultLevel := bodiesAt() + + properties.Set("http.log.base-level", "trace") + DeferCleanup(func() { properties.Set("http.log.base-level", "") }) + + Expect(bodiesAt()).To(Equal(defaultLevel+1), + "raising the base level by one must move the whole ladder up by one") + }) +}) + +// dedupeConsecutive drops runs of equal adjacent entries. +func dedupeConsecutive(values []string) []string { + var out []string + for _, v := range values { + if len(out) > 0 && out[len(out)-1] == v { + continue + } + out = append(out, v) + } + return out +} diff --git a/help/http.go b/help/http.go new file mode 100644 index 0000000..d831f39 --- /dev/null +++ b/help/http.go @@ -0,0 +1,129 @@ +package help + +import ( + "slices" + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/http" + "github.com/flanksource/commons/logger" +) + +func httpTopic() api.Text { + body := lines( + note("Clients built on commons/http log their traffic on a ladder relative to a base"), + note("level (debug by default). Credentials in headers, bodies and query strings are"), + note("redacted before anything is written."), + ) + body = body.Add(api.NewTableFrom(traceLevelRows())).NewLine() + return body.Add(lines( + knob("-Plog.level.http=", "raise the http logger alone, leaving the rest quiet"), + knob("HTTP_LOG_BASE_LEVEL=", "move the whole ladder; also -Phttp.log.base-level"), + knob("-Phttp.log.response.body.length", "bytes of response body logged (default 4096)"), + knob("-Phttp.request.maxBufferSize", "bytes of a streamed request body buffered for retry"), + knob("-Phttp.body.disabled", "never log request or response bodies"), + knob("-Phttp.headers.disabled", "never log request or response headers"), + note("CLIs that expose -Phttp.log= take a spec instead of a level: access,"), + note("headers, body, request, response, trace or all, plus the additive tokens"), + note("queryParam, formParams, responseHeaders, tls, timing and auth."), + note("To keep the exchanges rather than watch them scroll past, see HAR capture."), + )) +} + +// traceLevelRow is one rung of the HTTP trace ladder. +type traceLevelRow struct { + Level logger.LogLevel + Flag string + Captures string +} + +// Columns implements api.TableProvider. +func (traceLevelRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("level").Label("Level").Style("font-mono").Build(), + api.Column("flag").Label("Flag").Style("font-mono text-yellow-600").Build(), + api.Column("captures").Label("Captures").Build(), + } +} + +// Row implements api.TableProvider. +func (r traceLevelRow) Row() map[string]any { + return map[string]any{ + "level": r.Level.String(), + "flag": r.Flag, + "captures": r.Captures, + } +} + +// traceLevelRows derives the ladder from http.TraceConfigForLogLevel instead of +// transcribing it, so the table cannot drift from the code and reflects an +// HTTP_LOG_BASE_LEVEL / http.log.base-level override at render time. Levels that +// capture nothing new are folded away, and rungs that only extend the one below +// them are rendered as the delta. +func traceLevelRows() []traceLevelRow { + var rows []traceLevelRow + var previous []string + for level := logger.Warn; level <= logger.Trace4; level++ { + captures := traceCaptures(http.TraceConfigForLogLevel(level)) + if len(rows) > 0 && slices.Equal(previous, captures) { + continue + } + rows = append(rows, traceLevelRow{ + Level: level, + Flag: levelFlag(level), + Captures: describeRung(captures, previous), + }) + previous = captures + } + return rows +} + +// traceCaptures lists everything a level captures, in ladder order. +func traceCaptures(config http.TraceConfig, enabled bool) []string { + if !enabled { + return nil + } + if config.AccessLogErrorsOnly { + return []string{"failed requests only (errors and status >= 400)"} + } + + captures := []string{"one access line per request"} + if config.Headers || config.ResponseHeaders { + captures = append(captures, "headers") + } + if config.QueryParam || config.FormParams { + captures = append(captures, "query and form params") + } + if config.Body { + captures = append(captures, "request bodies") + } + if config.TLS { + captures = append(captures, "TLS summary") + } + if config.Response { + captures = append(captures, "response bodies") + } + return captures +} + +// describeRung renders captures relative to the rung below it, so each row shows +// what the extra verbosity buys rather than repeating the whole list. +func describeRung(captures, previous []string) string { + switch { + case len(captures) == 0: + return "nothing" + case len(previous) == 0 || !slices.Equal(previous, captures[:min(len(previous), len(captures))]): + return strings.Join(captures, ", ") + default: + return "+ " + strings.Join(captures[len(previous):], ", ") + } +} + +// levelFlag renders how a level is reached from the command line. logger.Configure +// passes the -v count straight to SetLogLevel, so the count is the level number. +func levelFlag(level logger.LogLevel) string { + if level <= logger.Info { + return "--log-level=" + level.String() + } + return "-" + strings.Repeat("v", int(level)) +} diff --git a/help/logging.go b/help/logging.go new file mode 100644 index 0000000..3654e7f --- /dev/null +++ b/help/logging.go @@ -0,0 +1,33 @@ +package help + +import "github.com/flanksource/clicky/api" + +func propertiesTopic() api.Text { + return lines( + note("Every log.* and http.* setting below is a property. Properties are set with"), + note("-P/--properties, which is repeatable and also accepts comma separated pairs:"), + example("-Plog.level.http=trace,http.body.disabled=true"), + ) +} + +func loggingTopic() api.Text { + return lines( + knob("-v, -vv, -vvv, -vvvv", "raise the global level to debug, trace, trace1, trace2"), + knob("--log-level=", "error, warn, info (default), debug, trace, trace1..trace4"), + knob("LOG_LEVEL=", "the same levels, from the environment"), + knob("-Plog.level.=", "raise a single subsystem, e.g. log.level.http=trace"), + note("A -v count wins over --log-level. Named loggers inherit the global level unless"), + note("log.level. overrides them, so one subsystem can be traced without the"), + note("rest of the process becoming verbose."), + ) +} + +func logFormatTopic() api.Text { + return lines( + knob("--json-logs", "structured JSON on stderr instead of console text"), + knob("--report-caller", "prefix each line with its source file and line"), + knob("--color=false / --no-color", "disable ANSI colour (whichever flag the CLI binds)"), + knob("NO_COLOR, COLOR=no, TERM=dumb", "disable ANSI colour from the environment"), + note("Logs always go to stderr; --log-to-stderr is accepted but ignored."), + ) +} diff --git a/http/client.go b/http/client.go index 4727a19..ab802eb 100644 --- a/http/client.go +++ b/http/client.go @@ -43,7 +43,6 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/json" "fmt" "net/http" "net/url" @@ -783,12 +782,14 @@ func (c *Client) getLogger() logger.Logger { // split — at Metadata, only request/response headers + timing are // captured (no bodies, no body re-read cost). At Full, the standard // collector middleware captures bodies too. -type HARLevel int +// The levels live in commons/har so the middleware, the property registry and +// this client all speak one enum; these names are kept for callers. +type HARLevel = har.Level const ( - HARDisabled HARLevel = iota - HARMetadata - HARFull + HARDisabled = har.Disabled + HARMetadata = har.Metadata + HARFull = har.Full ) // CommonsHTTPContext is the narrow interface a context object implements @@ -834,112 +835,18 @@ func (c *Client) WithContext(ctx CommonsHTTPContext, feature string) *Client { case HARFull: c = c.HARCollector(collector) case HARMetadata: - c.Use(metadataHARMiddleware(collector)) + c.Use(har.NewMetadataMiddleware(collector.Config, collector.Add)) } } return c } -// metadataHARMiddleware captures method, URL, sanitized headers, query -// string, status, and timings — no request or response bodies. Ported -// from duty/connection/common.go's metadataHARMiddleware. Body sizes -// use -1 per HAR spec ("size unknown"). Useful when the caller wants a -// HAR file for traffic analysis without paying the body-buffering cost. -func metadataHARMiddleware(collector *har.Collector) middlewares.Middleware { - return func(next http.RoundTripper) http.RoundTripper { - return middlewares.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - started := time.Now() - entry := &har.Entry{ - StartedDateTime: started.UTC().Format(time.RFC3339), - Request: har.Request{ - Method: req.Method, - URL: req.URL.String(), - HTTPVersion: harHTTPVersion(req.Proto), - Cookies: []har.Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(req.Header)), - QueryString: toHARQueryString(req.URL.Query()), - HeadersSize: -1, - BodySize: -1, - }, - } - - waitStart := time.Now() - resp, err := next.RoundTrip(req) - waitMs := float64(time.Since(waitStart).Microseconds()) / 1000.0 - - entry.Timings = har.Timings{Wait: waitMs} - entry.Time = waitMs - if resp != nil { - entry.Response = har.Response{ - Status: resp.StatusCode, - StatusText: resp.Status, - HTTPVersion: harHTTPVersion(resp.Proto), - Cookies: []har.Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(resp.Header)), - Content: har.Content{Size: -1}, - HeadersSize: -1, - BodySize: -1, - } - } else { - entry.Response = har.Response{ - Cookies: []har.Cookie{}, - Headers: []har.Header{}, - Content: har.Content{Size: -1}, - HeadersSize: -1, - BodySize: -1, - } - } - - collector.Add(entry) - return resp, err - }) - } -} - -func toHARHeaders(h http.Header) []har.Header { - headers := make([]har.Header, 0, len(h)) - for name, vals := range h { - for _, v := range vals { - headers = append(headers, har.Header{Name: name, Value: v}) - } - } - return headers -} - -func toHARQueryString(q url.Values) []har.QueryString { - qs := make([]har.QueryString, 0, len(q)) - for k, vs := range q { - for _, v := range vs { - qs = append(qs, har.QueryString{Name: k, Value: v}) - } - } - return qs -} - -func harHTTPVersion(proto string) string { - if strings.TrimSpace(proto) == "" { - return "HTTP/1.1" - } - return proto -} - // WriteHARFile serializes collector.Entries() into a HAR 1.2 file at // path. Designed for use from a context.AfterFunc hook owned by the -// caller — commons/http does not register any lifecycle itself. +// caller — commons/http does not register any lifecycle itself, though +// har.Registry does it for property-driven capture. func WriteHARFile(collector *har.Collector, path string) error { - file := har.File{ - Log: har.Log{ - Version: "1.2", - Creator: har.Creator{Name: "flanksource-commons", Version: "0"}, - Pages: []har.Page{}, - Entries: collector.Entries(), - }, - } - data, err := json.MarshalIndent(file, "", " ") - if err != nil { - return fmt.Errorf("marshal HAR: %w", err) - } - return os.WriteFile(path, append(data, '\n'), 0o644) + return har.WriteFile(collector, path) } // HAR enables HAR capture with default config.