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 /cmd | |
| parent | a5776d48925b1429b4704c11251220ab7143850f (diff) | |
First draft of the service
This version is pretty basic but it works.
Diffstat (limited to 'cmd')
| -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 |
6 files changed, 461 insertions, 0 deletions
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)) +} |
