aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGrégoire Duchêne <gduchene@awhk.org>2021-03-14 15:41:22 +0000
committerGrégoire Duchêne <gduchene@awhk.org>2021-03-14 15:41:22 +0000
commitce3182d4c3f5fb723a16bece3157a5058b1236f9 (patch)
tree858301133dd6a547e935da356f049ac165261c06
parentd10731043bfd7429ebd22c717794c8363f462caf (diff)
Move Twilio authn code into its own package
-rw-r--r--cmd/fwdsms/config.go2
-rw-r--r--cmd/fwdsms/mailer.go8
-rw-r--r--cmd/fwdsms/mailer_test.go13
-rw-r--r--cmd/fwdsms/main.go20
-rw-r--r--cmd/fwdsms/twilio.go115
-rw-r--r--cmd/fwdsms/twilio_test.go132
-rw-r--r--go.mod2
-rw-r--r--go.sum5
-rw-r--r--pkg/twilio/filter.go74
-rw-r--r--pkg/twilio/filter_test.go114
-rw-r--r--pkg/twilio/handler.go4
-rw-r--r--pkg/twilio/sms.go34
-rw-r--r--pkg/twilio/util.go16
13 files changed, 277 insertions, 262 deletions
diff --git a/cmd/fwdsms/config.go b/cmd/fwdsms/config.go
index 0a50901..4eb0fdf 100644
--- a/cmd/fwdsms/config.go
+++ b/cmd/fwdsms/config.go
@@ -30,7 +30,7 @@ type SMTP struct {
type Twilio struct {
Address string `yaml:"address"`
- AuthToken string `yaml:"authToken"`
+ AuthToken []byte `yaml:"authToken"`
Endpoint string `yaml:"endpoint"`
}
diff --git a/cmd/fwdsms/mailer.go b/cmd/fwdsms/mailer.go
index b61c981..f8c1db6 100644
--- a/cmd/fwdsms/mailer.go
+++ b/cmd/fwdsms/mailer.go
@@ -13,6 +13,8 @@ import (
"net/smtp"
"text/template"
"time"
+
+ "go.awhk.org/fwdsms/pkg/twilio"
)
type email struct {
@@ -23,7 +25,7 @@ type email struct {
type mailer struct {
auth smtp.Auth
hostname string
- sms <-chan SMS
+ sms <-chan twilio.SMS
tmplFrom, tmplTo, tmplMsg *template.Template
}
@@ -64,7 +66,7 @@ func (m *mailer) sendEmail(e email) error {
return c.Quit()
}
-func (m *mailer) newEmail(sms SMS) email {
+func (m *mailer) newEmail(sms twilio.SMS) email {
var from, to, msg bytes.Buffer
if err := m.tmplFrom.Execute(&from, sms); err != nil {
log.Printf("Failed to apply a template: %v.", err)
@@ -91,7 +93,7 @@ func (m *mailer) start(ctx context.Context) {
}
}
-func newMailer(cfg *Config, sms <-chan SMS) *mailer {
+func newMailer(cfg *Config, sms <-chan twilio.SMS) *mailer {
if cfg.Message.From == "" {
log.Fatal("Missing From field.")
}
diff --git a/cmd/fwdsms/mailer_test.go b/cmd/fwdsms/mailer_test.go
index b537ffb..e1376c3 100644
--- a/cmd/fwdsms/mailer_test.go
+++ b/cmd/fwdsms/mailer_test.go
@@ -9,6 +9,8 @@ import (
"time"
"github.com/stretchr/testify/assert"
+
+ "go.awhk.org/fwdsms/pkg/twilio"
)
func TestMailer_newEmail(t *testing.T) {
@@ -19,12 +21,17 @@ func TestMailer_newEmail(t *testing.T) {
Subject: "New SMS From {{.From}}",
Template: `From: {{.From}}
To: {{.To}}
-Date: {{.Date.UTC}}
+Date: {{.DateReceived.UTC}}
-{{.Message}}`,
+{{.Body}}`,
}}, nil)
// Reserved phone numbers, see Ofcom's website.
- sms := SMS{time.Unix(0, 0), "+442079460123", "+447700900123", "Hello World!"}
+ sms := twilio.SMS{
+ DateReceived: time.Unix(0, 0),
+ From: "+442079460123",
+ To: "+447700900123",
+ Body: "Hello World!",
+ }
wants := email{
from: "fwdsms@example.com",
to: "sms+447700900123@example.com",
diff --git a/cmd/fwdsms/main.go b/cmd/fwdsms/main.go
index 47af33a..a810be1 100644
--- a/cmd/fwdsms/main.go
+++ b/cmd/fwdsms/main.go
@@ -14,7 +14,10 @@ import (
"time"
"github.com/gorilla/handlers"
+ "github.com/gorilla/mux"
"golang.org/x/sys/unix"
+
+ "go.awhk.org/fwdsms/pkg/twilio"
)
var cfgFilename = flag.String("c", "/etc/fwdsms.yaml", "configuration file")
@@ -30,10 +33,19 @@ func main() {
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, unix.SIGTERM)
- sms := make(chan SMS)
- mux := http.NewServeMux()
- mux.Handle(cfg.Twilio.Endpoint, handlers.ProxyHeaders(newSMSHandler(cfg, sms)))
- srv := http.Server{Handler: mux}
+ sms := make(chan twilio.SMS)
+
+ r := mux.NewRouter()
+ r.Path(cfg.Twilio.Endpoint).
+ Methods(http.MethodPost).
+ Handler(handlers.ProxyHeaders(&twilio.Filter{
+ AuthToken: cfg.Twilio.AuthToken,
+ Handler: &twilio.SMSTee{
+ Chan: sms,
+ Handler: twilio.EmptyResponseHandler,
+ },
+ }))
+ srv := http.Server{Handler: r}
go func() {
var (
l net.Listener
diff --git a/cmd/fwdsms/twilio.go b/cmd/fwdsms/twilio.go
deleted file mode 100644
index f997465..0000000
--- a/cmd/fwdsms/twilio.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org>
-// SPDX-License-Identifier: ISC
-
-package main
-
-import (
- "crypto/hmac"
- "crypto/sha1"
- "encoding/base64"
- "errors"
- "fmt"
- "hash"
- "log"
- "net/http"
- "sort"
- "strings"
- "time"
-)
-
-type SMS struct {
- Date time.Time
- From, To, Message string
-}
-
-type smsHandler struct {
- hash hash.Hash
- sms chan SMS
-}
-
-var _ http.Handler = &smsHandler{}
-
-func (h *smsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- resp.Header().Set("Allow", http.MethodPost)
- resp.WriteHeader(http.StatusMethodNotAllowed)
- return
- }
- if err := h.checkRequestSignature(req); err != nil {
- log.Printf("Failed to check the request signature: %v.", err)
- resp.WriteHeader(http.StatusBadRequest)
- return
- }
-
- from, ok := req.PostForm["From"]
- if !ok || len(from) == 0 {
- resp.WriteHeader(http.StatusBadRequest)
- return
- }
- to, ok := req.PostForm["To"]
- if !ok || len(to) == 0 {
- resp.WriteHeader(http.StatusBadRequest)
- return
- }
- msg, ok := req.PostForm["Body"]
- if !ok || len(msg) == 0 {
- resp.WriteHeader(http.StatusBadRequest)
- return
- }
- resp.Header().Set("Content-Type", "text/xml")
- fmt.Fprintf(resp, "<Response/>")
- h.sms <- SMS{time.Now(), from[0], to[0], msg[0]}
-}
-
-func (h *smsHandler) checkRequestSignature(req *http.Request) error {
- reqSig, err := func() ([]byte, error) {
- h := req.Header.Get("X-Twilio-Signature")
- if len(h) == 0 {
- return nil, errors.New("missing X-Twilio-Signature header")
- }
- b, err := base64.StdEncoding.DecodeString(h)
- if err != nil {
- return nil, errors.New("bad X-Twilio-Signature header")
- }
- return b, nil
- }()
- if err != nil {
- return err
- }
-
- if err := req.ParseForm(); err != nil {
- return err
- }
- ourSig := func() []byte {
- defer h.hash.Reset()
- parts := []string{}
- for k := range req.PostForm {
- parts = append(parts, k)
- }
- sort.Strings(parts)
- for i := range parts {
- parts[i] += req.PostForm[parts[i]][0]
- }
- blob := req.Host + req.URL.Path + strings.Join(parts, "")
- if req.URL.Scheme != "" {
- blob = fmt.Sprintf("%s://%s", req.URL.Scheme, blob)
- }
- h.hash.Write([]byte(blob))
- return h.hash.Sum(nil)
- }()
-
- if !hmac.Equal(ourSig, reqSig) {
- return errors.New("signature mismatch")
- }
- return nil
-}
-
-func newSMSHandler(cfg *Config, sms chan SMS) *smsHandler {
- if cfg.Twilio.AuthToken == "" {
- log.Fatal("Twilio auth token unspecified.")
- }
- if cfg.Twilio.Endpoint == "" {
- log.Fatal("Twilio endpoint unspecified.")
- }
- return &smsHandler{hmac.New(sha1.New, []byte(cfg.Twilio.AuthToken)), sms}
-}
diff --git a/cmd/fwdsms/twilio_test.go b/cmd/fwdsms/twilio_test.go
deleted file mode 100644
index b17bf06..0000000
--- a/cmd/fwdsms/twilio_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org>
-// SPDX-License-Identifier: ISC
-
-package main
-
-import (
- "io/ioutil"
- "net/http"
- "net/url"
- "strings"
- "testing"
- "time"
-
- "github.com/gorilla/handlers"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "go.awhk.org/pipeln"
-)
-
-var handler = newSMSHandler(&Config{
- Twilio: Twilio{
- AuthToken: "token",
- Endpoint: "/endpoint",
- },
-}, make(chan SMS))
-
-func TestSMSHandler_checkRequestSignature_MissingHeader(t *testing.T) {
- req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", nil)
- assert.NoError(t, err)
- assert.EqualError(t, handler.checkRequestSignature(req), "missing X-Twilio-Signature header")
-}
-
-func TestSMSHandler_checkRequestSignature_SignatureBad(t *testing.T) {
- form := url.Values{}
- form.Set("foo", "bar")
- req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", strings.NewReader(form.Encode()))
- assert.NoError(t, err)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Twilio-Signature", "a bad signature")
- assert.NoError(t, req.ParseForm())
- assert.EqualError(t, handler.checkRequestSignature(req), "bad X-Twilio-Signature header")
-}
-
-func TestSMSHandler_checkRequestSignature_SignatureMismatch(t *testing.T) {
- form := url.Values{}
- form.Set("foo", "bar")
- req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", strings.NewReader(form.Encode()))
- assert.NoError(t, err)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Twilio-Signature", "DYIRnXpKIjrgAMxc0FD01B55+ag=")
- assert.NoError(t, req.ParseForm())
- assert.EqualError(t, handler.checkRequestSignature(req), "signature mismatch")
-}
-
-func TestSMSHandler_checkRequestSignature_SignatureGood(t *testing.T) {
- form := url.Values{}
- form.Set("foo", "bar")
- form.Set("bar", "baz")
- req, err := http.NewRequest(http.MethodPost, "/endpoint", strings.NewReader(form.Encode()))
- req.Host = "example.com"
- req.URL.Scheme = "https"
- assert.NoError(t, err)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- // Signature generated with:
- // % echo -n "https://example.com/endpointbarbazfoobar" | openssl dgst -binary -hmac "token" -sha1 | base64
- req.Header.Set("X-Twilio-Signature", "NpKVG88Z4y6ayJIxLJrzgEHeEwY=")
- assert.NoError(t, req.ParseForm())
- assert.NoError(t, handler.checkRequestSignature(req))
-}
-
-func TestSMSHandler_EndToEnd(t *testing.T) {
- mux := http.NewServeMux()
- mux.Handle("/endpoint", handlers.ProxyHeaders(handler))
- srv := http.Server{Handler: mux}
- ln := pipeln.New("localhost.test:80")
- go srv.Serve(ln)
- defer srv.Close()
-
- client := http.Client{Transport: &http.Transport{Dial: ln.Dial}}
- form := url.Values{}
- form.Set("From", "Foo")
- form.Set("To", "Bar")
- form.Set("Body", "Test")
- req, err := http.NewRequest(http.MethodPost, "http://localhost.test:80/endpoint", strings.NewReader(form.Encode()))
- require.NoError(t, err)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Forwarded-Scheme", "http")
-
- t.Run("Bad HTTP Method", func(t *testing.T) {
- resp, err := client.Head("http://localhost.test:80/endpoint")
- require.NoError(t, err)
- assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
- })
-
- t.Run("Bad Signature", func(t *testing.T) {
- req.Header.Set("X-Twilio-Signature", "DYIRnXpKIjrgAMxc0FD01B55+ag=")
- resp, err := client.Do(req)
- require.NoError(t, err)
- assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
- })
-
- t.Run("Good Signature", func(t *testing.T) {
- done := make(chan struct{})
-
- go func() {
- defer close(done)
- select {
- case sms := <-handler.sms:
- assert.Equal(t, "Foo", sms.From)
- assert.Equal(t, "Bar", sms.To)
- assert.Equal(t, "Test", sms.Message)
- case <-time.After(time.Second):
- t.Error("Timed out while waiting on handler.sms.")
- }
- }()
-
- // Signature generated with:
- // % echo -n "http://localhost.test:80/endpointBodyTestFromFooToBar" | openssl dgst -binary -hmac "token" -sha1 | base64
- req.Header.Set("X-Twilio-Signature", "iiifXqv3dP5j8Oj5eB4RAOm/3tI=")
- resp, err := client.Do(req)
- require.NoError(t, err)
- defer resp.Body.Close()
- body, err := ioutil.ReadAll(resp.Body)
- require.NoError(t, err)
-
- assert.Equal(t, http.StatusOK, resp.StatusCode)
- assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
- assert.Equal(t, "<Response/>", string(body))
- <-done
- })
-}
diff --git a/go.mod b/go.mod
index 3bdd67f..1359971 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,8 @@ go 1.15
require (
github.com/gorilla/handlers v1.5.1
+ github.com/gorilla/mux v1.8.0
github.com/stretchr/testify v1.7.0
- go.awhk.org/pipeln v0.1.0
golang.org/x/sys v0.0.0-20210218155724-8ebf48af031b
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)
diff --git a/go.sum b/go.sum
index 634b5a5..5efac10 100644
--- a/go.sum
+++ b/go.sum
@@ -4,14 +4,13 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-go.awhk.org/pipeln v0.1.0 h1:lqH2sV2g1mGg207mo5EvKtEFDg30mQ0JoECrhRl5lp8=
-go.awhk.org/pipeln v0.1.0/go.mod h1:+yf4v5PiMs/Zr9sQlUUPkPA4jULrBADr+hDuAPjtXZU=
golang.org/x/sys v0.0.0-20210218155724-8ebf48af031b h1:lAZ0/chPUDWwjqosYR0X4M490zQhMsiJ4K3DbA7o+3g=
golang.org/x/sys v0.0.0-20210218155724-8ebf48af031b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/pkg/twilio/filter.go b/pkg/twilio/filter.go
new file mode 100644
index 0000000..7d5f6b5
--- /dev/null
+++ b/pkg/twilio/filter.go
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "errors"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+)
+
+var (
+ ErrBase64 = errors.New("failed to decode X-Twilio-Signature header")
+ ErrMissingHeader = errors.New("missing X-Twilio-Signature header")
+ ErrSignatureMismatch = errors.New("signature mismatch")
+)
+
+type Filter struct {
+ AuthToken []byte
+ Handler http.Handler
+}
+
+var _ http.Handler = &Filter{}
+
+func (th *Filter) CheckRequestSignature(r *http.Request) error {
+ hdr := r.Header.Get("X-Twilio-Signature")
+ if len(hdr) == 0 {
+ return ErrMissingHeader
+ }
+ reqSig, err := base64.StdEncoding.DecodeString(hdr)
+ if err != nil {
+ return ErrBase64
+ }
+
+ // See https://www.twilio.com/docs/usage/security#validating-requests
+ // for more details.
+
+ parts := []string{}
+ if r.Method == http.MethodPost {
+ if err := r.ParseForm(); err != nil {
+ return err
+ }
+ for k := range r.PostForm {
+ parts = append(parts, k)
+ }
+ sort.Strings(parts)
+ for i, k := range parts {
+ parts[i] += r.PostForm[k][0]
+ }
+ }
+ s := r.URL.String() + strings.Join(parts, "")
+ h := hmac.New(sha1.New, th.AuthToken)
+ h.Write([]byte(s))
+ ourSig := h.Sum(nil)
+
+ if !hmac.Equal(reqSig, ourSig) {
+ return ErrSignatureMismatch
+ }
+ return nil
+}
+
+func (th *Filter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if err := th.CheckRequestSignature(r); err != nil {
+ log.Println("Failed to check Twilio signature:", err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ th.Handler.ServeHTTP(w, r)
+}
diff --git a/pkg/twilio/filter_test.go b/pkg/twilio/filter_test.go
new file mode 100644
index 0000000..c0c737c
--- /dev/null
+++ b/pkg/twilio/filter_test.go
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFilter_CheckRequestSignature(t *testing.T) {
+ th := &Filter{[]byte("token"), EmptyResponseHandler}
+
+ t.Run("Good Signature (POST)", func(t *testing.T) {
+ assert.NoError(t, th.CheckRequestSignature(newRequest(Post)))
+ })
+
+ t.Run("Good Signature (GET)", func(t *testing.T) {
+ assert.NoError(t, th.CheckRequestSignature(newRequest(Get)))
+ })
+
+ t.Run("Missing Header", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Del("X-Twilio-Signature")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrMissingHeader)
+ })
+
+ t.Run("Bad Base64", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "Very suspicious Base64 header.")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrBase64)
+ })
+
+ t.Run("Signature Mismatch", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "dpE7iSS3LEQo72hCT34eBRt3UEI=")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrSignatureMismatch)
+ })
+}
+
+func TestFilter_ServeHTTP(t *testing.T) {
+ th := &Filter{[]byte("token"), EmptyResponseHandler}
+
+ t.Run("Good Signature (POST)", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ th.ServeHTTP(w, newRequest(Post))
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "text/xml", w.HeaderMap.Get("Content-Type"))
+ assert.Equal(t, "<Response/>", w.Body.String())
+ })
+
+ t.Run("Good Signature (GET)", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ th.ServeHTTP(w, newRequest(Get))
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "text/xml", w.HeaderMap.Get("Content-Type"))
+ assert.Equal(t, "<Response/>", w.Body.String())
+ })
+
+ t.Run("Missing Header", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Del("X-Twilio-Signature")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("Bad Base64", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "Very suspicious Base64 header.")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("Signature Mismatch", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "dpE7iSS3LEQo72hCT34eBRt3UEI=")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+}
+
+const (
+ Get = true
+ Post = false
+)
+
+// X-Twilio-Signature can be manually generated with:
+// % echo -n "${SOME_STRING}" | openssl dgst -binary -hmac ${AUTH_TOKEN} -sha1 | base64
+
+func newRequest(get bool) *http.Request {
+ vals := url.Values{
+ "To": {"Bob"},
+ "From": {"Alice"},
+ "Body": {"A random message."},
+ }.Encode()
+ if get {
+ r := httptest.NewRequest(http.MethodGet, "https://example.test/endpoint?"+vals, nil)
+ r.Header.Set("X-Twilio-Signature", "Hh0ReTk/+7Ea38qZ3Xt1/NQx4i4=")
+ return r
+ }
+ rd := strings.NewReader(vals)
+ r := httptest.NewRequest(http.MethodPost, "https://example.test/endpoint", rd)
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("X-Twilio-Signature", "j61PPnnoUAAsfEnLuwUefOfylf4=")
+ return r
+}
diff --git a/pkg/twilio/handler.go b/pkg/twilio/handler.go
new file mode 100644
index 0000000..11cb0b8
--- /dev/null
+++ b/pkg/twilio/handler.go
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
diff --git a/pkg/twilio/sms.go b/pkg/twilio/sms.go
new file mode 100644
index 0000000..0a7dba8
--- /dev/null
+++ b/pkg/twilio/sms.go
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "net/http"
+ "time"
+)
+
+type SMS struct {
+ DateReceived time.Time
+ From, To, Body string
+}
+
+type SMSTee struct {
+ Chan chan<- SMS
+ Handler http.Handler
+}
+
+var _ http.Handler = &SMSTee{}
+
+func (th *SMSTee) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ select {
+ case th.Chan <- SMS{
+ DateReceived: time.Now(),
+ From: r.FormValue("From"),
+ To: r.FormValue("To"),
+ Body: r.FormValue("Body"),
+ }:
+ th.Handler.ServeHTTP(w, r)
+ case <-r.Context().Done():
+ }
+}
diff --git a/pkg/twilio/util.go b/pkg/twilio/util.go
new file mode 100644
index 0000000..1f1d269
--- /dev/null
+++ b/pkg/twilio/util.go
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// EmptyResponseHandler writes an empty XML response so Twilio knows not
+// to do anything after a webhook has been called.
+var EmptyResponseHandler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/xml")
+ fmt.Fprint(w, "<Response/>")
+})