settings_api.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350
  1. package server
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "crypto/tls"
  8. "encoding/hex"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "io"
  13. "net"
  14. "net/http"
  15. "net/mail"
  16. "net/netip"
  17. "net/smtp"
  18. "net/url"
  19. "regexp"
  20. "sort"
  21. "strconv"
  22. "strings"
  23. "time"
  24. "vocat/internal/store"
  25. )
  26. var (
  27. errUnsafeDestination = errors.New("notification destination is not public")
  28. errProviderRejected = errors.New("notification provider rejected the test")
  29. telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
  30. )
  31. var notificationChannels = []string{
  32. "telegram",
  33. "email",
  34. "webhook",
  35. "bark",
  36. "pushplus",
  37. }
  38. var notificationFields = map[string]map[string]string{
  39. "telegram": {
  40. "bot_token": "string", "chat_id": "string", "admin_id": "string",
  41. "base_url": "string", "proxy": "string",
  42. },
  43. "email": {
  44. "use_ssl": "boolean", "smtp_host": "string", "smtp_port": "integer", "username": "string",
  45. "password": "string", "from_address": "string", "to_addresses": "strings",
  46. },
  47. "webhook": {
  48. "urls": "strings", "secret": "string", "timeout_ms": "integer",
  49. "retry_max": "integer", "text_template": "string", "headers": "string_map",
  50. },
  51. "bark": {
  52. "urls": "strings", "group": "string", "icon": "string", "level": "string",
  53. },
  54. "pushplus": {
  55. "token": "string", "topic": "string", "channel": "string",
  56. },
  57. }
  58. // routeSettingsAPI is intentionally independent of the main router so it can
  59. // be wired after the surrounding authentication and CSRF checks.
  60. func (s *Server) routeSettingsAPI(
  61. w http.ResponseWriter,
  62. r *http.Request,
  63. cleanPath string,
  64. ) bool {
  65. cleanPath = strings.Trim(cleanPath, "/")
  66. switch cleanPath {
  67. case "settings/notifications":
  68. s.handleNotificationSettings(w, r)
  69. return true
  70. case "traffic/analysis":
  71. s.handleTrafficAnalysis(w, r)
  72. return true
  73. case "cards/policies":
  74. s.handleCardPolicies(w, r)
  75. return true
  76. case "settings/security":
  77. s.handleSecuritySettings(w, r)
  78. return true
  79. case "settings/logging":
  80. s.handleLoggingSettings(w, r)
  81. return true
  82. }
  83. segments := splitAPIPath(cleanPath)
  84. if len(segments) == 4 &&
  85. segments[0] == "settings" &&
  86. segments[1] == "notifications" &&
  87. segments[3] == "test" {
  88. s.handleNotificationTest(w, r, segments[2])
  89. return true
  90. }
  91. if len(segments) == 3 && segments[0] == "cards" && segments[2] == "policy" {
  92. s.handleCardPolicy(w, r, segments[1])
  93. return true
  94. }
  95. return false
  96. }
  97. func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Request) {
  98. switch r.Method {
  99. case http.MethodGet:
  100. s.writeNotificationSettings(w, r)
  101. case http.MethodPut:
  102. var request map[string]json.RawMessage
  103. if err := s.decodeJSON(w, r, &request); err != nil {
  104. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  105. return
  106. }
  107. if request == nil {
  108. writeError(w, http.StatusBadRequest, "invalid_request", "request body must be a JSON object")
  109. return
  110. }
  111. values := make([]store.NotificationSetting, 0, len(request))
  112. for _, channel := range notificationChannels {
  113. raw, present := request[channel]
  114. if !present {
  115. continue
  116. }
  117. enabled, config, err := decodeNotificationConfig(channel, raw, true)
  118. if err != nil {
  119. writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
  120. return
  121. }
  122. values = append(values, store.NotificationSetting{
  123. Channel: channel,
  124. Enabled: enabled,
  125. Config: config,
  126. SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
  127. })
  128. }
  129. for channel := range request {
  130. if !knownNotificationChannel(channel) {
  131. writeError(
  132. w,
  133. http.StatusBadRequest,
  134. "invalid_notification_channel",
  135. fmt.Sprintf("unsupported notification channel %q", channel),
  136. )
  137. return
  138. }
  139. }
  140. if err := s.store.SaveNotificationSettings(r.Context(), values); err != nil {
  141. s.writeStoreError(w, err)
  142. return
  143. }
  144. s.writeNotificationSettings(w, r)
  145. default:
  146. w.Header().Set("Allow", "GET, PUT")
  147. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  148. }
  149. }
  150. func (s *Server) writeNotificationSettings(w http.ResponseWriter, r *http.Request) {
  151. settings, err := s.store.ListNotificationSettings(r.Context())
  152. if err != nil {
  153. s.writeStoreError(w, err)
  154. return
  155. }
  156. stored := make(map[string]store.NotificationSetting, len(settings))
  157. for _, setting := range settings {
  158. stored[setting.Channel] = setting
  159. }
  160. response := make(map[string]any, len(notificationChannels))
  161. for _, channel := range notificationChannels {
  162. document := map[string]any{"enabled": false}
  163. if setting, ok := stored[channel]; ok {
  164. redacted := setting.Redacted()
  165. if err := json.Unmarshal(redacted.Config, &document); err != nil {
  166. s.logger.Error(
  167. "notification setting contains invalid JSON",
  168. "channel",
  169. channel,
  170. "error",
  171. err,
  172. )
  173. writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
  174. return
  175. }
  176. document["enabled"] = setting.Enabled
  177. }
  178. response[channel] = document
  179. }
  180. writeJSON(w, http.StatusOK, map[string]any{"data": response})
  181. }
  182. func decodeNotificationConfig(
  183. channel string,
  184. raw json.RawMessage,
  185. requireEnabled bool,
  186. ) (bool, json.RawMessage, error) {
  187. if !knownNotificationChannel(channel) {
  188. return false, nil, fmt.Errorf("unsupported notification channel %q", channel)
  189. }
  190. var document map[string]json.RawMessage
  191. if err := json.Unmarshal(raw, &document); err != nil || document == nil {
  192. return false, nil, fmt.Errorf("%s notification config must be an object", channel)
  193. }
  194. enabled := false
  195. enabledRaw, hasEnabled := document["enabled"]
  196. if requireEnabled && !hasEnabled {
  197. return false, nil, fmt.Errorf("%s.enabled is required", channel)
  198. }
  199. if hasEnabled {
  200. if err := json.Unmarshal(enabledRaw, &enabled); err != nil {
  201. return false, nil, fmt.Errorf("%s.enabled must be a boolean", channel)
  202. }
  203. delete(document, "enabled")
  204. }
  205. fields := notificationFields[channel]
  206. for name, value := range document {
  207. kind, known := fields[name]
  208. if !known {
  209. return false, nil, fmt.Errorf("%s.%s is not supported", channel, name)
  210. }
  211. if err := validateNotificationField(channel, name, kind, value); err != nil {
  212. return false, nil, err
  213. }
  214. }
  215. config, err := json.Marshal(document)
  216. if err != nil {
  217. return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
  218. }
  219. return enabled, config, nil
  220. }
  221. func validateNotificationField(
  222. channel string,
  223. name string,
  224. kind string,
  225. raw json.RawMessage,
  226. ) error {
  227. field := channel + "." + name
  228. switch kind {
  229. case "boolean":
  230. var value bool
  231. if err := json.Unmarshal(raw, &value); err != nil {
  232. return fmt.Errorf("%s must be a boolean", field)
  233. }
  234. case "string":
  235. var value string
  236. if err := json.Unmarshal(raw, &value); err != nil {
  237. return fmt.Errorf("%s must be a string", field)
  238. }
  239. limit := 4096
  240. if name == "text_template" {
  241. limit = 32768
  242. }
  243. if len(value) > limit || strings.ContainsAny(value, "\x00") {
  244. return fmt.Errorf("%s is too long or contains invalid characters", field)
  245. }
  246. if name == "base_url" && value != "" {
  247. if _, err := parseOutboundURL(value, true); err != nil {
  248. return fmt.Errorf("%s must be an absolute HTTPS URL", field)
  249. }
  250. }
  251. if name == "proxy" && value != "" {
  252. if _, err := parseOutboundURL(value, false); err != nil {
  253. return fmt.Errorf("%s is not a valid HTTP URL", field)
  254. }
  255. }
  256. if channel == "telegram" && name == "chat_id" && strings.TrimSpace(value) != "" {
  257. chatID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
  258. if err != nil || chatID == 0 {
  259. return fmt.Errorf("%s must be a non-zero integer", field)
  260. }
  261. }
  262. if channel == "telegram" && name == "admin_id" && strings.TrimSpace(value) != "" {
  263. adminID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
  264. if err != nil || adminID <= 0 {
  265. return fmt.Errorf("%s must be a positive integer", field)
  266. }
  267. }
  268. if name == "from_address" && value != "" {
  269. if _, err := mail.ParseAddress(value); err != nil {
  270. return fmt.Errorf("%s is not a valid email address", field)
  271. }
  272. }
  273. case "integer":
  274. var value int
  275. if err := json.Unmarshal(raw, &value); err != nil {
  276. return fmt.Errorf("%s must be an integer", field)
  277. }
  278. switch name {
  279. case "smtp_port":
  280. if value < 0 || value > 65535 {
  281. return fmt.Errorf("%s must be between 0 and 65535", field)
  282. }
  283. case "timeout_ms":
  284. if value != 0 && (value < 100 || value > 60000) {
  285. return fmt.Errorf("%s must be 0 or between 100 and 60000", field)
  286. }
  287. case "retry_max":
  288. if value < 0 || value > 10 {
  289. return fmt.Errorf("%s must be between 0 and 10", field)
  290. }
  291. }
  292. case "strings":
  293. var values []string
  294. if err := json.Unmarshal(raw, &values); err != nil {
  295. return fmt.Errorf("%s must be an array of strings", field)
  296. }
  297. if len(values) > 32 {
  298. return fmt.Errorf("%s cannot contain more than 32 values", field)
  299. }
  300. for _, value := range values {
  301. if strings.TrimSpace(value) == "" || len(value) > 4096 ||
  302. strings.ContainsAny(value, "\r\n\x00") {
  303. return fmt.Errorf("%s contains an invalid value", field)
  304. }
  305. if name == "urls" {
  306. if _, err := parseOutboundURL(value, false); err != nil {
  307. return fmt.Errorf("%s contains an invalid HTTP URL", field)
  308. }
  309. }
  310. if name == "to_addresses" {
  311. if _, err := mail.ParseAddress(value); err != nil {
  312. return fmt.Errorf("%s contains an invalid email address", field)
  313. }
  314. }
  315. }
  316. case "string_map":
  317. var values map[string]string
  318. if err := json.Unmarshal(raw, &values); err != nil {
  319. return fmt.Errorf("%s must be an object of strings", field)
  320. }
  321. if len(values) > 32 {
  322. return fmt.Errorf("%s cannot contain more than 32 entries", field)
  323. }
  324. for key, value := range values {
  325. if strings.TrimSpace(key) == "" || len(key) > 128 ||
  326. strings.ContainsAny(key, "\r\n:\x00") {
  327. return fmt.Errorf("%s contains an invalid header name", field)
  328. }
  329. if len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") {
  330. return fmt.Errorf("%s contains an invalid header value", field)
  331. }
  332. }
  333. default:
  334. return fmt.Errorf("%s has an unsupported field type", field)
  335. }
  336. return nil
  337. }
  338. func knownNotificationChannel(channel string) bool {
  339. _, ok := notificationFields[channel]
  340. return ok
  341. }
  342. func (s *Server) handleNotificationTest(
  343. w http.ResponseWriter,
  344. r *http.Request,
  345. channel string,
  346. ) {
  347. if !requireMethod(w, r, http.MethodPost) {
  348. return
  349. }
  350. channel = strings.ToLower(strings.TrimSpace(channel))
  351. if !knownNotificationChannel(channel) {
  352. writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
  353. return
  354. }
  355. if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
  356. writeError(
  357. w,
  358. http.StatusNotImplemented,
  359. "notification_test_unsupported",
  360. "this notification channel does not support a connectivity test",
  361. )
  362. return
  363. }
  364. var raw json.RawMessage
  365. if err := s.decodeJSON(w, r, &raw); err != nil {
  366. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  367. return
  368. }
  369. _, incoming, err := decodeNotificationConfig(channel, raw, false)
  370. if err != nil {
  371. writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
  372. return
  373. }
  374. resolved, provider, err := s.resolveNotificationTestConfig(
  375. r.Context(),
  376. channel,
  377. incoming,
  378. )
  379. if err != nil {
  380. if errors.Is(err, store.ErrNotFound) {
  381. writeError(
  382. w,
  383. http.StatusBadRequest,
  384. "notification_not_configured",
  385. "notification channel is not configured",
  386. )
  387. return
  388. }
  389. s.writeStoreError(w, err)
  390. return
  391. }
  392. if err := validateNotificationTestConfig(channel, resolved); err != nil {
  393. writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
  394. return
  395. }
  396. switch channel {
  397. case "webhook":
  398. err = sendWebhookNotificationTest(r.Context(), resolved)
  399. case "telegram":
  400. err = sendTelegramNotificationTest(r.Context(), resolved)
  401. case "email":
  402. err = sendEmailNotificationTest(r.Context(), resolved)
  403. case "bark":
  404. err = sendBarkNotificationTest(r.Context(), resolved)
  405. }
  406. if err != nil {
  407. redacted := store.RedactText(err.Error(), provider)
  408. if s.logger != nil {
  409. s.logger.Warn(
  410. "notification connectivity test failed",
  411. "channel",
  412. channel,
  413. "error",
  414. redacted,
  415. )
  416. }
  417. switch {
  418. case errors.Is(err, errUnsafeDestination):
  419. writeError(
  420. w,
  421. http.StatusBadRequest,
  422. "unsafe_destination",
  423. "notification destination must resolve only to public network addresses",
  424. )
  425. case errors.Is(err, errProviderRejected):
  426. writeError(
  427. w,
  428. http.StatusBadGateway,
  429. "notification_provider_rejected",
  430. "notification provider rejected the test message",
  431. )
  432. default:
  433. writeError(
  434. w,
  435. http.StatusBadGateway,
  436. "notification_test_failed",
  437. "notification provider could not be reached or the test message failed",
  438. )
  439. }
  440. return
  441. }
  442. writeJSON(w, http.StatusOK, map[string]any{
  443. "data": map[string]any{
  444. "channel": channel,
  445. "success": true,
  446. "tested_at": time.Now().UTC(),
  447. },
  448. })
  449. }
  450. func (s *Server) resolveNotificationTestConfig(
  451. ctx context.Context,
  452. channel string,
  453. incoming json.RawMessage,
  454. ) (map[string]any, store.NotificationSetting, error) {
  455. current, err := s.store.NotificationSetting(ctx, channel)
  456. notConfigured := errors.Is(err, store.ErrNotFound)
  457. if err != nil && !errors.Is(err, store.ErrNotFound) {
  458. return nil, store.NotificationSetting{}, err
  459. }
  460. if notConfigured {
  461. current = store.NotificationSetting{
  462. Channel: channel,
  463. Config: json.RawMessage(`{}`),
  464. SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
  465. }
  466. }
  467. var resolved map[string]any
  468. if err := json.Unmarshal(current.Config, &resolved); err != nil {
  469. return nil, store.NotificationSetting{}, fmt.Errorf("decode stored notification config: %w", err)
  470. }
  471. var overlay map[string]any
  472. if err := json.Unmarshal(incoming, &overlay); err != nil {
  473. return nil, store.NotificationSetting{}, fmt.Errorf("decode notification test config: %w", err)
  474. }
  475. sensitive := make(map[string]struct{})
  476. for _, field := range store.DefaultNotificationSensitiveFields(channel) {
  477. sensitive[field] = struct{}{}
  478. }
  479. for key, value := range overlay {
  480. if _, secret := sensitive[key]; secret {
  481. if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
  482. continue
  483. }
  484. }
  485. resolved[key] = value
  486. }
  487. encoded, err := json.Marshal(resolved)
  488. if err != nil {
  489. return nil, store.NotificationSetting{}, err
  490. }
  491. if len(resolved) == 0 && notConfigured {
  492. return nil, store.NotificationSetting{}, store.ErrNotFound
  493. }
  494. _, normalized, err := decodeNotificationConfig(channel, encoded, false)
  495. if err != nil {
  496. return nil, store.NotificationSetting{}, err
  497. }
  498. if err := json.Unmarshal(normalized, &resolved); err != nil {
  499. return nil, store.NotificationSetting{}, err
  500. }
  501. provider := store.NotificationSetting{
  502. Channel: channel,
  503. Config: normalized,
  504. SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
  505. }
  506. return resolved, provider, nil
  507. }
  508. func validateNotificationTestConfig(channel string, config map[string]any) error {
  509. switch channel {
  510. case "webhook":
  511. urls := configStrings(config, "urls")
  512. if len(urls) == 0 {
  513. return errors.New("webhook.urls must contain at least one URL")
  514. }
  515. if len(urls) > 8 {
  516. return errors.New("webhook test is limited to 8 URLs")
  517. }
  518. case "bark":
  519. urls := configStrings(config, "urls")
  520. if len(urls) == 0 {
  521. return errors.New("bark.urls must contain at least one URL")
  522. }
  523. if len(urls) > 8 {
  524. return errors.New("bark test is limited to 8 URLs")
  525. }
  526. case "telegram":
  527. token := configString(config, "bot_token")
  528. if token == "" || token == store.SecretMask {
  529. return errors.New("telegram.bot_token is required")
  530. }
  531. if !telegramTokenPattern.MatchString(token) {
  532. return errors.New("telegram.bot_token has an invalid format")
  533. }
  534. if configString(config, "chat_id") == "" {
  535. return errors.New("telegram.chat_id is required")
  536. }
  537. if baseURL := configString(config, "base_url"); baseURL != "" {
  538. if _, err := parseOutboundURL(baseURL, true); err != nil {
  539. return errors.New("telegram.base_url must be an absolute HTTPS URL")
  540. }
  541. }
  542. case "email":
  543. if configString(config, "smtp_host") == "" {
  544. return errors.New("email.smtp_host is required")
  545. }
  546. if configString(config, "from_address") == "" {
  547. return errors.New("email.from_address is required")
  548. }
  549. if len(configStrings(config, "to_addresses")) == 0 {
  550. return errors.New("email.to_addresses must contain at least one address")
  551. }
  552. if configString(config, "password") != "" && configString(config, "username") == "" {
  553. return errors.New("email.username is required when a password is configured")
  554. }
  555. }
  556. return nil
  557. }
  558. func sendWebhookNotificationTest(ctx context.Context, config map[string]any) error {
  559. timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
  560. client, err := restrictedHTTPClient(ctx, timeout, "")
  561. if err != nil {
  562. return err
  563. }
  564. payload, _ := json.Marshal(map[string]any{
  565. "event": "test",
  566. "message": "vocat notification test",
  567. "timestamp": time.Now().UTC().Format(time.RFC3339),
  568. })
  569. for _, destination := range configStrings(config, "urls") {
  570. parsed, err := validateOutboundURL(ctx, destination, false)
  571. if err != nil {
  572. return err
  573. }
  574. request, err := http.NewRequestWithContext(
  575. ctx,
  576. http.MethodPost,
  577. parsed.String(),
  578. bytes.NewReader(payload),
  579. )
  580. if err != nil {
  581. return fmt.Errorf("create webhook test request: %w", err)
  582. }
  583. for name, value := range configStringMap(config, "headers") {
  584. request.Header.Set(name, value)
  585. }
  586. request.Header.Set("Content-Type", "application/json")
  587. request.Header.Set("User-Agent", "vocat-notification-test/1")
  588. if secret := configString(config, "secret"); secret != "" {
  589. signature := hmac.New(sha256.New, []byte(secret))
  590. _, _ = signature.Write(payload)
  591. request.Header.Set(
  592. "X-vocat-Signature",
  593. "sha256="+hex.EncodeToString(signature.Sum(nil)),
  594. )
  595. }
  596. if err := performNotificationRequest(client, request, false); err != nil {
  597. return err
  598. }
  599. }
  600. return nil
  601. }
  602. func sendBarkNotificationTest(ctx context.Context, config map[string]any) error {
  603. client, err := restrictedHTTPClient(ctx, 6*time.Second, "")
  604. if err != nil {
  605. return err
  606. }
  607. message := map[string]any{
  608. "title": "vocat",
  609. "body": "vocat notification test",
  610. }
  611. if group := configString(config, "group"); group != "" {
  612. message["group"] = group
  613. }
  614. if icon := configString(config, "icon"); icon != "" {
  615. message["icon"] = icon
  616. }
  617. if level := configString(config, "level"); level != "" {
  618. message["level"] = level
  619. }
  620. payload, _ := json.Marshal(message)
  621. for _, destination := range configStrings(config, "urls") {
  622. parsed, err := validateOutboundURL(ctx, destination, false)
  623. if err != nil {
  624. return err
  625. }
  626. request, err := http.NewRequestWithContext(
  627. ctx,
  628. http.MethodPost,
  629. parsed.String(),
  630. bytes.NewReader(payload),
  631. )
  632. if err != nil {
  633. return fmt.Errorf("create bark test request: %w", err)
  634. }
  635. request.Header.Set("Content-Type", "application/json; charset=utf-8")
  636. request.Header.Set("User-Agent", "vocat-notification-test/1")
  637. if err := performNotificationRequest(client, request, false); err != nil {
  638. return err
  639. }
  640. }
  641. return nil
  642. }
  643. func sendTelegramNotificationTest(ctx context.Context, config map[string]any) error {
  644. baseURL := configString(config, "base_url")
  645. if baseURL == "" {
  646. baseURL = "https://api.telegram.org"
  647. }
  648. parsed, err := validateOutboundURL(ctx, baseURL, true)
  649. if err != nil {
  650. return err
  651. }
  652. token := configString(config, "bot_token")
  653. parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/sendMessage"
  654. parsed.RawPath = ""
  655. parsed.RawQuery = ""
  656. parsed.Fragment = ""
  657. client, err := restrictedHTTPClient(ctx, 6*time.Second, configString(config, "proxy"))
  658. if err != nil {
  659. return err
  660. }
  661. payload, _ := json.Marshal(map[string]any{
  662. "chat_id": configString(config, "chat_id"),
  663. "text": "vocat notification test",
  664. })
  665. request, err := http.NewRequestWithContext(
  666. ctx,
  667. http.MethodPost,
  668. parsed.String(),
  669. bytes.NewReader(payload),
  670. )
  671. if err != nil {
  672. return fmt.Errorf("create Telegram test request: %w", err)
  673. }
  674. request.Header.Set("Content-Type", "application/json")
  675. request.Header.Set("User-Agent", "vocat-notification-test/1")
  676. return performNotificationRequest(client, request, true)
  677. }
  678. func performNotificationRequest(
  679. client *http.Client,
  680. request *http.Request,
  681. requireTelegramOK bool,
  682. ) error {
  683. response, err := client.Do(request)
  684. if err != nil {
  685. return fmt.Errorf("send notification test: %w", err)
  686. }
  687. defer response.Body.Close()
  688. body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
  689. if readErr != nil {
  690. return fmt.Errorf("read notification response: %w", readErr)
  691. }
  692. if response.StatusCode < 200 || response.StatusCode >= 300 {
  693. return fmt.Errorf("%w: HTTP %d", errProviderRejected, response.StatusCode)
  694. }
  695. if requireTelegramOK {
  696. var result struct {
  697. OK bool `json:"ok"`
  698. }
  699. if json.Unmarshal(body, &result) != nil || !result.OK {
  700. return fmt.Errorf("%w: Telegram response was not successful", errProviderRejected)
  701. }
  702. }
  703. return nil
  704. }
  705. func sendEmailNotificationTest(ctx context.Context, config map[string]any) error {
  706. host := strings.TrimSpace(configString(config, "smtp_host"))
  707. port := configInt(config, "smtp_port")
  708. if port == 0 {
  709. port = 587
  710. }
  711. timeout := 8 * time.Second
  712. address := net.JoinHostPort(host, strconv.Itoa(port))
  713. connection, err := dialRestricted(ctx, "tcp", address, timeout)
  714. if err != nil {
  715. return fmt.Errorf("connect SMTP server: %w", err)
  716. }
  717. defer connection.Close()
  718. if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
  719. return fmt.Errorf("set SMTP deadline: %w", err)
  720. }
  721. tlsConfig := &tls.Config{
  722. MinVersion: tls.VersionTLS12,
  723. ServerName: host,
  724. }
  725. if port == 465 {
  726. secure := tls.Client(connection, tlsConfig)
  727. if err := secure.HandshakeContext(ctx); err != nil {
  728. return fmt.Errorf("establish SMTP TLS: %w", err)
  729. }
  730. connection = secure
  731. }
  732. client, err := smtp.NewClient(connection, host)
  733. if err != nil {
  734. return fmt.Errorf("start SMTP session: %w", err)
  735. }
  736. defer client.Close()
  737. if port != 465 {
  738. if available, _ := client.Extension("STARTTLS"); !available {
  739. return errors.New("SMTP server does not offer STARTTLS")
  740. }
  741. if err := client.StartTLS(tlsConfig); err != nil {
  742. return fmt.Errorf("start SMTP TLS: %w", err)
  743. }
  744. }
  745. username := configString(config, "username")
  746. password := configString(config, "password")
  747. if username != "" {
  748. if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
  749. return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
  750. }
  751. }
  752. from, err := mail.ParseAddress(configString(config, "from_address"))
  753. if err != nil {
  754. return fmt.Errorf("parse sender address: %w", err)
  755. }
  756. recipients := make([]*mail.Address, 0)
  757. for _, item := range configStrings(config, "to_addresses") {
  758. address, err := mail.ParseAddress(item)
  759. if err != nil {
  760. return fmt.Errorf("parse recipient address: %w", err)
  761. }
  762. recipients = append(recipients, address)
  763. }
  764. if err := client.Mail(from.Address); err != nil {
  765. return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected)
  766. }
  767. for _, recipient := range recipients {
  768. if err := client.Rcpt(recipient.Address); err != nil {
  769. return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected)
  770. }
  771. }
  772. writer, err := client.Data()
  773. if err != nil {
  774. return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
  775. }
  776. message := strings.Join([]string{
  777. "Date: " + time.Now().UTC().Format(time.RFC1123Z),
  778. "From: " + from.String(),
  779. "To: " + joinMailAddresses(recipients),
  780. "Subject: vocat notification test",
  781. "MIME-Version: 1.0",
  782. "Content-Type: text/plain; charset=UTF-8",
  783. "",
  784. "This is a vocat notification test.",
  785. "",
  786. }, "\r\n")
  787. if _, err := io.WriteString(writer, message); err != nil {
  788. _ = writer.Close()
  789. return fmt.Errorf("write SMTP test message: %w", err)
  790. }
  791. if err := writer.Close(); err != nil {
  792. return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected)
  793. }
  794. if err := client.Quit(); err != nil {
  795. return fmt.Errorf("finish SMTP session: %w", err)
  796. }
  797. return nil
  798. }
  799. func joinMailAddresses(values []*mail.Address) string {
  800. result := make([]string, 0, len(values))
  801. for _, value := range values {
  802. result = append(result, value.String())
  803. }
  804. return strings.Join(result, ", ")
  805. }
  806. func restrictedHTTPClient(
  807. ctx context.Context,
  808. timeout time.Duration,
  809. proxy string,
  810. ) (*http.Client, error) {
  811. timeout = clampNotificationTimeout(timeout)
  812. transport := &http.Transport{
  813. Proxy: nil,
  814. DialContext: restrictedDialer(timeout),
  815. ForceAttemptHTTP2: true,
  816. DisableKeepAlives: true,
  817. MaxIdleConns: 0,
  818. TLSHandshakeTimeout: timeout,
  819. ResponseHeaderTimeout: timeout,
  820. ExpectContinueTimeout: time.Second,
  821. TLSClientConfig: &tls.Config{
  822. MinVersion: tls.VersionTLS12,
  823. },
  824. }
  825. if strings.TrimSpace(proxy) != "" {
  826. parsed, err := validateOutboundURL(ctx, proxy, false)
  827. if err != nil {
  828. return nil, fmt.Errorf("validate notification proxy: %w", err)
  829. }
  830. transport.Proxy = http.ProxyURL(parsed)
  831. }
  832. return &http.Client{
  833. Transport: transport,
  834. Timeout: timeout,
  835. CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
  836. return errors.New("notification provider redirects are not allowed")
  837. },
  838. }, nil
  839. }
  840. func clampNotificationTimeout(timeout time.Duration) time.Duration {
  841. if timeout < 100*time.Millisecond {
  842. return 100 * time.Millisecond
  843. }
  844. if timeout > 10*time.Second {
  845. return 10 * time.Second
  846. }
  847. return timeout
  848. }
  849. func durationMilliseconds(value int, fallback time.Duration) time.Duration {
  850. if value == 0 {
  851. return fallback
  852. }
  853. return time.Duration(value) * time.Millisecond
  854. }
  855. func validateOutboundURL(
  856. ctx context.Context,
  857. raw string,
  858. requireHTTPS bool,
  859. ) (*url.URL, error) {
  860. parsed, err := parseOutboundURL(raw, requireHTTPS)
  861. if err != nil {
  862. return nil, err
  863. }
  864. if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
  865. return nil, err
  866. }
  867. return parsed, nil
  868. }
  869. func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
  870. parsed, err := url.Parse(strings.TrimSpace(raw))
  871. if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
  872. return nil, errors.New("destination must be an absolute HTTP URL")
  873. }
  874. if parsed.User != nil {
  875. return nil, errors.New("destination URL cannot contain user information")
  876. }
  877. if parsed.Scheme != "http" && parsed.Scheme != "https" {
  878. return nil, errors.New("destination URL must use HTTP or HTTPS")
  879. }
  880. if requireHTTPS && parsed.Scheme != "https" {
  881. return nil, errors.New("destination URL must use HTTPS")
  882. }
  883. if parsed.Port() != "" {
  884. port, err := strconv.Atoi(parsed.Port())
  885. if err != nil || port < 1 || port > 65535 {
  886. return nil, errors.New("destination URL has an invalid port")
  887. }
  888. }
  889. return parsed, nil
  890. }
  891. func restrictedDialer(timeout time.Duration) func(
  892. context.Context,
  893. string,
  894. string,
  895. ) (net.Conn, error) {
  896. return func(ctx context.Context, network string, address string) (net.Conn, error) {
  897. return dialRestricted(ctx, network, address, timeout)
  898. }
  899. }
  900. func dialRestricted(
  901. ctx context.Context,
  902. network string,
  903. address string,
  904. timeout time.Duration,
  905. ) (net.Conn, error) {
  906. host, port, err := net.SplitHostPort(address)
  907. if err != nil {
  908. return nil, fmt.Errorf("parse outbound address: %w", err)
  909. }
  910. addresses, err := resolvePublicAddresses(ctx, host)
  911. if err != nil {
  912. return nil, err
  913. }
  914. dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)}
  915. var failures []error
  916. for _, ip := range addresses {
  917. connection, err := dialer.DialContext(
  918. ctx,
  919. network,
  920. net.JoinHostPort(ip.String(), port),
  921. )
  922. if err == nil {
  923. return connection, nil
  924. }
  925. failures = append(failures, err)
  926. }
  927. return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
  928. }
  929. func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
  930. normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
  931. if normalized == "" || normalized == "localhost" ||
  932. strings.HasSuffix(normalized, ".localhost") ||
  933. normalized == "metadata" ||
  934. strings.HasSuffix(normalized, ".internal") ||
  935. strings.HasSuffix(normalized, ".local") {
  936. return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
  937. }
  938. if literal, err := netip.ParseAddr(normalized); err == nil {
  939. literal = literal.Unmap()
  940. if !publicNotificationAddress(literal) {
  941. return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
  942. }
  943. return []netip.Addr{literal}, nil
  944. }
  945. addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", normalized)
  946. if err != nil {
  947. return nil, fmt.Errorf("resolve notification destination: %w", err)
  948. }
  949. if len(addresses) == 0 {
  950. return nil, errors.New("notification destination did not resolve")
  951. }
  952. result := make([]netip.Addr, 0, len(addresses))
  953. for _, address := range addresses {
  954. address = address.Unmap()
  955. if !publicNotificationAddress(address) {
  956. return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
  957. }
  958. result = append(result, address)
  959. }
  960. return result, nil
  961. }
  962. var blockedNotificationNetworks = []netip.Prefix{
  963. netip.MustParsePrefix("0.0.0.0/8"),
  964. netip.MustParsePrefix("10.0.0.0/8"),
  965. netip.MustParsePrefix("100.64.0.0/10"),
  966. netip.MustParsePrefix("127.0.0.0/8"),
  967. netip.MustParsePrefix("169.254.0.0/16"),
  968. netip.MustParsePrefix("172.16.0.0/12"),
  969. netip.MustParsePrefix("192.0.0.0/24"),
  970. netip.MustParsePrefix("192.0.2.0/24"),
  971. netip.MustParsePrefix("192.88.99.0/24"),
  972. netip.MustParsePrefix("192.168.0.0/16"),
  973. netip.MustParsePrefix("198.18.0.0/15"),
  974. netip.MustParsePrefix("198.51.100.0/24"),
  975. netip.MustParsePrefix("203.0.113.0/24"),
  976. netip.MustParsePrefix("224.0.0.0/4"),
  977. netip.MustParsePrefix("240.0.0.0/4"),
  978. netip.MustParsePrefix("::/128"),
  979. netip.MustParsePrefix("::1/128"),
  980. netip.MustParsePrefix("64:ff9b:1::/48"),
  981. netip.MustParsePrefix("100::/64"),
  982. netip.MustParsePrefix("2001:db8::/32"),
  983. netip.MustParsePrefix("fc00::/7"),
  984. netip.MustParsePrefix("fe80::/10"),
  985. netip.MustParsePrefix("ff00::/8"),
  986. }
  987. func publicNotificationAddress(address netip.Addr) bool {
  988. if !address.IsValid() || !address.IsGlobalUnicast() {
  989. return false
  990. }
  991. address = address.Unmap()
  992. for _, blocked := range blockedNotificationNetworks {
  993. if blocked.Contains(address) {
  994. return false
  995. }
  996. }
  997. return true
  998. }
  999. func configString(config map[string]any, key string) string {
  1000. value, _ := config[key].(string)
  1001. return strings.TrimSpace(value)
  1002. }
  1003. func configStrings(config map[string]any, key string) []string {
  1004. switch value := config[key].(type) {
  1005. case []string:
  1006. return value
  1007. case []any:
  1008. result := make([]string, 0, len(value))
  1009. for _, item := range value {
  1010. text, ok := item.(string)
  1011. if ok {
  1012. result = append(result, strings.TrimSpace(text))
  1013. }
  1014. }
  1015. return result
  1016. default:
  1017. return nil
  1018. }
  1019. }
  1020. func configStringMap(config map[string]any, key string) map[string]string {
  1021. object, ok := config[key].(map[string]any)
  1022. if !ok {
  1023. return nil
  1024. }
  1025. result := make(map[string]string, len(object))
  1026. for name, value := range object {
  1027. text, ok := value.(string)
  1028. if ok {
  1029. result[name] = text
  1030. }
  1031. }
  1032. return result
  1033. }
  1034. func configInt(config map[string]any, key string) int {
  1035. switch value := config[key].(type) {
  1036. case float64:
  1037. return int(value)
  1038. case json.Number:
  1039. result, _ := value.Int64()
  1040. return int(result)
  1041. case int:
  1042. return value
  1043. default:
  1044. return 0
  1045. }
  1046. }
  1047. // handleCardPolicies returns every stored card policy (VoHive: GET /cards/policies).
  1048. func (s *Server) handleCardPolicies(w http.ResponseWriter, r *http.Request) {
  1049. if !requireMethod(w, r, http.MethodGet) {
  1050. return
  1051. }
  1052. policies, err := s.store.ListCardPolicies(r.Context())
  1053. if err != nil {
  1054. s.writeStoreError(w, err)
  1055. return
  1056. }
  1057. result := make([]map[string]any, 0, len(policies))
  1058. for _, policy := range policies {
  1059. result = append(result, cardPolicyResponse(policy))
  1060. }
  1061. writeJSON(w, http.StatusOK, map[string]any{"data": result})
  1062. }
  1063. // liveCardPolicyFlags resolves the current VoWiFi/airplane state for the
  1064. // device that presently hosts the given SIM (matched by live ICCID), so the card
  1065. // policy toggles reflect what the card is actually doing now rather than a stale
  1066. // stored value. ok is false when no present device reports this ICCID.
  1067. func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi, airplane, ok bool) {
  1068. configs, err := s.store.ListDevices(ctx)
  1069. if err != nil {
  1070. return false, false, false
  1071. }
  1072. clean := strings.TrimSpace(iccid)
  1073. for _, config := range configs {
  1074. entry, _, present := s.physicalForConfig(config)
  1075. if !present || entry.Snapshot == nil {
  1076. continue
  1077. }
  1078. if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) {
  1079. continue
  1080. }
  1081. // VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS
  1082. // path owns service. That physical CFUN state is not the user's separate
  1083. // airplane-mode policy; exposing both toggles as enabled is contradictory
  1084. // and makes the UI unable to represent the active policy correctly.
  1085. return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true
  1086. }
  1087. return false, false, false
  1088. }
  1089. func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid string) {
  1090. iccid = strings.TrimSpace(iccid)
  1091. if !validICCID(iccid) {
  1092. writeError(
  1093. w,
  1094. http.StatusBadRequest,
  1095. "invalid_iccid",
  1096. "ICCID must contain between 10 and 32 decimal digits",
  1097. )
  1098. return
  1099. }
  1100. switch r.Method {
  1101. case http.MethodGet:
  1102. policy, err := s.store.CardPolicy(r.Context(), iccid)
  1103. if errors.Is(err, store.ErrNotFound) {
  1104. policy = store.CardPolicy{
  1105. ICCID: iccid,
  1106. IPVersion: "IPV4V6",
  1107. Source: "default",
  1108. }
  1109. } else if err != nil {
  1110. s.writeStoreError(w, err)
  1111. return
  1112. }
  1113. // Reflect the SIM's live current state in the toggles (APN / IP version
  1114. // remain stored preferences); fall back to the stored policy when the card
  1115. // is not currently present in any device.
  1116. if vowifi, airplane, ok := s.liveCardPolicyFlags(r.Context(), iccid); ok {
  1117. policy.VoWiFiEnabled = vowifi
  1118. policy.AirplaneEnabled = airplane
  1119. }
  1120. writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
  1121. case http.MethodPut:
  1122. var request struct {
  1123. VoWiFiEnabled *bool `json:"vowifi_enabled"`
  1124. AirplaneEnabled *bool `json:"airplane_enabled"`
  1125. APN string `json:"apn"`
  1126. IPVersion string `json:"ip_version"`
  1127. }
  1128. if err := s.decodeJSON(w, r, &request); err != nil {
  1129. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  1130. return
  1131. }
  1132. if request.VoWiFiEnabled == nil ||
  1133. request.AirplaneEnabled == nil {
  1134. writeError(
  1135. w,
  1136. http.StatusBadRequest,
  1137. "invalid_card_policy",
  1138. "all card policy switches are required",
  1139. )
  1140. return
  1141. }
  1142. request.APN = strings.TrimSpace(request.APN)
  1143. if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
  1144. writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
  1145. return
  1146. }
  1147. request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
  1148. if request.IPVersion == "" {
  1149. request.IPVersion = "IPV4V6"
  1150. }
  1151. if request.IPVersion != "IP" &&
  1152. request.IPVersion != "IPV6" &&
  1153. request.IPVersion != "IPV4V6" {
  1154. writeError(
  1155. w,
  1156. http.StatusBadRequest,
  1157. "invalid_card_policy",
  1158. "IP version must be IP, IPV6, or IPV4V6",
  1159. )
  1160. return
  1161. }
  1162. if *request.VoWiFiEnabled && *request.AirplaneEnabled {
  1163. writeError(
  1164. w,
  1165. http.StatusBadRequest,
  1166. "invalid_card_policy",
  1167. "VoWiFi and airplane mode cannot both be enabled",
  1168. )
  1169. return
  1170. }
  1171. policy := store.CardPolicy{
  1172. ICCID: iccid,
  1173. NetworkEnabled: false,
  1174. VoWiFiEnabled: *request.VoWiFiEnabled,
  1175. AirplaneEnabled: *request.AirplaneEnabled,
  1176. APN: request.APN,
  1177. IPVersion: request.IPVersion,
  1178. Source: "manual",
  1179. }
  1180. if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
  1181. s.writeStoreError(w, err)
  1182. return
  1183. }
  1184. policy, err := s.store.CardPolicy(r.Context(), iccid)
  1185. if err != nil {
  1186. s.writeStoreError(w, err)
  1187. return
  1188. }
  1189. writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
  1190. default:
  1191. w.Header().Set("Allow", "GET, PUT")
  1192. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  1193. }
  1194. }
  1195. func validICCID(value string) bool {
  1196. if len(value) < 10 || len(value) > 32 {
  1197. return false
  1198. }
  1199. for _, character := range value {
  1200. if character < '0' || character > '9' {
  1201. return false
  1202. }
  1203. }
  1204. return true
  1205. }
  1206. func cardPolicyResponse(policy store.CardPolicy) map[string]any {
  1207. response := map[string]any{
  1208. "iccid": policy.ICCID,
  1209. "network_enabled": false,
  1210. "vowifi_enabled": policy.VoWiFiEnabled,
  1211. "airplane_enabled": policy.AirplaneEnabled,
  1212. "apn": policy.APN,
  1213. "ip_version": policy.IPVersion,
  1214. "source": policy.Source,
  1215. }
  1216. if !policy.CreatedAt.IsZero() {
  1217. response["created_at"] = policy.CreatedAt
  1218. }
  1219. if !policy.UpdatedAt.IsZero() {
  1220. response["updated_at"] = policy.UpdatedAt
  1221. }
  1222. return response
  1223. }
  1224. func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) {
  1225. if !requireMethod(w, r, http.MethodGet) {
  1226. return
  1227. }
  1228. rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range")))
  1229. if rangeName == "" {
  1230. rangeName = "day"
  1231. }
  1232. var window time.Duration
  1233. switch rangeName {
  1234. case "hour":
  1235. window = time.Hour
  1236. case "day":
  1237. window = 24 * time.Hour
  1238. case "week":
  1239. window = 7 * 24 * time.Hour
  1240. case "month":
  1241. window = 30 * 24 * time.Hour
  1242. default:
  1243. writeError(
  1244. w,
  1245. http.StatusBadRequest,
  1246. "invalid_range",
  1247. "traffic range must be hour, day, week, or month",
  1248. )
  1249. return
  1250. }
  1251. deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
  1252. if len(deviceID) > 128 || strings.ContainsAny(deviceID, "\x00\r\n") {
  1253. writeError(w, http.StatusBadRequest, "invalid_device", "device ID is invalid")
  1254. return
  1255. }
  1256. now := time.Now().UTC()
  1257. rows, err := s.store.ListTrafficBuckets(r.Context(), store.TrafficFilter{
  1258. DeviceID: deviceID,
  1259. Bucket: rangeName,
  1260. Since: now.Add(-window),
  1261. Until: now.Add(time.Minute),
  1262. Limit: 1000,
  1263. })
  1264. if err != nil {
  1265. s.writeStoreError(w, err)
  1266. return
  1267. }
  1268. type aggregate struct {
  1269. period time.Time
  1270. rx int64
  1271. tx int64
  1272. }
  1273. byPeriod := make(map[int64]*aggregate)
  1274. for _, row := range rows {
  1275. key := row.PeriodStart.Unix()
  1276. value := byPeriod[key]
  1277. if value == nil {
  1278. value = &aggregate{period: row.PeriodStart}
  1279. byPeriod[key] = value
  1280. }
  1281. value.rx += row.RXBytes
  1282. value.tx += row.TXBytes
  1283. }
  1284. values := make([]*aggregate, 0, len(byPeriod))
  1285. for _, value := range byPeriod {
  1286. values = append(values, value)
  1287. }
  1288. sort.Slice(values, func(left, right int) bool {
  1289. return values[left].period.Before(values[right].period)
  1290. })
  1291. buckets := make([]map[string]any, 0, len(values))
  1292. for _, value := range values {
  1293. buckets = append(buckets, map[string]any{
  1294. "bucket": rangeName,
  1295. "period_start": value.period,
  1296. "rx_bytes": value.rx,
  1297. "tx_bytes": value.tx,
  1298. "total_bytes": value.rx + value.tx,
  1299. })
  1300. }
  1301. writeJSON(w, http.StatusOK, map[string]any{
  1302. "data": map[string]any{
  1303. "status": "ok",
  1304. "range": rangeName,
  1305. "buckets": buckets,
  1306. },
  1307. })
  1308. }