Files
silo-server/internal/diagnostics/settings.go
Quick104andClaude Fable 5 2d5d4980de fix(diagnostics): address round-2 review findings on PR #445
- 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
2026-07-21 11:29:02 -04:00

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
}