package server import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/mail" "net/netip" "net/smtp" "net/url" "regexp" "sort" "strconv" "strings" "sync" "time" "vocat/internal/device" "vocat/internal/store" ) var ( errUnsafeDestination = errors.New("notification destination is not public") errProviderRejected = errors.New("notification provider rejected the test") telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`) ) var notificationChannels = []string{ "telegram", "email", "webhook", "bark", "pushplus", "wecom", } var notificationFields = map[string]map[string]string{ "telegram": { "bot_token": "string", "chat_id": "string", "admin_id": "string", "base_url": "string", "proxy": "string", }, "email": { "use_ssl": "boolean", "smtp_host": "string", "smtp_port": "integer", "username": "string", "password": "string", "from_address": "string", "to_addresses": "strings", }, "webhook": { "urls": "strings", "secret": "string", "timeout_ms": "integer", "retry_max": "integer", "text_template": "string", "headers": "string_map", }, "bark": { "urls": "strings", "group": "string", "icon": "string", "level": "string", }, "pushplus": { "token": "string", "topic": "string", "channel": "string", }, "wecom": { "urls": "strings", "payload_template": "string", }, } // routeSettingsAPI is intentionally independent of the main router so it can // be wired after the surrounding authentication and CSRF checks. func (s *Server) routeSettingsAPI( w http.ResponseWriter, r *http.Request, cleanPath string, ) bool { cleanPath = strings.Trim(cleanPath, "/") switch cleanPath { case "settings/notifications": s.handleNotificationSettings(w, r) return true case "traffic/analysis": s.handleTrafficAnalysis(w, r) return true case "cards/policies": s.handleCardPolicies(w, r) return true case "settings/security": s.handleSecuritySettings(w, r) return true case "settings/logging": s.handleLoggingSettings(w, r) return true } segments := splitAPIPath(cleanPath) if len(segments) == 4 && segments[0] == "settings" && segments[1] == "notifications" && segments[3] == "test" { s.handleNotificationTest(w, r, segments[2]) return true } if len(segments) == 3 && segments[0] == "cards" && segments[2] == "policy" { s.handleCardPolicy(w, r, segments[1]) return true } if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" { s.handleCardAPNProfiles(w, r, segments[1], "") return true } if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" { s.handleCardAPNProfiles(w, r, segments[1], segments[3]) return true } return false } func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.writeNotificationSettings(w, r) case http.MethodPut: var request map[string]json.RawMessage if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } if request == nil { writeError(w, http.StatusBadRequest, "invalid_request", "request body must be a JSON object") return } values := make([]store.NotificationSetting, 0, len(request)) for _, channel := range notificationChannels { raw, present := request[channel] if !present { continue } enabled, config, err := decodeNotificationConfig(channel, raw, true) if err != nil { writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } values = append(values, store.NotificationSetting{ Channel: channel, Enabled: enabled, Config: config, SensitiveFields: store.DefaultNotificationSensitiveFields(channel), }) } for channel := range request { if !knownNotificationChannel(channel) { writeError( w, http.StatusBadRequest, "invalid_notification_channel", fmt.Sprintf("unsupported notification channel %q", channel), ) return } } if err := s.store.SaveNotificationSettings(r.Context(), values); err != nil { s.writeStoreError(w, err) return } s.writeNotificationSettings(w, r) default: w.Header().Set("Allow", "GET, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } func (s *Server) writeNotificationSettings(w http.ResponseWriter, r *http.Request) { settings, err := s.store.ListNotificationSettings(r.Context()) if err != nil { s.writeStoreError(w, err) return } stored := make(map[string]store.NotificationSetting, len(settings)) for _, setting := range settings { stored[setting.Channel] = setting } response := make(map[string]any, len(notificationChannels)) for _, channel := range notificationChannels { document := map[string]any{"enabled": false} if setting, ok := stored[channel]; ok { redacted := setting.Redacted() if err := json.Unmarshal(redacted.Config, &document); err != nil { s.logger.Error( "notification setting contains invalid JSON", "channel", channel, "error", err, ) writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed") return } document["enabled"] = setting.Enabled } response[channel] = document } writeJSON(w, http.StatusOK, map[string]any{"data": response}) } func decodeNotificationConfig( channel string, raw json.RawMessage, requireEnabled bool, ) (bool, json.RawMessage, error) { if !knownNotificationChannel(channel) { return false, nil, fmt.Errorf("unsupported notification channel %q", channel) } var document map[string]json.RawMessage if err := json.Unmarshal(raw, &document); err != nil || document == nil { return false, nil, fmt.Errorf("%s notification config must be an object", channel) } enabled := false enabledRaw, hasEnabled := document["enabled"] if requireEnabled && !hasEnabled { return false, nil, fmt.Errorf("%s.enabled is required", channel) } if hasEnabled { if err := json.Unmarshal(enabledRaw, &enabled); err != nil { return false, nil, fmt.Errorf("%s.enabled must be a boolean", channel) } delete(document, "enabled") } fields := notificationFields[channel] for name, value := range document { kind, known := fields[name] if !known { return false, nil, fmt.Errorf("%s.%s is not supported", channel, name) } if err := validateNotificationField(channel, name, kind, value); err != nil { return false, nil, err } } config, err := json.Marshal(document) if err != nil { return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err) } return enabled, config, nil } func validateNotificationField( channel string, name string, kind string, raw json.RawMessage, ) error { field := channel + "." + name switch kind { case "boolean": var value bool if err := json.Unmarshal(raw, &value); err != nil { return fmt.Errorf("%s must be a boolean", field) } case "string": var value string if err := json.Unmarshal(raw, &value); err != nil { return fmt.Errorf("%s must be a string", field) } limit := 4096 if name == "text_template" { limit = 32768 } if len(value) > limit || strings.ContainsAny(value, "\x00") { return fmt.Errorf("%s is too long or contains invalid characters", field) } if name == "base_url" && value != "" { if _, err := telegramAPIURL(value, "123456:validation-token", "sendMessage"); err != nil { return fmt.Errorf("%s must be an absolute HTTPS URL or a URL template with two %%s placeholders", field) } } if name == "proxy" && value != "" { if _, err := parseOutboundURL(value, false); err != nil { return fmt.Errorf("%s is not a valid HTTP URL", field) } } if channel == "telegram" && name == "chat_id" && strings.TrimSpace(value) != "" { chatID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil || chatID == 0 { return fmt.Errorf("%s must be a non-zero integer", field) } } if channel == "telegram" && name == "admin_id" && strings.TrimSpace(value) != "" { adminID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil || adminID <= 0 { return fmt.Errorf("%s must be a positive integer", field) } } if name == "from_address" && value != "" { if _, err := parseMailAddress(value); err != nil { return fmt.Errorf("%s is not a valid email address", field) } } if channel == "wecom" && name == "payload_template" && value != "" { if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil { return fmt.Errorf("%s is not a valid JSON template: %w", field, err) } } case "integer": var value int if err := json.Unmarshal(raw, &value); err != nil { return fmt.Errorf("%s must be an integer", field) } switch name { case "smtp_port": if value < 0 || value > 65535 { return fmt.Errorf("%s must be between 0 and 65535", field) } case "timeout_ms": if value != 0 && (value < 100 || value > 60000) { return fmt.Errorf("%s must be 0 or between 100 and 60000", field) } case "retry_max": if value < 0 || value > 10 { return fmt.Errorf("%s must be between 0 and 10", field) } } case "strings": var values []string if err := json.Unmarshal(raw, &values); err != nil { return fmt.Errorf("%s must be an array of strings", field) } if len(values) > 32 { return fmt.Errorf("%s cannot contain more than 32 values", field) } for _, value := range values { if strings.TrimSpace(value) == "" || len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") { return fmt.Errorf("%s contains an invalid value", field) } if name == "urls" { if channel == "wecom" && value == store.SecretMask { continue } if _, err := parseOutboundURL(value, false); err != nil { return fmt.Errorf("%s contains an invalid HTTP URL", field) } } if name == "to_addresses" { if _, err := mail.ParseAddress(value); err != nil { return fmt.Errorf("%s contains an invalid email address", field) } } } case "string_map": var values map[string]string if err := json.Unmarshal(raw, &values); err != nil { return fmt.Errorf("%s must be an object of strings", field) } if len(values) > 32 { return fmt.Errorf("%s cannot contain more than 32 entries", field) } for key, value := range values { if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsAny(key, "\r\n:\x00") { return fmt.Errorf("%s contains an invalid header name", field) } if len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") { return fmt.Errorf("%s contains an invalid header value", field) } } default: return fmt.Errorf("%s has an unsupported field type", field) } return nil } func knownNotificationChannel(channel string) bool { _, ok := notificationFields[channel] return ok } func (s *Server) handleNotificationTest( w http.ResponseWriter, r *http.Request, channel string, ) { if !requireMethod(w, r, http.MethodPost) { return } channel = strings.ToLower(strings.TrimSpace(channel)) if !knownNotificationChannel(channel) { writeError(w, http.StatusNotFound, "not_found", "notification channel was not found") return } if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" { writeError( w, http.StatusNotImplemented, "notification_test_unsupported", "this notification channel does not support a connectivity test", ) return } var raw json.RawMessage if err := s.decodeJSON(w, r, &raw); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } _, incoming, err := decodeNotificationConfig(channel, raw, false) if err != nil { writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } resolved, provider, err := s.resolveNotificationTestConfig( r.Context(), channel, incoming, ) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError( w, http.StatusBadRequest, "notification_not_configured", "notification channel is not configured", ) return } s.writeStoreError(w, err) return } if err := validateNotificationTestConfig(channel, resolved); err != nil { writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } switch channel { case "webhook": err = sendWebhookNotificationTest(r.Context(), resolved) case "telegram": err = sendTelegramNotificationTest(r.Context(), resolved) case "email": err = sendEmailNotificationTest(r.Context(), resolved) case "bark": err = sendBarkNotificationTest(r.Context(), resolved) case "wecom": err = sendWecomNotificationTest(r.Context(), resolved) } if err != nil { redacted := store.RedactText(err.Error(), provider) if s.logger != nil { s.logger.Warn( "notification connectivity test failed", "channel", channel, "error", redacted, ) } switch { case errors.Is(err, errUnsafeDestination): writeError( w, http.StatusBadRequest, "unsafe_destination", "notification destination must resolve only to public network addresses", ) case errors.Is(err, errProviderRejected): writeError( w, http.StatusBadGateway, "notification_provider_rejected", "notification provider rejected the test message", ) default: writeError( w, http.StatusBadGateway, "notification_test_failed", "notification provider could not be reached or the test message failed", ) } return } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "channel": channel, "success": true, "tested_at": time.Now().UTC(), }, }) } func (s *Server) resolveNotificationTestConfig( ctx context.Context, channel string, incoming json.RawMessage, ) (map[string]any, store.NotificationSetting, error) { current, err := s.store.NotificationSetting(ctx, channel) notConfigured := errors.Is(err, store.ErrNotFound) if err != nil && !errors.Is(err, store.ErrNotFound) { return nil, store.NotificationSetting{}, err } if notConfigured { current = store.NotificationSetting{ Channel: channel, Config: json.RawMessage(`{}`), SensitiveFields: store.DefaultNotificationSensitiveFields(channel), } } var resolved map[string]any if err := json.Unmarshal(current.Config, &resolved); err != nil { return nil, store.NotificationSetting{}, fmt.Errorf("decode stored notification config: %w", err) } var overlay map[string]any if err := json.Unmarshal(incoming, &overlay); err != nil { return nil, store.NotificationSetting{}, fmt.Errorf("decode notification test config: %w", err) } sensitive := make(map[string]struct{}) for _, field := range store.DefaultNotificationSensitiveFields(channel) { sensitive[field] = struct{}{} } for key, value := range overlay { if _, secret := sensitive[key]; secret { value = mergeNotificationTestSecretValue(value, resolved[key]) } resolved[key] = value } encoded, err := json.Marshal(resolved) if err != nil { return nil, store.NotificationSetting{}, err } if len(resolved) == 0 && notConfigured { return nil, store.NotificationSetting{}, store.ErrNotFound } _, normalized, err := decodeNotificationConfig(channel, encoded, false) if err != nil { return nil, store.NotificationSetting{}, err } if err := json.Unmarshal(normalized, &resolved); err != nil { return nil, store.NotificationSetting{}, err } provider := store.NotificationSetting{ Channel: channel, Config: normalized, SensitiveFields: store.DefaultNotificationSensitiveFields(channel), } return resolved, provider, nil } // mergeNotificationTestSecretValue preserves masked values submitted by the // settings form while allowing newly entered sensitive values in the same // request. WeCom URLs are a sensitive list, unlike the string-based secrets // used by the other notification channels. func mergeNotificationTestSecretValue(incoming, existing any) any { if incoming == nil { return existing } switch next := incoming.(type) { case string: if next == "" || next == store.SecretMask { return existing } case []any: previous, ok := existing.([]any) if !ok { return incoming } merged := make([]any, len(next)) for index, value := range next { if index < len(previous) { merged[index] = mergeNotificationTestSecretValue(value, previous[index]) } else { merged[index] = value } } return merged } return incoming } func validateNotificationTestConfig(channel string, config map[string]any) error { switch channel { case "webhook": urls := configStrings(config, "urls") if len(urls) == 0 { return errors.New("webhook.urls must contain at least one URL") } if len(urls) > 8 { return errors.New("webhook test is limited to 8 URLs") } case "bark": urls := configStrings(config, "urls") if len(urls) == 0 { return errors.New("bark.urls must contain at least one URL") } if len(urls) > 8 { return errors.New("bark test is limited to 8 URLs") } case "wecom": return validateWecomNotificationConfig(config) case "telegram": token := configString(config, "bot_token") if token == "" || token == store.SecretMask { return errors.New("telegram.bot_token is required") } if !telegramTokenPattern.MatchString(token) { return errors.New("telegram.bot_token has an invalid format") } if configString(config, "chat_id") == "" { return errors.New("telegram.chat_id is required") } if baseURL := configString(config, "base_url"); baseURL != "" { if _, err := telegramAPIURL(baseURL, token, "sendMessage"); err != nil { return errors.New("telegram.base_url must be an absolute HTTPS URL or a URL template with two %s placeholders") } } case "email": if configString(config, "smtp_host") == "" { return errors.New("email.smtp_host is required") } if configString(config, "from_address") == "" { return errors.New("email.from_address is required") } if len(configStrings(config, "to_addresses")) == 0 { return errors.New("email.to_addresses must contain at least one address") } if configString(config, "password") != "" && configString(config, "username") == "" { return errors.New("email.username is required when a password is configured") } } return nil } func sendWebhookNotificationTest(ctx context.Context, config map[string]any) error { timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second) client, err := restrictedHTTPClient(ctx, timeout, "") if err != nil { return err } payload, _ := json.Marshal(map[string]any{ "event": "test", "message": "vocat notification test", "timestamp": time.Now().UTC().Format(time.RFC3339), }) for _, destination := range configStrings(config, "urls") { parsed, err := validateOutboundURL(ctx, destination, false) if err != nil { return err } request, err := http.NewRequestWithContext( ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload), ) if err != nil { return fmt.Errorf("create webhook test request: %w", err) } for name, value := range configStringMap(config, "headers") { request.Header.Set(name, value) } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "vocat-notification-test/1") if secret := configString(config, "secret"); secret != "" { signature := hmac.New(sha256.New, []byte(secret)) _, _ = signature.Write(payload) request.Header.Set( "X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)), ) } if err := performNotificationRequest(client, request, false); err != nil { return err } } return nil } func sendBarkNotificationTest(ctx context.Context, config map[string]any) error { client, err := restrictedHTTPClient(ctx, 6*time.Second, "") if err != nil { return err } message := map[string]any{ "title": "vocat", "body": "vocat notification test", } if group := configString(config, "group"); group != "" { message["group"] = group } if icon := configString(config, "icon"); icon != "" { message["icon"] = icon } if level := configString(config, "level"); level != "" { message["level"] = level } payload, _ := json.Marshal(message) for _, destination := range configStrings(config, "urls") { parsed, err := validateOutboundURL(ctx, destination, false) if err != nil { return err } request, err := http.NewRequestWithContext( ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload), ) if err != nil { return fmt.Errorf("create bark test request: %w", err) } request.Header.Set("Content-Type", "application/json; charset=utf-8") request.Header.Set("User-Agent", "vocat-notification-test/1") if err := performNotificationRequest(client, request, false); err != nil { return err } } return nil } func sendTelegramNotificationTest(ctx context.Context, config map[string]any) error { token := configString(config, "bot_token") parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage") if err != nil { return err } client, err := restrictedHTTPClient(ctx, 6*time.Second, configString(config, "proxy")) if err != nil { return err } payload, _ := json.Marshal(map[string]any{ "chat_id": configString(config, "chat_id"), "text": "vocat notification test", }) request, err := http.NewRequestWithContext( ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload), ) if err != nil { return fmt.Errorf("create Telegram test request: %w", err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "vocat-notification-test/1") return performNotificationRequest(client, request, true) } func performNotificationRequest( client *http.Client, request *http.Request, requireTelegramOK bool, ) error { response, err := client.Do(request) if err != nil { return fmt.Errorf("send notification test: %w", err) } defer response.Body.Close() body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) if readErr != nil { return fmt.Errorf("read notification response: %w", readErr) } if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf("%w: HTTP %d", errProviderRejected, response.StatusCode) } if requireTelegramOK { var result struct { OK bool `json:"ok"` } if json.Unmarshal(body, &result) != nil || !result.OK { return fmt.Errorf("%w: Telegram response was not successful", errProviderRejected) } } return nil } func sendEmailNotificationTest(ctx context.Context, config map[string]any) error { host := strings.TrimSpace(configString(config, "smtp_host")) port := configInt(config, "smtp_port") if port == 0 { port = 587 } timeout := 8 * time.Second address := net.JoinHostPort(host, strconv.Itoa(port)) connection, err := dialRestricted(ctx, "tcp", address, timeout) if err != nil { return fmt.Errorf("connect SMTP server: %w", err) } defer connection.Close() if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil { return fmt.Errorf("set SMTP deadline: %w", err) } tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS12, ServerName: host, } if port == 465 { secure := tls.Client(connection, tlsConfig) if err := secure.HandshakeContext(ctx); err != nil { return fmt.Errorf("establish SMTP TLS: %w", err) } connection = secure } client, err := smtp.NewClient(connection, host) if err != nil { return fmt.Errorf("start SMTP session: %w", err) } defer client.Close() if port != 465 { if available, _ := client.Extension("STARTTLS"); !available { return errors.New("SMTP server does not offer STARTTLS") } if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("start SMTP TLS: %w", err) } } username := configString(config, "username") password := configString(config, "password") if username != "" { if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil { return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected) } } from, err := parseMailAddress(configString(config, "from_address")) if err != nil { return fmt.Errorf("parse sender address: %w", err) } recipients := make([]*mail.Address, 0) for _, item := range configStrings(config, "to_addresses") { address, err := parseMailAddress(item) if err != nil { return fmt.Errorf("parse recipient address: %w", err) } recipients = append(recipients, address) } if err := client.Mail(from.Address); err != nil { return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected) } for _, recipient := range recipients { if err := client.Rcpt(recipient.Address); err != nil { return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected) } } writer, err := client.Data() if err != nil { return fmt.Errorf("%w: SMTP message rejected", errProviderRejected) } // Addresses are parsed as RFC mailboxes, the subject rejects control // characters, and the body is MIME-base64 encoded by writePlainTextMail. // CodeQL's email-injection query has no sanitizer model for these steps. // Keep this call on one source line: CodeQL reports the interprocedural sink // at the writer argument, and suppression comments bind to that exact line. // codeql[go/email-injection] if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil { _ = writer.Close() return fmt.Errorf("write SMTP test message: %w", err) } if err := writer.Close(); err != nil { return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected) } if err := client.Quit(); err != nil { return fmt.Errorf("finish SMTP session: %w", err) } return nil } func parseMailAddress(value string) (*mail.Address, error) { value = strings.TrimSpace(value) if value == "" || strings.ContainsAny(value, "\r\n\x00") { return nil, errors.New("email address contains a prohibited control character") } address, err := mail.ParseAddress(value) if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") { return nil, errors.New("invalid email address") } for _, character := range address.Name { if character < 0x20 || character == 0x7f { return nil, errors.New("email display name contains a prohibited control character") } } return address, nil } func formatMailAddress(address *mail.Address) string { if address.Name == "" { return address.Address } return (&mail.Address{Name: address.Name, Address: address.Address}).String() } func restrictedHTTPClient( ctx context.Context, timeout time.Duration, proxy string, ) (*http.Client, error) { timeout = clampNotificationTimeout(timeout) transport := &http.Transport{ Proxy: nil, DialContext: restrictedDialer(timeout), ForceAttemptHTTP2: true, DisableKeepAlives: true, MaxIdleConns: 0, TLSHandshakeTimeout: timeout, ResponseHeaderTimeout: timeout, ExpectContinueTimeout: time.Second, TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, }, } if strings.TrimSpace(proxy) != "" { parsed, err := validateOutboundURL(ctx, proxy, false) if err != nil { return nil, fmt.Errorf("validate notification proxy: %w", err) } transport.Proxy = http.ProxyURL(parsed) } return &http.Client{ Transport: transport, Timeout: timeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errors.New("notification provider redirects are not allowed") }, }, nil } func clampNotificationTimeout(timeout time.Duration) time.Duration { if timeout < 100*time.Millisecond { return 100 * time.Millisecond } if timeout > 10*time.Second { return 10 * time.Second } return timeout } func durationMilliseconds(value int, fallback time.Duration) time.Duration { if value == 0 { return fallback } return time.Duration(value) * time.Millisecond } func validateOutboundURL( ctx context.Context, raw string, requireHTTPS bool, ) (*url.URL, error) { parsed, err := parseOutboundURL(raw, requireHTTPS) if err != nil { return nil, err } if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil { return nil, err } return parsed, nil } func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false { return nil, errors.New("destination must be an absolute HTTP URL") } if parsed.User != nil { return nil, errors.New("destination URL cannot contain user information") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, errors.New("destination URL must use HTTP or HTTPS") } if requireHTTPS && parsed.Scheme != "https" { return nil, errors.New("destination URL must use HTTPS") } if parsed.Port() != "" { port, err := strconv.Atoi(parsed.Port()) if err != nil || port < 1 || port > 65535 { return nil, errors.New("destination URL has an invalid port") } } return parsed, nil } func restrictedDialer(timeout time.Duration) func( context.Context, string, string, ) (net.Conn, error) { return func(ctx context.Context, network string, address string) (net.Conn, error) { return dialRestricted(ctx, network, address, timeout) } } func dialRestricted( ctx context.Context, network string, address string, timeout time.Duration, ) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("parse outbound address: %w", err) } addresses, err := resolvePublicAddresses(ctx, host) if err != nil { return nil, err } perAddress := clampNotificationTimeout(timeout) stagger := 300 * time.Millisecond if perAddress < stagger { stagger = perAddress / 2 } raceContext, cancel := context.WithCancel(ctx) defer cancel() type attempt struct { conn net.Conn err error } resultCh := make(chan attempt, len(addresses)) var wg sync.WaitGroup launcher := time.NewTicker(stagger) defer launcher.Stop() for index, ip := range addresses { if index > 0 { select { case <-raceContext.Done(): break case <-launcher.C: } } if raceContext.Err() != nil { break } ip := ip wg.Add(1) go func() { defer wg.Done() dialer := net.Dialer{Timeout: perAddress} connection, dialErr := dialer.DialContext( raceContext, network, net.JoinHostPort(ip.String(), port), ) if dialErr != nil { resultCh <- attempt{err: dialErr} return } if raceContext.Err() != nil { connection.Close() resultCh <- attempt{err: raceContext.Err()} return } resultCh <- attempt{conn: connection} }() } go func() { wg.Wait() close(resultCh) }() var failures []error for result := range resultCh { if result.conn != nil { cancel() return result.conn, nil } if result.err != nil && !errors.Is(result.err, context.Canceled) { failures = append(failures, result.err) } if ctx.Err() != nil { return nil, ctx.Err() } } if len(failures) == 0 { return nil, ctx.Err() } return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...)) } func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) { normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) if normalized == "" || normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") || normalized == "metadata" || strings.HasSuffix(normalized, ".internal") || strings.HasSuffix(normalized, ".local") { return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination) } if literal, err := netip.ParseAddr(normalized); err == nil { literal = literal.Unmap() if !publicNotificationAddress(literal) { return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal) } return []netip.Addr{literal}, nil } addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", normalized) if err != nil { return nil, fmt.Errorf("resolve notification destination: %w", err) } if len(addresses) == 0 { return nil, errors.New("notification destination did not resolve") } result := make([]netip.Addr, 0, len(addresses)) for _, address := range addresses { address = address.Unmap() if !publicNotificationAddress(address) { return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address) } result = append(result, address) } return result, nil } var blockedNotificationNetworks = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("224.0.0.0/4"), netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("::/128"), netip.MustParsePrefix("::1/128"), netip.MustParsePrefix("64:ff9b:1::/48"), netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("ff00::/8"), } func publicNotificationAddress(address netip.Addr) bool { if !address.IsValid() || !address.IsGlobalUnicast() { return false } address = address.Unmap() for _, blocked := range blockedNotificationNetworks { if blocked.Contains(address) { return false } } return true } func configString(config map[string]any, key string) string { value, _ := config[key].(string) return strings.TrimSpace(value) } func configStrings(config map[string]any, key string) []string { switch value := config[key].(type) { case []string: return value case []any: result := make([]string, 0, len(value)) for _, item := range value { text, ok := item.(string) if ok { result = append(result, strings.TrimSpace(text)) } } return result default: return nil } } func configStringMap(config map[string]any, key string) map[string]string { object, ok := config[key].(map[string]any) if !ok { return nil } result := make(map[string]string, len(object)) for name, value := range object { text, ok := value.(string) if ok { result[name] = text } } return result } func configInt(config map[string]any, key string) int { switch value := config[key].(type) { case float64: return int(value) case json.Number: result, _ := value.Int64() return int(result) case int: return value default: return 0 } } // handleCardPolicies returns every stored card policy (VoHive: GET /cards/policies). func (s *Server) handleCardPolicies(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodGet) { return } policies, err := s.store.ListCardPolicies(r.Context()) if err != nil { s.writeStoreError(w, err) return } result := make([]map[string]any, 0, len(policies)) for _, policy := range policies { result = append(result, cardPolicyResponse(policy)) } writeJSON(w, http.StatusOK, map[string]any{"data": result}) } // liveCardPolicyFlags resolves the current VoWiFi/airplane state for the // device that presently hosts the given SIM (matched by live ICCID), so the card // policy toggles reflect what the card is actually doing now rather than a stale // stored value. ok is false when no present device reports this ICCID. func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi, airplane, ok bool) { configs, err := s.store.ListDevices(ctx) if err != nil { return false, false, false } clean := strings.TrimSpace(iccid) for _, config := range configs { entry, _, present := s.physicalForConfig(config) if !present || entry.Snapshot == nil { continue } if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) { continue } // VoWiFi is an RF-off service mode. Surface that fact explicitly: while // VoWiFi is selected both switches are on, but the airplane switch is // read-only in the UI. Once VoWiFi is disabled, airplane remains on until // the user explicitly turns it off. return config.VoWiFiEnabled, config.VoWiFiEnabled || entry.Snapshot.FlightMode, true } return false, false, false } func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid string) { iccid = strings.TrimSpace(iccid) if !validICCID(iccid) { writeError( w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits", ) return } switch r.Method { case http.MethodGet: policy, err := s.store.CardPolicy(r.Context(), iccid) if errors.Is(err, store.ErrNotFound) { policy = defaultCardPolicy(iccid) } else if err != nil { s.writeStoreError(w, err) return } // Reflect the SIM's live current state in the toggles (APN / IP version // remain stored preferences); fall back to the stored policy when the card // is not currently present in any device. if vowifi, airplane, ok := s.liveCardPolicyFlags(r.Context(), iccid); ok { policy.VoWiFiEnabled = vowifi policy.AirplaneEnabled = airplane } writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) case http.MethodPut: var request struct { VoWiFiEnabled *bool `json:"vowifi_enabled"` AirplaneEnabled *bool `json:"airplane_enabled"` APN *string `json:"apn"` IPVersion *string `json:"ip_version"` CustomPhoneNumber *string `json:"custom_phone_number"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil && request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil { writeError( w, http.StatusBadRequest, "invalid_card_policy", "at least one card policy field is required", ) return } policy, err := s.store.CardPolicy(r.Context(), iccid) if errors.Is(err, store.ErrNotFound) { policy = defaultCardPolicy(iccid) } else if err != nil { s.writeStoreError(w, err) return } if request.APN != nil { apn := strings.TrimSpace(*request.APN) if !device.ValidAPN(apn) { writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN must contain only letters, digits, dots, underscores, or hyphens") return } policy.APN = apn } if request.IPVersion != nil { ipVersion := strings.ToUpper(strings.TrimSpace(*request.IPVersion)) if ipVersion == "" { ipVersion = "IPV4V6" } if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" { writeError( w, http.StatusBadRequest, "invalid_card_policy", "IP version must be IP, IPV6, or IPV4V6", ) return } policy.IPVersion = ipVersion } if request.CustomPhoneNumber != nil { phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber) if phoneErr != nil { writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error()) return } policy.CustomPhoneNumber = phoneNumber } if request.VoWiFiEnabled != nil { policy.VoWiFiEnabled = *request.VoWiFiEnabled } if request.AirplaneEnabled != nil { policy.AirplaneEnabled = *request.AirplaneEnabled } // VoWiFi always owns an RF-off modem. Store airplane=true even when an // older client omits that implication, so disabling VoWiFi cannot expose a // brief cellular attach window. if policy.VoWiFiEnabled { policy.AirplaneEnabled = true policy.NetworkEnabled = false } if policy.IPVersion == "" { policy.IPVersion = "IPV4V6" } policy.Source = "manual" if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { s.writeStoreError(w, err) return } policy, err = s.store.CardPolicy(r.Context(), iccid) if err != nil { s.writeStoreError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) default: w.Header().Set("Allow", "GET, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } func defaultCardPolicy(iccid string) store.CardPolicy { return store.CardPolicy{ ICCID: strings.TrimSpace(iccid), VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "default", } } type cardAPNProfilePayload struct { APN string `json:"apn"` Username string `json:"username"` Password *string `json:"password"` ClearPassword bool `json:"clear_password"` Proxy string `json:"proxy"` MCC string `json:"mcc"` MNC string `json:"mnc"` IPVersion string `json:"ip_version"` RoamingIPVersion string `json:"roaming_ip_version"` AuthType string `json:"auth_type"` } func (s *Server) decodeCardAPNProfilePayload(w http.ResponseWriter, r *http.Request) (cardAPNProfilePayload, bool) { var request cardAPNProfilePayload if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return request, false } request.APN = strings.TrimSpace(request.APN) if request.APN == "" || !device.ValidAPN(request.APN) { writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens") return request, false } request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion)) if request.IPVersion == "" { request.IPVersion = "IPV4V6" } if request.IPVersion != "IP" && request.IPVersion != "IPV6" && request.IPVersion != "IPV4V6" { writeError(w, http.StatusBadRequest, "invalid_ip_version", "IP version must be IP, IPV6, or IPV4V6") return request, false } request.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(request.RoamingIPVersion)) if request.RoamingIPVersion == "" { request.RoamingIPVersion = "IP" } if request.RoamingIPVersion != "IP" && request.RoamingIPVersion != "IPV6" && request.RoamingIPVersion != "IPV4V6" { writeError(w, http.StatusBadRequest, "invalid_roaming_ip_version", "roaming IP version must be IP, IPV6, or IPV4V6") return request, false } request.AuthType = strings.ToUpper(strings.TrimSpace(request.AuthType)) if request.AuthType == "" { request.AuthType = "NONE" } if request.AuthType != "NONE" && request.AuthType != "PAP" && request.AuthType != "CHAP" && request.AuthType != "PAP_OR_CHAP" { writeError(w, http.StatusBadRequest, "invalid_auth_type", "authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP") return request, false } request.Username = strings.TrimSpace(request.Username) request.Proxy = strings.TrimSpace(request.Proxy) request.MCC = strings.TrimSpace(request.MCC) request.MNC = strings.TrimSpace(request.MNC) password := "" if request.Password != nil { password = *request.Password } if !validAPNText(request.Username, 128) || !validAPNText(password, 128) || !validAPNText(request.Proxy, 255) { writeError(w, http.StatusBadRequest, "invalid_apn_credentials", "APN username, password, or proxy contains unsupported characters") return request, false } if request.MCC != "" && !decimalLength(request.MCC, 3, 3) { writeError(w, http.StatusBadRequest, "invalid_mcc", "MCC must contain exactly 3 digits") return request, false } if request.MNC != "" && !decimalLength(request.MNC, 2, 3) { writeError(w, http.StatusBadRequest, "invalid_mnc", "MNC must contain 2 or 3 digits") return request, false } return request, true } func (s *Server) handleCardAPNProfiles(w http.ResponseWriter, r *http.Request, iccid, profileID string) { iccid = strings.TrimSpace(iccid) if !validICCID(iccid) { writeError(w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits") return } if profileID != "" { id, err := strconv.ParseInt(profileID, 10, 64) if err != nil || id < 1 { writeError(w, http.StatusBadRequest, "invalid_apn_profile", "APN profile ID is invalid") return } profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid) if err != nil { s.writeStoreError(w, err) return } var existing store.CardAPNProfile for _, profile := range profiles { if profile.ID == id { existing = profile break } } if existing.ID == 0 { writeError(w, http.StatusNotFound, "apn_profile_not_found", "APN profile was not found") return } switch r.Method { case http.MethodDelete: if err := s.store.DeleteCardAPNProfile(r.Context(), iccid, id); err != nil { s.writeStoreError(w, err) return } policy, err := s.store.CardPolicy(r.Context(), iccid) if err == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) { policy.APN = "" policy.IPVersion = "IPV4V6" policy.Source = "manual" if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { s.writeStoreError(w, err) return } } else if err != nil && !errors.Is(err, store.ErrNotFound) { s.writeStoreError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "id": id}}) case http.MethodPatch, http.MethodPut: request, ok := s.decodeCardAPNProfilePayload(w, r) if !ok { return } password := existing.Password if request.ClearPassword { password = "" } else if request.Password != nil && *request.Password != "" { password = *request.Password } updated, err := s.store.UpdateCardAPNProfile(r.Context(), store.CardAPNProfile{ ID: id, ICCID: iccid, APN: request.APN, Username: request.Username, Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC, IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion, AuthType: request.AuthType, }) if err != nil { s.writeStoreError(w, err) return } policy, policyErr := s.store.CardPolicy(r.Context(), iccid) if policyErr == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) { policy.APN = updated.APN policy.IPVersion = updated.IPVersion policy.Source = "manual" if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { s.writeStoreError(w, err) return } } else if policyErr != nil && !errors.Is(policyErr, store.ErrNotFound) { s.writeStoreError(w, policyErr) return } writeJSON(w, http.StatusOK, map[string]any{"data": cardAPNProfileResponse(updated)}) default: w.Header().Set("Allow", "PATCH, PUT, DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return } switch r.Method { case http.MethodGet: profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid) if err != nil { s.writeStoreError(w, err) return } items := make([]map[string]any, 0, len(profiles)) for _, profile := range profiles { items = append(items, cardAPNProfileResponse(profile)) } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}}) case http.MethodPost: request, ok := s.decodeCardAPNProfilePayload(w, r) if !ok { return } if _, err := s.store.CardPolicy(r.Context(), iccid); errors.Is(err, store.ErrNotFound) { if err := s.store.UpsertCardPolicy(r.Context(), defaultCardPolicy(iccid)); err != nil { s.writeStoreError(w, err) return } } else if err != nil { s.writeStoreError(w, err) return } password := "" if request.Password != nil { password = *request.Password } profile, err := s.store.UpsertCardAPNProfile(r.Context(), store.CardAPNProfile{ ICCID: iccid, APN: request.APN, Username: request.Username, Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC, IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion, AuthType: request.AuthType, }) if err != nil { s.writeStoreError(w, err) return } writeJSON(w, http.StatusCreated, map[string]any{"data": cardAPNProfileResponse(profile)}) default: w.Header().Set("Allow", "GET, POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } func cardAPNProfileResponse(profile store.CardAPNProfile) map[string]any { return map[string]any{ "id": profile.ID, "iccid": profile.ICCID, "apn": profile.APN, "username": profile.Username, "has_password": profile.Password != "", "proxy": profile.Proxy, "mcc": profile.MCC, "mnc": profile.MNC, "ip_version": profile.IPVersion, "roaming_ip_version": profile.RoamingIPVersion, "auth_type": profile.AuthType, "created_at": profile.CreatedAt, "updated_at": profile.UpdatedAt, } } func validAPNText(value string, maxLength int) bool { if len(value) > maxLength || strings.ContainsAny(value, "\r\n\x00\"") { return false } for _, character := range value { if character < 0x20 || character == 0x7f { return false } } return true } func decimalLength(value string, minimum, maximum int) bool { if len(value) < minimum || len(value) > maximum { return false } for _, character := range value { if character < '0' || character > '9' { return false } } return true } func validICCID(value string) bool { if len(value) < 10 || len(value) > 32 { return false } for _, character := range value { if character < '0' || character > '9' { return false } } return true } func normalizeCustomPhoneNumber(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { return "", nil } var normalized strings.Builder digitCount := 0 for index, character := range value { switch { case character >= '0' && character <= '9': normalized.WriteRune(character) digitCount++ case character == '+' && index == 0: normalized.WriteRune(character) case character == ' ' || character == '-' || character == '(' || character == ')': // Common visual separators are accepted but not persisted. default: return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens") } } if digitCount < 3 || digitCount > 20 { return "", errors.New("custom phone number must contain between 3 and 20 digits") } return normalized.String(), nil } func cardPolicyResponse(policy store.CardPolicy) map[string]any { response := map[string]any{ "iccid": policy.ICCID, "network_enabled": false, "vowifi_enabled": policy.VoWiFiEnabled, "airplane_enabled": policy.AirplaneEnabled, "apn": policy.APN, "ip_version": policy.IPVersion, "custom_phone_number": policy.CustomPhoneNumber, "source": policy.Source, } if !policy.CreatedAt.IsZero() { response["created_at"] = policy.CreatedAt } if !policy.UpdatedAt.IsZero() { response["updated_at"] = policy.UpdatedAt } return response } func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodGet) { return } if !s.developerActive(r.Context()) { writeError(w, http.StatusForbidden, "developer_mode_required", "traffic analysis is available only in developer mode") return } rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range"))) if rangeName == "" { rangeName = "day" } var window time.Duration switch rangeName { case "hour": window = time.Hour case "day": window = 24 * time.Hour case "week": window = 7 * 24 * time.Hour case "month": window = 30 * 24 * time.Hour default: writeError( w, http.StatusBadRequest, "invalid_range", "traffic range must be hour, day, week, or month", ) return } deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) if len(deviceID) > 128 || strings.ContainsAny(deviceID, "\x00\r\n") { writeError(w, http.StatusBadRequest, "invalid_device", "device ID is invalid") return } now := time.Now().UTC() rows, err := s.store.ListTrafficBuckets(r.Context(), store.TrafficFilter{ DeviceID: deviceID, Bucket: rangeName, Since: now.Add(-window), Until: now.Add(time.Minute), Limit: 1000, }) if err != nil { s.writeStoreError(w, err) return } type aggregate struct { period time.Time rx int64 tx int64 } byPeriod := make(map[int64]*aggregate) for _, row := range rows { key := row.PeriodStart.Unix() value := byPeriod[key] if value == nil { value = &aggregate{period: row.PeriodStart} byPeriod[key] = value } value.rx += row.RXBytes value.tx += row.TXBytes } values := make([]*aggregate, 0, len(byPeriod)) for _, value := range byPeriod { values = append(values, value) } sort.Slice(values, func(left, right int) bool { return values[left].period.Before(values[right].period) }) buckets := make([]map[string]any, 0, len(values)) for _, value := range values { buckets = append(buckets, map[string]any{ "bucket": rangeName, "period_start": value.period, "rx_bytes": value.rx, "tx_bytes": value.tx, "total_bytes": value.rx + value.tx, }) } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "status": "ok", "range": rangeName, "buckets": buckets, }, }) }