Files
2026-08-11 22:56:19 +08:00

1767 lines
53 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"
"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,
},
})
}