diff options
| author | Grégoire Duchêne <gduchene@awhk.org> | 2020-11-29 13:43:55 +0000 |
|---|---|---|
| committer | Grégoire Duchêne <gduchene@awhk.org> | 2020-11-29 18:00:37 +0000 |
| commit | 5277c7123524f5cccbf5ce62f5aa93b4bed68b10 (patch) | |
| tree | 939ede863a41db0ffd2080f80600197458f8b7dc | |
| parent | a5776d48925b1429b4704c11251220ab7143850f (diff) | |
First draft of the service
This version is pretty basic but it works.
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | cmd/fwdsms/config.go | 48 | ||||
| -rw-r--r-- | cmd/fwdsms/mailer.go | 118 | ||||
| -rw-r--r-- | cmd/fwdsms/mailer_test.go | 45 | ||||
| -rw-r--r-- | cmd/fwdsms/main.go | 73 | ||||
| -rw-r--r-- | cmd/fwdsms/twilio.go | 115 | ||||
| -rw-r--r-- | cmd/fwdsms/twilio_test.go | 62 | ||||
| -rw-r--r-- | configs/example.yaml | 20 | ||||
| -rw-r--r-- | deployments/archlinux/PKGBUILD | 26 | ||||
| -rw-r--r-- | deployments/docker/Dockerfile | 13 | ||||
| -rw-r--r-- | deployments/systemd/fwdsms.service | 13 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 19 |
13 files changed, 566 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a55dc0 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# `fwdsms` + +`fwdsms` is a simple tool that acts as a Twilio SMS webhook that +forwards every SMS to an arbitrary email address. diff --git a/cmd/fwdsms/config.go b/cmd/fwdsms/config.go new file mode 100644 index 0000000..0a50901 --- /dev/null +++ b/cmd/fwdsms/config.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +// SPDX-License-Identifier: ISC + +package main + +import ( + "io/ioutil" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Message Message `yaml:"message"` + SMTP SMTP `yaml:"smtp"` + Twilio Twilio `yaml:"twilio"` +} + +type Message struct { + From string `yaml:"from"` + To string `yaml:"to"` + Subject string `yaml:"subject"` + Template string `yaml:"template"` +} + +type SMTP struct { + Address string `yaml:"hostname"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type Twilio struct { + Address string `yaml:"address"` + AuthToken string `yaml:"authToken"` + Endpoint string `yaml:"endpoint"` +} + +func loadConfig(filename string) (*Config, error) { + b, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + cfg := &Config{} + err = yaml.Unmarshal(b, cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/cmd/fwdsms/mailer.go b/cmd/fwdsms/mailer.go new file mode 100644 index 0000000..b61c981 --- /dev/null +++ b/cmd/fwdsms/mailer.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +// SPDX-License-Identifier: ISC + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "log" + "net" + "net/smtp" + "text/template" + "time" +) + +type email struct { + from, to string + body []byte +} + +type mailer struct { + auth smtp.Auth + hostname string + sms <-chan SMS + tmplFrom, tmplTo, tmplMsg *template.Template +} + +func (m *mailer) sendEmail(e email) error { + dialer := &net.Dialer{Timeout: time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", m.hostname, nil) + if err != nil { + return err + } + conn.SetDeadline(time.Now().Add(5 * time.Second)) + h, _, _ := net.SplitHostPort(m.hostname) + c, err := smtp.NewClient(conn, h) + if err != nil { + conn.Close() + return err + } + defer c.Close() + if err := c.Auth(m.auth); err != nil { + return err + } + + if err := c.Mail(e.from); err != nil { + return err + } + if err := c.Rcpt(e.to); err != nil { + return err + } + w, err := c.Data() + if err != nil { + return err + } + if _, err := w.Write(e.body); err != nil { + return err + } + if err = w.Close(); err != nil { + return nil + } + return c.Quit() +} + +func (m *mailer) newEmail(sms 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) + } + if err := m.tmplTo.Execute(&to, sms); err != nil { + log.Printf("Failed to apply a template: %v.", err) + } + if err := m.tmplMsg.Execute(&msg, sms); err != nil { + log.Printf("Failed to apply a template: %v.", err) + } + return email{from.String(), to.String(), msg.Bytes()} +} + +func (m *mailer) start(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case sms := <-m.sms: + if err := m.sendEmail(m.newEmail(sms)); err != nil { + log.Printf("Failed to send email: %v.", err) + } + } + } +} + +func newMailer(cfg *Config, sms <-chan SMS) *mailer { + if cfg.Message.From == "" { + log.Fatal("Missing From field.") + } + if cfg.Message.To == "" { + log.Fatal("Missing To field.") + } + if cfg.Message.Subject == "" { + log.Fatal("Missing Subject field.") + } + if cfg.Message.Template == "" { + log.Fatal("Missing Template field.") + } + tmplMsg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n", + cfg.Message.From, cfg.Message.To, cfg.Message.Subject, cfg.Message.Template) + host, _, _ := net.SplitHostPort(cfg.SMTP.Address) + return &mailer{ + auth: smtp.PlainAuth("", cfg.SMTP.Username, cfg.SMTP.Password, host), + hostname: cfg.SMTP.Address, + sms: sms, + tmplFrom: template.Must(template.New("from").Parse(cfg.Message.From)), + tmplTo: template.Must(template.New("to").Parse(cfg.Message.To)), + tmplMsg: template.Must(template.New("message").Parse(tmplMsg)), + } +} diff --git a/cmd/fwdsms/mailer_test.go b/cmd/fwdsms/mailer_test.go new file mode 100644 index 0000000..b537ffb --- /dev/null +++ b/cmd/fwdsms/mailer_test.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +// SPDX-License-Identifier: ISC + +package main + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMailer_newEmail(t *testing.T) { + m := newMailer(&Config{ + Message: Message{ + From: "fwdsms@example.com", + To: "sms{{.To}}@example.com", + Subject: "New SMS From {{.From}}", + Template: `From: {{.From}} + To: {{.To}} +Date: {{.Date.UTC}} + +{{.Message}}`, + }}, nil) + // Reserved phone numbers, see Ofcom's website. + sms := SMS{time.Unix(0, 0), "+442079460123", "+447700900123", "Hello World!"} + wants := email{ + from: "fwdsms@example.com", + to: "sms+447700900123@example.com", + body: []byte(strings.Join([]string{ + "From: fwdsms@example.com", + "To: sms+447700900123@example.com", + "Subject: New SMS From +442079460123", + "", + `From: +442079460123 + To: +447700900123 +Date: 1970-01-01 00:00:00 +0000 UTC + +Hello World!`, + "", + }, "\r\n")), + } + assert.Equal(t, wants, m.newEmail(sms)) +} diff --git a/cmd/fwdsms/main.go b/cmd/fwdsms/main.go new file mode 100644 index 0000000..47af33a --- /dev/null +++ b/cmd/fwdsms/main.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +// SPDX-License-Identifier: ISC + +package main + +import ( + "context" + "flag" + "log" + "net" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gorilla/handlers" + "golang.org/x/sys/unix" +) + +var cfgFilename = flag.String("c", "/etc/fwdsms.yaml", "configuration file") + +func main() { + flag.Parse() + log.SetFlags(0) + cfg, err := loadConfig(*cfgFilename) + if err != nil { + log.Fatalf("Could not load the configuration: %v.", err) + } + + 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} + go func() { + var ( + l net.Listener + err error + ) + if cfg.Twilio.Address != "" && cfg.Twilio.Address[0] == '/' { + if l, err = net.Listen("unix", cfg.Twilio.Address); err != nil { + log.Fatalf("Could not set up UNIX listener: %v.", err) + } + if err = os.Chmod(cfg.Twilio.Address, 0666); err != nil { + log.Fatalf("Could not set up permissions on UNIX socket: %v.", err) + } + } else { + if cfg.Twilio.Address == "" { + cfg.Twilio.Address = ":8080" + } + if l, err = net.Listen("tcp", cfg.Twilio.Address); err != nil { + log.Fatalf("Could not set up TCP listener: %v.", err) + } + } + if err = srv.Serve(l); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to serve HTTP: %v.", err) + } + }() + + mailer := newMailer(cfg, sms) + ctx, cancel := context.WithCancel(context.Background()) + go mailer.start(ctx) + + <-done + cancel() + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to properly shut down the HTTP server: %v.", err) + } +} diff --git a/cmd/fwdsms/twilio.go b/cmd/fwdsms/twilio.go new file mode 100644 index 0000000..6e7dc0c --- /dev/null +++ b/cmd/fwdsms/twilio.go @@ -0,0 +1,115 @@ +// 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, ok := req.Header["X-Twilio-Signature"] + if !ok || len(h) == 0 { + return nil, errors.New("missing X-Twilio-Signature header") + } + b, err := base64.StdEncoding.DecodeString(h[0]) + 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.RequestURI + 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 new file mode 100644 index 0000000..38a34b4 --- /dev/null +++ b/cmd/fwdsms/twilio_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +// SPDX-License-Identifier: ISC + +package main + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +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), "request 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, "https://example.com/endpoint", strings.NewReader(form.Encode())) + 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)) +} diff --git a/configs/example.yaml b/configs/example.yaml new file mode 100644 index 0000000..a4caa57 --- /dev/null +++ b/configs/example.yaml @@ -0,0 +1,20 @@ +message: + from: foo@example.com + to: bar@example.com + subject: New SMS From {{.From}} For {{.To}} + template: | + From: {{.From}} + To: {{.To}} + Date: {{.Date}} + + {{.Message}} + +smtp: + hostname: example.com:465 + username: bar + password: some password + +twilio: + address: /run/fwdsms/socket + authToken: some token + endpoint: / diff --git a/deployments/archlinux/PKGBUILD b/deployments/archlinux/PKGBUILD new file mode 100644 index 0000000..c5d254c --- /dev/null +++ b/deployments/archlinux/PKGBUILD @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +# SPDX-License-Identifier: ISC + +# Maintainer: Grégoire Duchêne <gduchene@awhk.org> +pkgname=fwdsms +pkgver=0.1 +pkgrel=1 +arch=(x86_64) +url=https://github.com/gduchene/fwdsms +license=(custom:ISC) +makedepends=(go) +source=(git://github.com/gduchene/fwdsms.git) +sha256sums=(SKIP) + +build() { + cd ${pkgname}/cmd/${pkgname} + go build +} + +package() { + cd ${pkgname} + install -Dm755 cmd/${pkgname}/${pkgname} ${pkgdir}/usr/bin/${pkgname} + install -Dm644 configs/example.yaml ${pkgdir}/etc/${pkgname}.yaml + install -Dm644 deployments/systemd/${pkgname}.service ${pkgdir}/usr/lib/systemd/system/${pkgname}.service + install -Dm644 LICENSE ${pkgdir}/usr/share/licenses/${pkgname}/LICENSE +} diff --git a/deployments/docker/Dockerfile b/deployments/docker/Dockerfile new file mode 100644 index 0000000..0246f1f --- /dev/null +++ b/deployments/docker/Dockerfile @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +# SPDX-License-Identifier: ISC + +FROM golang:1.15 +WORKDIR /root +COPY . ./ +RUN go install ... + +FROM scratch +COPY --from=0 /go/bin/fwdsms /usr/bin/fwdsms + +EXPOSE 8008 +ENTRYPOINT ["/usr/bin/fwdsms"] diff --git a/deployments/systemd/fwdsms.service b/deployments/systemd/fwdsms.service new file mode 100644 index 0000000..a095805 --- /dev/null +++ b/deployments/systemd/fwdsms.service @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> +# SPDX-License-Identifier: ISC + +[Unit] +Description=SMS-to-email Forwarder + +[Service] +ExecStart=fwdsms +DynamicUser=true +RuntimeDirectory=fwdsms + +[Install] +WantedBy=multi-user.target @@ -0,0 +1,10 @@ +module go.awhk.org/fwdsms + +go 1.15 + +require ( + github.com/gorilla/handlers v1.5.1 + github.com/stretchr/testify v1.6.1 + golang.org/x/sys v0.0.0-20201126233918-771906719818 + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 +) @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +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/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.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20201126233918-771906719818 h1:f1CIuDlJhwANEC2MM87MBEVMr3jl5bifgsfj90XAF9c= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
