package app import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" ) const statusWebhookMaxAttempts = 10 type statusWebhookEnvelope struct { ID string `json:"id"` Type string `json:"type"` CreatedAt string `json:"createdAt"` Data any `json:"data"` } func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error { if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" { return nil } now := a.now().UTC() id := newID("whk") payload := jsonEncode(statusWebhookEnvelope{ID: id, Type: eventType, CreatedAt: now.Format(time.RFC3339Nano), Data: data}) _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO status_webhook_outbox(id,event_key,event_type,mailbox_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, id, eventKey, eventType, mailboxID, payload, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) return err } func (a *App) statusWebhookWorker(ctx context.Context) { if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" { return } a.log.Info("status webhook worker started") ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { if err := a.processDueStatusWebhooks(ctx); err != nil && !errors.Is(err, context.Canceled) { a.log.Warn("status webhook worker failed", "error", err) } select { case <-ctx.Done(): a.log.Info("status webhook worker stopped") return case <-ticker.C: } } } func (a *App) processDueStatusWebhooks(ctx context.Context) error { if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" { return nil } _, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox WHERE updated_at=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), statusWebhookMaxAttempts) rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM status_webhook_outbox WHERE delivered_at IS NULL AND attempt_count= 300 { return fmt.Errorf("status webhook returned %d", resp.StatusCode) } return nil } func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) { if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" { return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required") } target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL)) if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" { return nil, errors.New("invalid status webhook URL") } if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") { return nil, errors.New("status webhook URL must use HTTPS") } if !a.cfg.StatusWebhookAllowPrivateHosts { if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil { return nil, err } } return target, nil } func (a *App) statusWebhookDialContext(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } if a.cfg.StatusWebhookAllowPrivateHosts { return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address) } ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) if err != nil { return nil, err } for _, ip := range ips { if !isPublicStatusWebhookIP(ip) { return nil, errors.New("private or local status webhook hosts are not allowed") } } dialer := &net.Dialer{Timeout: 5 * time.Second} var lastErr error for _, ip := range ips { conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) if err == nil { return conn, nil } lastErr = err } if lastErr == nil { lastErr = errors.New("status webhook host resolved without usable addresses") } return nil, lastErr } func validatePublicWebhookHost(ctx context.Context, host string) error { if strings.EqualFold(host, "localhost") { return errors.New("localhost status webhook hosts are not allowed") } ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) if err != nil { return fmt.Errorf("failed to resolve status webhook host: %w", err) } for _, ip := range ips { if !isPublicStatusWebhookIP(ip) { return errors.New("private or local status webhook hosts are not allowed") } } return nil } func isPublicStatusWebhookIP(ip net.IP) bool { if ip == nil { return false } return ip.IsGlobalUnicast() && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified() } func truncateWebhookError(value string) string { value = strings.TrimSpace(value) if len(value) > 1000 { return value[:1000] } return value }