Head of Product & Engineering 9995027093
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: per-location Cast API key and unified admin config API
Each GHL location can now have its own Cast API key and sender ID stored
in MongoDB. Falls back to global CAST_API_KEY / CAST_SENDER_ID env vars
when not set per-location.

Admin endpoints (all require Authorization: Bearer <INBOUND_API_KEY>):
  GET  /api/admin/locations                        — list all locations
  GET  /api/admin/locations/{locationId}/config    — get location config
  PUT  /api/admin/locations/{locationId}/config    — set sender_id + cast_api_key

Cast API key is masked in GET responses (first 12 chars + "...").
Replaces the /sender-id endpoint deployed in the previous commit.

Also adds FUTURE_DEV.md documenting the migration path to Infisical
for secret management, plus MongoDB security hardening checklist.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-06 14:08:29 +02:00

252 lines
8.0 KiB
Go

package ghl
import (
"context"
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
"git.sds.dev/CAST/cast-ghl-plugin/internal/phone"
)
const seenMessageTTL = 10 * time.Minute
type seenEntry struct {
at time.Time
}
// WebhookHandler handles inbound GHL webhook deliveries.
//
// Signature verification supports two GHL schemes:
// - x-wh-signature: Ed25519 (current GHL scheme, key from Marketplace app settings)
// - X-GHL-Signature: Ed25519 (GHL next-gen header name, active from July 2026)
//
// Both headers use the same Ed25519 public key configured via GHL_WEBHOOK_PUBLIC_KEY.
// The handler checks X-GHL-Signature first; if absent it falls back to x-wh-signature.
type WebhookHandler struct {
webhookKey ed25519.PublicKey // Ed25519 key for both x-wh-signature and X-GHL-Signature
castClient *castclient.Client
ghlAPI *APIClient
oauthHandler *OAuthHandler
store TokenStore
seenMu sync.Mutex
seenMessages map[string]seenEntry
}
// NewWebhookHandler parses pubKeyPEM (Ed25519 PKIX) and wires all dependencies.
func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
key, err := parseEd25519PublicKey(pubKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse webhook public key: %w", err)
}
return &WebhookHandler{
webhookKey: key,
castClient: castClient,
ghlAPI: ghlAPI,
oauthHandler: oauth,
store: store,
seenMessages: make(map[string]seenEntry),
}, nil
}
// markSeen returns true if messageID was already seen within seenMessageTTL (duplicate).
// Otherwise records it and returns false.
func (h *WebhookHandler) markSeen(messageID string) bool {
h.seenMu.Lock()
defer h.seenMu.Unlock()
now := time.Now()
// Evict expired entries on every call to avoid unbounded growth.
for id, e := range h.seenMessages {
if now.Sub(e.at) > seenMessageTTL {
delete(h.seenMessages, id)
}
}
if _, exists := h.seenMessages[messageID]; exists {
return true
}
h.seenMessages[messageID] = seenEntry{at: now}
return false
}
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("webhook: failed to read body", "err", err)
http.Error(w, "failed to read request body", http.StatusInternalServerError)
return
}
if !h.verifyIncomingSignature(r, body) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
return
}
var webhook OutboundMessageWebhook
if err := json.Unmarshal(body, &webhook); err != nil {
slog.Error("webhook: failed to parse payload", "err", err)
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if webhook.Type != "SMS" {
slog.Debug("webhook: ignoring non-SMS webhook", "type", webhook.Type)
w.WriteHeader(http.StatusOK)
return
}
if h.markSeen(webhook.MessageID) {
slog.Warn("webhook: duplicate messageId ignored", "message_id", webhook.MessageID)
w.WriteHeader(http.StatusOK)
return
}
slog.Info("webhook: received outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID)
w.WriteHeader(http.StatusOK)
go h.processOutbound(webhook)
}
func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) {
defer func() {
if r := recover(); r != nil {
slog.Error("webhook: processOutbound panic", "message_id", webhook.MessageID, "panic", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
slog.Info("webhook: processing outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID, "phone", webhook.Phone)
localPhone, err := phone.ToLocal(webhook.Phone)
if err != nil {
slog.Error("webhook: phone normalization failed", "phone", webhook.Phone, "err", err)
h.updateStatus(ctx, webhook, "failed")
return
}
// Look up per-location Cast config; fall back to client defaults if unset.
var senderID, castAPIKey string
if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil {
senderID = rec.SenderID
castAPIKey = rec.CastAPIKey
}
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID, "per_location_key", castAPIKey != "")
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, castAPIKey, senderID)
if err != nil {
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err)
h.updateStatus(ctx, webhook, "failed")
return
}
slog.Info("webhook: cast send success", "message_id", webhook.MessageID)
h.updateStatus(ctx, webhook, "delivered")
}
func (h *WebhookHandler) updateStatus(ctx context.Context, webhook OutboundMessageWebhook, status string) {
token, err := h.oauthHandler.GetValidToken(ctx, webhook.LocationID)
if err != nil {
slog.Error("webhook: failed to get valid token for status update", "location_id", webhook.LocationID, "err", err)
return
}
if err := h.ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, status); err != nil {
slog.Error("webhook: failed to update message status", "message_id", webhook.MessageID, "status", status, "err", err)
return
}
slog.Info("webhook: message status updated", "message_id", webhook.MessageID, "status", status)
}
// verifyIncomingSignature checks X-GHL-Signature first, then x-wh-signature.
// Both use the same Ed25519 key from GHL Marketplace app settings.
func (h *WebhookHandler) verifyIncomingSignature(r *http.Request, body []byte) bool {
if sig := r.Header.Get("X-GHL-Signature"); sig != "" {
return h.verifyEd25519(body, sig)
}
return h.verifyEd25519(body, r.Header.Get("x-wh-signature"))
}
// verifyEd25519 verifies a raw Ed25519 signature (base64-encoded) over body.
func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool {
if signatureB64 == "" {
return false
}
sigBytes, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
return false
}
return ed25519.Verify(h.webhookKey, body, sigBytes)
}
func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("uninstall: failed to read body", "err", err)
http.Error(w, "failed to read request body", http.StatusInternalServerError)
return
}
if !h.verifyIncomingSignature(r, body) {
slog.Warn("uninstall: invalid signature")
http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
return
}
var payload UninstallWebhook
if err := json.Unmarshal(body, &payload); err != nil {
slog.Error("uninstall: failed to parse payload", "err", err)
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if payload.LocationID == "" {
slog.Error("uninstall: missing locationId")
http.Error(w, "missing locationId", http.StatusBadRequest)
return
}
ctx := r.Context()
if err := h.store.DeleteToken(ctx, payload.LocationID); err != nil {
slog.Error("uninstall: failed to delete token", "location_id", payload.LocationID, "err", err)
http.Error(w, "failed to process uninstall", http.StatusInternalServerError)
return
}
slog.Info("uninstall: token deleted", "location_id", payload.LocationID)
w.WriteHeader(http.StatusOK)
}
// parseEd25519PublicKey decodes a PEM-encoded PKIX Ed25519 public key.
// Tolerates literal \n sequences in the input (common when stored as a single
// line in a .env file).
func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) {
pemStr = strings.ReplaceAll(pemStr, `\n`, "\n")
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ed25519Pub, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("key is not Ed25519 (got %T)", pub)
}
return ed25519Pub, nil
}