-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathoptions.go
More file actions
360 lines (308 loc) · 11.6 KB
/
Copy pathoptions.go
File metadata and controls
360 lines (308 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
package govisual
import (
"context"
"crypto/subtle"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/doganarif/govisual/v2/internal/profiling"
"github.com/doganarif/govisual/v2/store"
)
// DashboardAuth authorizes a request to the dashboard. Return true to allow,
// false to deny (govisual sends an HTTP 401). Implementations should be
// constant-time when comparing secrets.
type DashboardAuth func(r *http.Request) bool
// ErrorHandler receives capture/storage errors synchronously after the wrapped
// handler returns. It should return promptly. Panics are recovered and logged
// so observability failures never replace the application's result.
type ErrorHandler func(error)
type Config struct {
MaxRequests int
DashboardPath string
LogRequestBody bool
LogResponseBody bool
// MaxBodyBytes caps the captured request and response body size.
// 0 (default) means use middleware.DefaultMaxBodyBytes (1 MiB).
// Set to -1 to disable the cap entirely (NOT recommended).
MaxBodyBytes int
IgnorePaths []string
// SampleRate is the fraction of requests to capture, 0..1. 1 captures
// everything (the default); lower values shed load on chatty services.
SampleRate float64
// Store is the storage backend for captured requests. Nil means an
// in-memory store bounded by MaxRequests. Database-backed stores live in
// their own modules under store/ (postgres, redis, sqlite, mongodb).
Store store.Store
// ActivityLog, if set, is displayed on the dashboard's Agents tab. Share
// the same instance with the mcp module (mcp.WithActivityLog) so agent
// tool calls appear here.
ActivityLog *store.ActivityLog
// ErrorHandler receives storage errors. Nil uses log.Printf.
ErrorHandler ErrorHandler
// Performance Profiling configuration
EnableProfiling bool
ProfileType ProfileType
ProfileThreshold time.Duration
MaxProfileMetrics int
// Dashboard security ----------------------------------------------------
// DashboardAuth, if set, must approve every request to the dashboard.
// If nil, the dashboard is fully open — only safe for local dev.
DashboardAuth DashboardAuth
// LocalhostOnly rejects dashboard requests whose remote address is not a
// loopback IP. On by default — even with the rest of the server bound to
// 0.0.0.0, the dashboard stays local unless WithAllowRemote is used.
LocalhostOnly bool
// EnableReplay enables the POST /__viz/api/replay endpoint. Replays target
// only ReplayBaseURL or a validated loopback dashboard origin. It remains
// disabled by default because replay can repeat captured side effects.
EnableReplay bool
// ReplayBaseURL pins dashboard replays to a configured application origin.
// It is required when LocalhostOnly is false. A localhost-only dashboard
// may leave it empty to target its own validated localhost origin.
ReplayBaseURL string
// ExposeSystemInfo controls whether the GET /__viz/api/system-info endpoint
// is enabled. Disabled by default; enabling exposes runtime info (hostname,
// Go version, memory stats).
ExposeSystemInfo bool
// ExposeEnvVars is an explicit allowlist of environment variable names that
// the system-info endpoint may surface. Anything not in this set is omitted
// entirely (NOT redacted) so an attacker cannot infer the existence of a
// sensitive name.
ExposeEnvVars []string
// ShutdownContext, if set, triggers graceful shutdown of govisual-owned
// resources (the storage backend) when the context is cancelled. This
// replaces the prior behavior of registering a global signal handler that
// called os.Exit — a library has no business killing the host process.
ShutdownContext context.Context
}
// Option is a function that modifies the configuration
type Option func(*Config)
// WithMaxRequests sets the maximum number of requests to store
func WithMaxRequests(max int) Option {
return func(c *Config) {
c.MaxRequests = max
}
}
// WithDashboardPath sets the path to access the dashboard
func WithDashboardPath(path string) Option {
return func(c *Config) {
c.DashboardPath = strings.TrimSuffix(path, "/")
}
}
// WithRequestBodyLogging enables or disables request body logging
func WithRequestBodyLogging(enabled bool) Option {
return func(c *Config) {
c.LogRequestBody = enabled
}
}
// WithResponseBodyLogging enables or disables response body logging
func WithResponseBodyLogging(enabled bool) Option {
return func(c *Config) {
c.LogResponseBody = enabled
}
}
// WithMaxBodyBytes caps the captured request and response body size.
// Values:
// - 0: use the package default (1 MiB)
// - >0: explicit cap in bytes
// - <0: disable cap (unbounded — be careful with large downloads)
func WithMaxBodyBytes(n int) Option {
return func(c *Config) {
c.MaxBodyBytes = n
}
}
// WithSampleRate captures only the given fraction of requests (0..1).
// Uncaptured requests pass through with no overhead. Useful when govisual
// wraps a busy service and full capture would be noise.
func WithSampleRate(rate float64) Option {
return func(c *Config) {
if rate < 0 {
rate = 0
}
if rate > 1 {
rate = 1
}
c.SampleRate = rate
}
}
// WithIgnorePaths sets the path patterns to ignore
func WithIgnorePaths(patterns ...string) Option {
return func(c *Config) {
c.IgnorePaths = append(c.IgnorePaths, patterns...)
}
}
// WithStore sets the storage backend for captured requests. Construct one
// from a storage module, e.g. postgres.New(...) from
// github.com/doganarif/govisual/store/postgres. Without this option an
// in-memory store bounded by WithMaxRequests is used.
func WithStore(s store.Store) Option {
return func(c *Config) {
c.Store = s
}
}
// WithActivityLog attaches an activity log the dashboard can surface. Pass
// the same *store.ActivityLog to the mcp module so coding-agent tool calls
// show up on the dashboard's Agents tab.
func WithActivityLog(a *store.ActivityLog) Option {
return func(c *Config) {
c.ActivityLog = a
}
}
// WithErrorHandler receives request-capture persistence errors synchronously.
// The callback should return promptly; its panics are recovered and logged.
// Govisual does not turn these errors into application failures.
func WithErrorHandler(fn func(error)) Option {
return func(c *Config) {
c.ErrorHandler = fn
}
}
// ShouldIgnorePath checks if a path should be ignored based on the configured patterns.
func (c *Config) ShouldIgnorePath(path string) bool {
// The dashboard itself must always be ignored, otherwise opening it
// would recursively log every poll.
if path == c.DashboardPath || strings.HasPrefix(path, c.DashboardPath+"/") {
return true
}
for _, pattern := range c.IgnorePaths {
if matched, err := filepath.Match(pattern, path); err == nil && matched {
return true
}
// Trailing-slash patterns are treated as "prefix match".
if len(pattern) > 0 && pattern[len(pattern)-1] == '/' {
if strings.HasPrefix(path, pattern) {
return true
}
}
}
return false
}
// WithProfiling enables or disables performance profiling
func WithProfiling(enabled bool) Option {
return func(c *Config) {
c.EnableProfiling = enabled
}
}
// WithProfileType sets the types of profiling to perform
func WithProfileType(profileType ProfileType) Option {
return func(c *Config) {
c.ProfileType = profileType
}
}
// WithProfileThreshold sets the minimum duration to trigger profiling
func WithProfileThreshold(threshold time.Duration) Option {
return func(c *Config) {
c.ProfileThreshold = threshold
}
}
// WithMaxProfileMetrics sets the maximum number of profile metrics to store
func WithMaxProfileMetrics(max int) Option {
return func(c *Config) {
c.MaxProfileMetrics = max
}
}
// WithDashboardAuth installs a custom authentication function for the dashboard.
// The function runs on every dashboard request and must return true to allow access.
func WithDashboardAuth(fn DashboardAuth) Option {
return func(c *Config) {
c.DashboardAuth = fn
}
}
// WithBasicAuth protects the dashboard with HTTP Basic Auth using a constant-time
// comparison. Both username and password are required.
func WithBasicAuth(username, password string) Option {
expectedUser := []byte(username)
expectedPass := []byte(password)
return func(c *Config) {
c.DashboardAuth = func(r *http.Request) bool {
user, pass, ok := r.BasicAuth()
if !ok {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(user), expectedUser) == 1
passOK := subtle.ConstantTimeCompare([]byte(pass), expectedPass) == 1
return userOK && passOK
}
}
}
// WithLocalhostOnly restricts the dashboard to requests originating from a
// loopback address. This is the default; the option exists so the intent can
// be stated explicitly.
func WithLocalhostOnly() Option {
return func(c *Config) {
c.LocalhostOnly = true
}
}
// WithAllowRemote lets non-loopback addresses reach the dashboard. Always pair
// it with WithBasicAuth or WithDashboardAuth; an open dashboard exposes every
// captured request and response body to whoever can reach the port. Dashboard
// replay also requires WithReplayBaseURL when remote access is enabled.
func WithAllowRemote() Option {
return func(c *Config) {
c.LocalhostOnly = false
}
}
// WithReplayEnabled enables the dashboard's /api/replay endpoint. Disabled by
// default because the endpoint, if reachable, lets a caller make the server
// perform arbitrary outbound HTTP requests (an SSRF primitive). Only enable
// behind authentication and/or localhost-only access.
func WithReplayEnabled(enabled bool) Option {
return func(c *Config) {
c.EnableReplay = enabled
}
}
// WithReplayBaseURL pins dashboard replays to the supplied application base
// URL. It is required with WithAllowRemote and is also useful behind a reverse
// proxy where the browser-facing dashboard origin is not directly reachable
// from the application process.
func WithReplayBaseURL(baseURL string) Option {
return func(c *Config) {
c.ReplayBaseURL = strings.TrimSuffix(baseURL, "/")
}
}
// WithSystemInfo enables the dashboard's /api/system-info endpoint and
// optionally sets the allowlist of environment variable names to expose.
// Pass no names to enable the endpoint but expose nothing (memory/runtime
// info only).
func WithSystemInfo(envAllowlist ...string) Option {
return func(c *Config) {
c.ExposeSystemInfo = true
c.ExposeEnvVars = append(c.ExposeEnvVars, envAllowlist...)
}
}
// WithShutdownContext wires govisual's internal cleanup (the storage
// backend) to a caller-provided context. When the context is
// cancelled, govisual releases its resources. Replaces the prior behavior of
// installing a global signal handler that called os.Exit.
//
// Note: govisual spawns one goroutine that blocks on ctx.Done() for the
// lifetime of the wrapped handler. If you never cancel the context (for
// example, by passing context.Background()), that goroutine is retained for
// the process lifetime — harmless in long-running services, but tests should
// pass a cancellable context (e.g. t.Context()) to avoid leaks across cases.
func WithShutdownContext(ctx context.Context) Option {
return func(c *Config) {
c.ShutdownContext = ctx
}
}
// defaultConfig returns the default configuration
func defaultConfig() *Config {
return &Config{
MaxRequests: 100,
DashboardPath: "/__viz",
LogRequestBody: false,
LogResponseBody: false,
MaxBodyBytes: 0, // 0 => use middleware.DefaultMaxBodyBytes
// Browser probes that would otherwise clutter every capture; users
// can extend this list via WithIgnorePaths.
IgnorePaths: []string{"/favicon.ico"},
SampleRate: 1,
EnableProfiling: false,
ProfileType: profiling.ProfileAll,
ProfileThreshold: 10 * time.Millisecond,
MaxProfileMetrics: 1000,
LocalhostOnly: true,
EnableReplay: false,
ExposeSystemInfo: false,
}
}