- settings.go: cap the parsed cleanup interval at 7 days before converting to time.Duration so a huge configured value can't overflow int64 nanoseconds and wrap into a tiny/negative interval; add boundary tests. - settings.go: propagate genuine settings read failures from LoadSettings (missing/empty -> default, error -> fail) so a transient DB error surfaces retryably instead of silently reporting uploads disabled or wrong quotas. - bundle.go: validate non-manifest bundle entries while streaming with bounded memory -- device.json and crash/*.json must be a single JSON object, logs.jsonl/breadcrumbs.jsonl must be newline-delimited JSON objects with a per-line byte cap (new contract.MaxLogLineBytes); binary members stay opaque. - diagnostics upload handler: extend the read deadline per-route via http.ResponseController.SetReadDeadline (10m) so slow mobile uploads of large bundles aren't cut off by the shared 30s server ReadTimeout. - web admin download: request the ?proxy=1 streaming path directly so downloads work when S3Private is only server-reachable and errors can surface in-page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
257 lines
8.4 KiB
Go
257 lines
8.4 KiB
Go
package diagnostics
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
KeyUploadsEnabled = "diagnostics.uploads_enabled"
|
|
KeyMaxBundleBytes = "diagnostics.max_bundle_bytes"
|
|
KeyMaxUncompressedBytes = "diagnostics.max_uncompressed_bytes"
|
|
KeyMaxReportsPerUserDay = "diagnostics.max_reports_per_user_per_day"
|
|
KeyRetentionDays = "diagnostics.retention_days"
|
|
KeyMaxBytesPerUser = "diagnostics.max_bytes_per_user"
|
|
KeyConsentNoticeVersion = "diagnostics.consent_notice_version"
|
|
KeyCleanupIntervalMinutes = "diagnostics.cleanup_interval_minutes"
|
|
KeyServerInstanceID = "diagnostics.server_instance_id"
|
|
DefaultUploadsEnabled = false
|
|
DefaultMaxBundleBytes = int64(10 * 1024 * 1024)
|
|
DefaultMaxUncompressed = int64(64 * 1024 * 1024)
|
|
DefaultMaxReportsPerDay = 20
|
|
DefaultRetentionDays = 30
|
|
DefaultMaxBytesPerUser = int64(200 * 1024 * 1024)
|
|
DefaultConsentNoticeVer = 1
|
|
// DefaultCleanupIntervalMinutes matches the prior opslog-shared cadence so
|
|
// splitting diagnostics onto its own key preserves current behavior.
|
|
DefaultCleanupIntervalMinutes = 15
|
|
// maxCleanupIntervalMinutes caps the parsed interval well below the point
|
|
// where minutes * time.Minute overflows int64 nanoseconds (~1.5e8 minutes,
|
|
// which would wrap a huge configured value into a tiny — or negative —
|
|
// duration). 7 days is far longer than any sane cleanup cadence.
|
|
maxCleanupIntervalMinutes = 7 * 24 * 60
|
|
defaultUploadsEnabledStr = "false"
|
|
defaultMaxBundleStr = "10485760"
|
|
defaultMaxUncompressed = "67108864"
|
|
defaultMaxReportsStr = "20"
|
|
defaultRetentionDaysStr = "30"
|
|
defaultMaxBytesUserStr = "209715200"
|
|
defaultConsentNoticeStr = "1"
|
|
defaultCleanupIntervalStr = "15"
|
|
)
|
|
|
|
// SettingsStore is the read/write surface over server_settings used by the
|
|
// diagnostics feature gate. catalog.ServerSettingsRepo satisfies it.
|
|
type SettingsStore interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
Set(ctx context.Context, key, value string) error
|
|
}
|
|
|
|
// conditionalSettingsStore is the optional insert-if-absent surface used to seed
|
|
// generated singletons (the server instance ID) without racing concurrent
|
|
// nodes. catalog.ServerSettingsRepo satisfies it.
|
|
type conditionalSettingsStore interface {
|
|
SetIfAbsent(ctx context.Context, key, value string) (bool, error)
|
|
}
|
|
|
|
type Settings struct {
|
|
UploadsEnabled bool
|
|
MaxBundleBytes int64
|
|
MaxUncompressedBytes int64
|
|
MaxReportsPerUserDay int
|
|
RetentionDays int
|
|
MaxBytesPerUser int64
|
|
ConsentNoticeVersion int
|
|
ServerInstanceID string
|
|
}
|
|
|
|
func SeedDefaults(ctx context.Context, store SettingsStore) error {
|
|
defaults := map[string]string{
|
|
KeyUploadsEnabled: defaultUploadsEnabledStr,
|
|
KeyMaxBundleBytes: defaultMaxBundleStr,
|
|
KeyMaxUncompressedBytes: defaultMaxUncompressed,
|
|
KeyMaxReportsPerUserDay: defaultMaxReportsStr,
|
|
KeyRetentionDays: defaultRetentionDaysStr,
|
|
KeyMaxBytesPerUser: defaultMaxBytesUserStr,
|
|
KeyConsentNoticeVersion: defaultConsentNoticeStr,
|
|
KeyCleanupIntervalMinutes: defaultCleanupIntervalStr,
|
|
}
|
|
for key, value := range defaults {
|
|
existing, err := store.Get(ctx, key)
|
|
if err != nil {
|
|
return fmt.Errorf("seed diagnostics defaults for %s: %w", key, err)
|
|
}
|
|
if existing != "" {
|
|
continue
|
|
}
|
|
if err := store.Set(ctx, key, value); err != nil {
|
|
return fmt.Errorf("seed diagnostics default %s: %w", key, err)
|
|
}
|
|
}
|
|
|
|
if _, err := ensureServerInstanceID(ctx, store); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureServerInstanceID returns the diagnostics server instance ID, generating
|
|
// and persisting one when absent. Seeding is atomic when the store supports
|
|
// insert-if-absent: concurrent nodes converge on the single winning value
|
|
// instead of overwriting each other (which would reject already-issued
|
|
// destination IDs as destination_mismatch).
|
|
func ensureServerInstanceID(ctx context.Context, store SettingsStore) (string, error) {
|
|
existing, err := store.Get(ctx, KeyServerInstanceID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
|
|
}
|
|
if id := strings.TrimSpace(existing); id != "" {
|
|
return id, nil
|
|
}
|
|
|
|
instanceID, err := newServerInstanceID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
conditional, ok := store.(conditionalSettingsStore)
|
|
if !ok {
|
|
if err := store.Set(ctx, KeyServerInstanceID, instanceID); err != nil {
|
|
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
|
|
}
|
|
return instanceID, nil
|
|
}
|
|
|
|
if _, err := conditional.SetIfAbsent(ctx, KeyServerInstanceID, instanceID); err != nil {
|
|
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
|
|
}
|
|
// Re-read so a node that lost the insert race adopts the winning value.
|
|
winner, err := store.Get(ctx, KeyServerInstanceID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
|
|
}
|
|
if id := strings.TrimSpace(winner); id != "" {
|
|
return id, nil
|
|
}
|
|
return instanceID, nil
|
|
}
|
|
|
|
// LoadCleanupInterval returns the configured diagnostics cleanup interval. It is
|
|
// intentionally independent of the opslog cleanup cadence so tuning one does not
|
|
// silently move the other.
|
|
func LoadCleanupInterval(ctx context.Context, store SettingsStore) time.Duration {
|
|
minutes := DefaultCleanupIntervalMinutes
|
|
if store == nil {
|
|
return time.Duration(minutes) * time.Minute
|
|
}
|
|
if raw, err := store.Get(ctx, KeyCleanupIntervalMinutes); err == nil && strings.TrimSpace(raw) != "" {
|
|
if parsed := parseInt(raw); parsed > 0 {
|
|
minutes = parsed
|
|
if minutes > maxCleanupIntervalMinutes {
|
|
minutes = maxCleanupIntervalMinutes
|
|
}
|
|
}
|
|
}
|
|
return time.Duration(minutes) * time.Minute
|
|
}
|
|
|
|
func DefaultSettings() Settings {
|
|
return Settings{
|
|
UploadsEnabled: DefaultUploadsEnabled,
|
|
MaxBundleBytes: DefaultMaxBundleBytes,
|
|
MaxUncompressedBytes: DefaultMaxUncompressed,
|
|
MaxReportsPerUserDay: DefaultMaxReportsPerDay,
|
|
RetentionDays: DefaultRetentionDays,
|
|
MaxBytesPerUser: DefaultMaxBytesPerUser,
|
|
ConsentNoticeVersion: DefaultConsentNoticeVer,
|
|
}
|
|
}
|
|
|
|
func LoadSettings(ctx context.Context, store SettingsStore) (Settings, error) {
|
|
settings := DefaultSettings()
|
|
if store == nil {
|
|
return settings, nil
|
|
}
|
|
// get distinguishes a genuine read failure — propagated so callers can
|
|
// surface a retryable error — from a missing or empty value, which falls
|
|
// back to the default. Silently defaulting on a transient DB error could
|
|
// report uploads disabled or the wrong quota limits to clients making
|
|
// decisions from /diagnostics/status.
|
|
get := func(key string) (string, error) {
|
|
raw, err := store.Get(ctx, key)
|
|
if err != nil {
|
|
return "", fmt.Errorf("load diagnostics setting %s: %w", key, err)
|
|
}
|
|
return strings.TrimSpace(raw), nil
|
|
}
|
|
|
|
if raw, err := get(KeyUploadsEnabled); err != nil {
|
|
return Settings{}, err
|
|
} else if raw != "" {
|
|
if parsed, parseErr := strconv.ParseBool(raw); parseErr == nil {
|
|
settings.UploadsEnabled = parsed
|
|
}
|
|
}
|
|
if raw, err := get(KeyMaxBundleBytes); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt64(raw); parsed > 0 {
|
|
settings.MaxBundleBytes = parsed
|
|
}
|
|
if raw, err := get(KeyMaxUncompressedBytes); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt64(raw); parsed > 0 {
|
|
settings.MaxUncompressedBytes = parsed
|
|
}
|
|
if raw, err := get(KeyMaxReportsPerUserDay); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt(raw); parsed > 0 {
|
|
settings.MaxReportsPerUserDay = parsed
|
|
}
|
|
if raw, err := get(KeyRetentionDays); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt(raw); parsed > 0 {
|
|
settings.RetentionDays = parsed
|
|
}
|
|
if raw, err := get(KeyMaxBytesPerUser); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt64(raw); parsed > 0 {
|
|
settings.MaxBytesPerUser = parsed
|
|
}
|
|
if raw, err := get(KeyConsentNoticeVersion); err != nil {
|
|
return Settings{}, err
|
|
} else if parsed := parseInt(raw); parsed > 0 {
|
|
settings.ConsentNoticeVersion = parsed
|
|
}
|
|
if raw, err := get(KeyServerInstanceID); err != nil {
|
|
return Settings{}, err
|
|
} else {
|
|
settings.ServerInstanceID = raw
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
func newServerInstanceID() (string, error) {
|
|
return uuid.NewString(), nil
|
|
}
|
|
|
|
func parseInt(s string) int {
|
|
v, err := strconv.Atoi(strings.TrimSpace(s))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func parseInt64(s string) int64 {
|
|
v, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|