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}, }) }