Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
252 lines
8.0 KiB
Go
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
|
|
}
|