192 lines
6.5 KiB
Go
192 lines
6.5 KiB
Go
package sms
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/utils"
|
|
)
|
|
|
|
// A provider-agnostic HTTP gateway, and the wiring that installs it.
|
|
//
|
|
// This package called itself "the seam, not the integration", with a logging
|
|
// sink standing in until a gateway was plugged in. The sink was never replaced:
|
|
// sms.Register had no callers anywhere in the tree, so every customer
|
|
// verification code since this surface shipped has gone to the application log
|
|
// and nowhere else. Two consequences, both live in production:
|
|
//
|
|
// 1. No customer can complete sign-in without somebody reading the server log
|
|
// to them. That is the single blocker on the customer app, and it is why
|
|
// the app's offline dev mode became the only practical way in — which in
|
|
// turn is why bookings "made" in it never reached the admin console.
|
|
// 2. Every OTP ever issued is sitting in log storage as plaintext. A
|
|
// credential in a log file is a credential in the wrong place.
|
|
//
|
|
// Rather than hard-coding one vendor, this posts to whatever gateway the
|
|
// deployment names. The Indian providers this would plausibly use — MSG91,
|
|
// Gupshup, Textlocal, Fast2SMS — all accept an authenticated POST carrying a
|
|
// destination and a body, so one templated request covers them and swapping
|
|
// vendors is configuration rather than a release.
|
|
//
|
|
// Configuration, all read once at startup. An absent SMS_GATEWAY_URL leaves the
|
|
// log sink exactly where it is, so this change cannot break a deployment that
|
|
// has not been configured yet:
|
|
//
|
|
// SMS_GATEWAY_URL endpoint to POST to; absent means "stay on the log sink"
|
|
// SMS_GATEWAY_METHOD HTTP method, default POST
|
|
// SMS_GATEWAY_AUTH Authorization header value, for vendors that use one
|
|
// SMS_GATEWAY_HEADER one extra "Name: value" header, for vendors with their own key header
|
|
// SMS_GATEWAY_BODY body template; {{phone}}, {{message}} and {{sender}} are substituted
|
|
// SMS_GATEWAY_TYPE content type, default application/json
|
|
// SMS_SENDER_ID the registered sender id, substituted as {{sender}}
|
|
|
|
const gatewayTimeout = 10 * time.Second
|
|
|
|
type httpSender struct {
|
|
url string
|
|
method string
|
|
auth string
|
|
headerName string
|
|
headerValue string
|
|
bodyTmpl string
|
|
contentType string
|
|
senderID string
|
|
client *http.Client
|
|
}
|
|
|
|
func (httpSender) Name() string { return "http-gateway" }
|
|
|
|
func (s httpSender) Send(phone, message string) error {
|
|
body := s.bodyTmpl
|
|
body = strings.ReplaceAll(body, "{{phone}}", phone)
|
|
body = strings.ReplaceAll(body, "{{message}}", jsonEscape(message))
|
|
body = strings.ReplaceAll(body, "{{sender}}", s.senderID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, s.method, s.url, bytes.NewReader([]byte(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("sms: build gateway request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", s.contentType)
|
|
if s.auth != "" {
|
|
req.Header.Set("Authorization", s.auth)
|
|
}
|
|
if s.headerName != "" {
|
|
req.Header.Set(s.headerName, s.headerValue)
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("sms: gateway unreachable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read a bounded slice of the response for the log. The gateway's reason
|
|
// for refusing — "insufficient balance", "DLT template not approved" — is
|
|
// the entire diagnosis, and it only ever appears in the body.
|
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
// The number is masked and the message is NOT logged: the message
|
|
// contains the code, which is the thing this whole file exists to keep
|
|
// out of the log.
|
|
utils.Error("sms: gateway rejected the send",
|
|
"status", resp.StatusCode,
|
|
"phone", maskPhone(phone),
|
|
"response", strings.TrimSpace(string(snippet)))
|
|
return fmt.Errorf("sms: gateway returned %d", resp.StatusCode)
|
|
}
|
|
|
|
utils.Info("sms: code delivered", "phone", maskPhone(phone))
|
|
return nil
|
|
}
|
|
|
|
// jsonEscape makes a message safe to interpolate into a JSON body template.
|
|
// The OTP text carries no quotes today, but a template is a template and the
|
|
// next message to go through here will not be this one.
|
|
func jsonEscape(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch r {
|
|
case '"':
|
|
b.WriteString(`\"`)
|
|
case '\\':
|
|
b.WriteString(`\\`)
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Configure installs a real gateway when one is configured, and says plainly
|
|
// which transport the process ended up with.
|
|
//
|
|
// Called once from main() after config load. Deliberately loud in both
|
|
// directions: a deployment that believes it can send texts and cannot is the
|
|
// exact failure that has been live in this service since the customer surface
|
|
// shipped, so it must not be possible to start without the answer appearing in
|
|
// the boot log.
|
|
func Configure() {
|
|
url := strings.TrimSpace(os.Getenv("SMS_GATEWAY_URL"))
|
|
if url == "" {
|
|
utils.Warn("SMS: no gateway configured (SMS_GATEWAY_URL is unset). " +
|
|
"Verification codes are written to THIS LOG and no text is sent. " +
|
|
"Customer sign-in cannot complete unless somebody reads the code out " +
|
|
"of here, or CX_STAGING_OTP is set on a non-production deployment.")
|
|
return
|
|
}
|
|
|
|
method := strings.ToUpper(strings.TrimSpace(os.Getenv("SMS_GATEWAY_METHOD")))
|
|
if method == "" {
|
|
method = http.MethodPost
|
|
}
|
|
|
|
bodyTmpl := os.Getenv("SMS_GATEWAY_BODY")
|
|
if strings.TrimSpace(bodyTmpl) == "" {
|
|
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
|
|
}
|
|
|
|
contentType := strings.TrimSpace(os.Getenv("SMS_GATEWAY_TYPE"))
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
|
|
var headerName, headerValue string
|
|
if raw := strings.TrimSpace(os.Getenv("SMS_GATEWAY_HEADER")); raw != "" {
|
|
if name, value, ok := strings.Cut(raw, ":"); ok {
|
|
headerName = strings.TrimSpace(name)
|
|
headerValue = strings.TrimSpace(value)
|
|
} else {
|
|
utils.Warn("SMS: SMS_GATEWAY_HEADER is not in 'Name: value' form and was ignored",
|
|
"value", raw)
|
|
}
|
|
}
|
|
|
|
Register(httpSender{
|
|
url: url,
|
|
method: method,
|
|
auth: strings.TrimSpace(os.Getenv("SMS_GATEWAY_AUTH")),
|
|
headerName: headerName,
|
|
headerValue: headerValue,
|
|
bodyTmpl: bodyTmpl,
|
|
contentType: contentType,
|
|
senderID: strings.TrimSpace(os.Getenv("SMS_SENDER_ID")),
|
|
client: &http.Client{Timeout: gatewayTimeout},
|
|
})
|
|
}
|