mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
1351 lines
38 KiB
Go
1351 lines
38 KiB
Go
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"
|
|
"time"
|
|
|
|
"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",
|
|
}
|
|
|
|
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",
|
|
},
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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 := parseOutboundURL(value, true); err != nil {
|
|
return fmt.Errorf("%s must be an absolute HTTPS URL", 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 := mail.ParseAddress(value); err != nil {
|
|
return fmt.Errorf("%s is not a valid email address", field)
|
|
}
|
|
}
|
|
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 _, 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" {
|
|
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)
|
|
}
|
|
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 {
|
|
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
|
|
continue
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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 "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 := parseOutboundURL(baseURL, true); err != nil {
|
|
return errors.New("telegram.base_url must be an absolute HTTPS URL")
|
|
}
|
|
}
|
|
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 {
|
|
baseURL := configString(config, "base_url")
|
|
if baseURL == "" {
|
|
baseURL = "https://api.telegram.org"
|
|
}
|
|
parsed, err := validateOutboundURL(ctx, baseURL, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := configString(config, "bot_token")
|
|
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/sendMessage"
|
|
parsed.RawPath = ""
|
|
parsed.RawQuery = ""
|
|
parsed.Fragment = ""
|
|
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 := mail.ParseAddress(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 := mail.ParseAddress(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)
|
|
}
|
|
message := strings.Join([]string{
|
|
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
|
"From: " + from.String(),
|
|
"To: " + joinMailAddresses(recipients),
|
|
"Subject: vocat notification test",
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/plain; charset=UTF-8",
|
|
"",
|
|
"This is a vocat notification test.",
|
|
"",
|
|
}, "\r\n")
|
|
if _, err := io.WriteString(writer, message); 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 joinMailAddresses(values []*mail.Address) string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
result = append(result, value.String())
|
|
}
|
|
return strings.Join(result, ", ")
|
|
}
|
|
|
|
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
|
|
}
|
|
dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)}
|
|
var failures []error
|
|
for _, ip := range addresses {
|
|
connection, err := dialer.DialContext(
|
|
ctx,
|
|
network,
|
|
net.JoinHostPort(ip.String(), port),
|
|
)
|
|
if err == nil {
|
|
return connection, nil
|
|
}
|
|
failures = append(failures, 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 deliberately puts the modem into RF-off mode while the SWu/IMS
|
|
// path owns service. That physical CFUN state is not the user's separate
|
|
// airplane-mode policy; exposing both toggles as enabled is contradictory
|
|
// and makes the UI unable to represent the active policy correctly.
|
|
return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, 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 = store.CardPolicy{
|
|
ICCID: iccid,
|
|
IPVersion: "IPV4V6",
|
|
Source: "default",
|
|
}
|
|
} 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"`
|
|
}
|
|
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 {
|
|
writeError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
"invalid_card_policy",
|
|
"all card policy switches are required",
|
|
)
|
|
return
|
|
}
|
|
request.APN = strings.TrimSpace(request.APN)
|
|
if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
|
|
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
|
|
return
|
|
}
|
|
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_card_policy",
|
|
"IP version must be IP, IPV6, or IPV4V6",
|
|
)
|
|
return
|
|
}
|
|
if *request.VoWiFiEnabled && *request.AirplaneEnabled {
|
|
writeError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
"invalid_card_policy",
|
|
"VoWiFi and airplane mode cannot both be enabled",
|
|
)
|
|
return
|
|
}
|
|
policy := store.CardPolicy{
|
|
ICCID: iccid,
|
|
NetworkEnabled: false,
|
|
VoWiFiEnabled: *request.VoWiFiEnabled,
|
|
AirplaneEnabled: *request.AirplaneEnabled,
|
|
APN: request.APN,
|
|
IPVersion: request.IPVersion,
|
|
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 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 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,
|
|
"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
|
|
}
|
|
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,
|
|
},
|
|
})
|
|
}
|