aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--cmd/fwdsms/config.go48
-rw-r--r--cmd/fwdsms/mailer.go118
-rw-r--r--cmd/fwdsms/mailer_test.go45
-rw-r--r--cmd/fwdsms/main.go73
-rw-r--r--cmd/fwdsms/twilio.go115
-rw-r--r--cmd/fwdsms/twilio_test.go62
-rw-r--r--configs/example.yaml20
-rw-r--r--deployments/archlinux/PKGBUILD26
-rw-r--r--deployments/docker/Dockerfile13
-rw-r--r--deployments/systemd/fwdsms.service13
-rw-r--r--go.mod10
-rw-r--r--go.sum19
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
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f7e3092
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b0ceda5
--- /dev/null
+++ b/go.sum
@@ -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=