├── .gitignore ├── LICENSE ├── README.md ├── shim.go └── shim_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /shim 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Hans Nielsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sendmail-shim 2 | ============= 3 | 4 | This is a small utility that logs outgoing email directly to a file. No SMTP, mail servers, or networking is involved. 5 | 6 | Useful when you have a centralized log collector and want to keep records of outbound email in a scalable and searchable fashion. 7 | 8 | See https://www.stackallocated.com/blog/2019/stop-using-smtp for more words about this. 9 | 10 | Building and installing 11 | ----------------------- 12 | This program only uses the Go standard library and should work with older versions of Go. It has been tested on Go 1.11 and later. 13 | ``` 14 | $ go build shim.go 15 | $ sudo -s 16 | # chown root:root shim 17 | # chmod u+s shim 18 | # mv shim /usr/sbin/sendmail 19 | ``` 20 | 21 | If you want to change the log filename, edit the line in `shim.go`. 22 | 23 | Running 24 | ------- 25 | ``` 26 | $ /usr/sbin/sendmail foo@example.com 27 | Subject: Test 28 | 29 | Hello World 30 | ^D 31 | $ tail -1 /var/log/sendmail-shim.log.json 32 | {"time":"2019-07-28T09:25:47Z","uid":"501","username":"hans","arguments":["foo@example.com"],"body":"Subject: Test\n\nHello World\n"} 33 | $ 34 | ``` 35 | 36 | Testing 37 | ------- 38 | ``` 39 | $ go test 40 | ``` 41 | -------------------------------------------------------------------------------- /shim.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/user" 11 | "time" 12 | ) 13 | 14 | type LogEntry struct { 15 | Time string `json:"time"` 16 | UserID string `json:"uid"` 17 | Username string `json:"username,omitempty"` 18 | Arguments []string `json:"arguments"` 19 | Body string `json:"body"` 20 | } 21 | 22 | type LogError struct { 23 | Err error 24 | Tag string 25 | } 26 | 27 | // Returns (uid, username) 28 | type UsernameFunc func() (string, string) 29 | 30 | func GetUsername() (uid string, username string) { 31 | // get calling user ID and name 32 | u, err := user.Current() 33 | if err == nil { 34 | return u.Uid, u.Username 35 | } 36 | 37 | // just return the user ID 38 | return fmt.Sprintf("%d", os.Getuid()), "" 39 | } 40 | 41 | type TimeFunc func() string 42 | 43 | func GetTime() string { 44 | return time.Now().UTC().Format(time.RFC3339) 45 | } 46 | 47 | func OpenLogFile(path string) (*os.File, *LogError) { 48 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) 49 | if err != nil { 50 | return nil, &LogError{ 51 | fmt.Errorf("couldn't open log file: %v", err), 52 | "open-log-file", 53 | } 54 | } 55 | return f, nil 56 | } 57 | 58 | type EmailLogger struct { 59 | Args []string 60 | Body io.Reader 61 | User UsernameFunc 62 | Time TimeFunc 63 | Writer io.Writer 64 | LogPath string // used if there's no Writer 65 | } 66 | 67 | func (l *EmailLogger) Populate(e *LogEntry) *LogError { 68 | // set the time 69 | e.Time = l.Time() 70 | 71 | // populate the uid and username 72 | uid, username := l.User() 73 | e.UserID = uid 74 | e.Username = username 75 | 76 | // just use the full arguments list minus the program name 77 | e.Arguments = l.Args 78 | 79 | // read stdin 80 | body, err := ioutil.ReadAll(l.Body) 81 | if err != nil { 82 | return &LogError{ 83 | fmt.Errorf("couldn't read stdin: %v", err), 84 | "stdin-failed", 85 | } 86 | } 87 | e.Body = string(body) 88 | 89 | return nil 90 | } 91 | 92 | func (l *EmailLogger) EncodeJSON(e LogEntry) *LogError { 93 | j := json.NewEncoder(l.Writer) 94 | err := j.Encode(e) 95 | if err != nil { 96 | return &LogError{ 97 | fmt.Errorf("couldn't encode JSON: %v", err), 98 | "json-encoding", 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | func (l *EmailLogger) Emit() *LogError { 105 | if l.Writer == nil { 106 | // open the log file if there's no writer 107 | f, err := OpenLogFile(l.LogPath) 108 | if err != nil { 109 | return err 110 | } 111 | l.Writer = f 112 | defer func() { 113 | l.Writer = nil 114 | f.Close() 115 | }() 116 | } 117 | 118 | // build the log entry 119 | entry := LogEntry{} 120 | err := l.Populate(&entry) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | // write out JSON 126 | err = l.EncodeJSON(entry) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | // emit success metrics here if you want! 132 | // 133 | // metrics.Increment("sendmail-shim.success", 1, map[string]string{"uid": entry.UserID}) 134 | 135 | return nil 136 | } 137 | 138 | func main() { 139 | l := EmailLogger{ 140 | LogPath: "/var/log/sendmail-shim.log.json", 141 | Args: os.Args[1:], 142 | Body: os.Stdin, 143 | User: GetUsername, 144 | Time: GetTime, 145 | } 146 | err := l.Emit() 147 | if err != nil { 148 | // emit failure metrics here if you want! 149 | // 150 | // metrics.Increment("sendmail-shim.error", 1, map[string]string{"reason": err.Tag}) 151 | 152 | log.Fatal(err.Err) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /shim_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestOpenLogFile(t *testing.T) { 14 | // test file open failure 15 | f, lerr := OpenLogFile("") 16 | if f != nil { 17 | t.Error("shouldn't open an empty filename") 18 | } 19 | if lerr.Tag != "open-log-file" { 20 | t.Errorf("invalid error tag %q", lerr.Tag) 21 | } 22 | if lerr.Err == nil { 23 | t.Error("should have an error") 24 | } 25 | 26 | dir, err := ioutil.TempDir("", "log-file-test") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | defer os.RemoveAll(dir) 31 | 32 | // test creating a file 33 | n := path.Join(dir, "test.log") 34 | f, lerr = OpenLogFile(n) 35 | if lerr != nil { 36 | t.Fatal(lerr.Err) 37 | } 38 | if f == nil { 39 | t.Fatal("expected a file") 40 | } 41 | _, err = f.WriteString("foo\n") 42 | f.Close() 43 | 44 | // test writing to an existing file 45 | f, lerr = OpenLogFile(n) 46 | if lerr != nil { 47 | t.Fatal(lerr.Err) 48 | } 49 | if f == nil { 50 | t.Fatal("expected a file") 51 | } 52 | _, err = f.WriteString("bar\n") 53 | f.Close() 54 | 55 | // verify that the file has the right stuff in it 56 | contents, err := ioutil.ReadFile(n) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | expected := "foo\nbar\n" 61 | if string(contents) != expected { 62 | t.Errorf("expected %q, got %q", expected, contents) 63 | } 64 | } 65 | 66 | type ErrorWriter struct{} 67 | 68 | func (_ ErrorWriter) Write(_ []byte) (int, error) { 69 | return 0, fmt.Errorf("oh no") 70 | } 71 | 72 | func TestEncodeJSON(t *testing.T) { 73 | // test JSON encoding errors 74 | l1 := EmailLogger{ 75 | Writer: ErrorWriter{}, 76 | } 77 | lerr := l1.EncodeJSON(LogEntry{}) 78 | if lerr == nil { 79 | t.Fatal("expected an error") 80 | } 81 | if lerr.Tag != "json-encoding" { 82 | t.Fatalf("unexpected tag %q", lerr.Tag) 83 | } 84 | 85 | // test expected encoding 86 | e := LogEntry{ 87 | Time: "2009-11-10T23:00:00Z", 88 | UserID: "123", 89 | Username: "foo", 90 | Arguments: []string{"yay", "asdf"}, 91 | Body: "stuff", 92 | } 93 | expected := "{\"time\":\"2009-11-10T23:00:00Z\",\"uid\":\"123\",\"username\":\"foo\",\"arguments\":[\"yay\",\"asdf\"],\"body\":\"stuff\"}\n" 94 | b := bytes.Buffer{} 95 | l2 := EmailLogger{ 96 | Writer: &b, 97 | } 98 | lerr = l2.EncodeJSON(e) 99 | if lerr != nil { 100 | t.Fatal(lerr.Err) 101 | } 102 | actual := b.String() 103 | if expected != actual { 104 | t.Fatalf("expected %q, got %q", expected, actual) 105 | } 106 | } 107 | 108 | type ErrorReader struct{} 109 | 110 | func (_ ErrorReader) Read(_ []byte) (int, error) { 111 | return 0, fmt.Errorf("oh no") 112 | } 113 | 114 | func ConstUsername() (string, string) { 115 | return "123", "foobar" 116 | } 117 | 118 | func ConstTime() string { 119 | return "2009-11-10T23:00:00Z" 120 | } 121 | 122 | func TestPopulateEntry(t *testing.T) { 123 | // test stdin read failure 124 | l1 := EmailLogger{ 125 | Body: ErrorReader{}, 126 | User: ConstUsername, 127 | Time: ConstTime, 128 | } 129 | e1 := LogEntry{} 130 | lerr := l1.Populate(&e1) 131 | if lerr == nil { 132 | t.Fatal("expected an error") 133 | } 134 | if lerr.Tag != "stdin-failed" { 135 | t.Fatalf("unexpected tag %q", lerr.Tag) 136 | } 137 | 138 | // test entry population 139 | l2 := EmailLogger{ 140 | Body: strings.NewReader("hello"), 141 | User: ConstUsername, 142 | Time: ConstTime, 143 | Args: []string{"yay", "stuff"}, 144 | } 145 | e2 := LogEntry{} 146 | lerr = l2.Populate(&e2) 147 | if lerr != nil { 148 | t.Fatal(lerr.Err) 149 | } 150 | if e2.Time != "2009-11-10T23:00:00Z" { 151 | t.Errorf("bad time %q", e2.Time) 152 | } 153 | if e2.UserID != "123" { 154 | t.Errorf("bad user ID %q", e2.UserID) 155 | } 156 | if e2.Username != "foobar" { 157 | t.Errorf("bad username %q", e2.Username) 158 | } 159 | if e2.Body != "hello" { 160 | t.Errorf("bad body %q", e2.Body) 161 | } 162 | if a := e2.Arguments; len(a) != 2 || a[0] != "yay" || a[1] != "stuff" { 163 | t.Errorf("bad arguments %v", e2.Arguments) 164 | } 165 | } 166 | 167 | func TestEmit(t *testing.T) { 168 | dir, err := ioutil.TempDir("", "emit-test") 169 | if err != nil { 170 | t.Fatal(err) 171 | } 172 | defer os.RemoveAll(dir) 173 | n := path.Join(dir, "test.log.json") 174 | 175 | l := EmailLogger{ 176 | LogPath: n, 177 | Args: []string{"fro", "bozz"}, 178 | Body: strings.NewReader("hello\nworld\n"), 179 | User: ConstUsername, 180 | Time: ConstTime, 181 | } 182 | lerr := l.Emit() 183 | if lerr != nil { 184 | t.Fatal(err) 185 | } 186 | 187 | b, err := ioutil.ReadFile(n) 188 | if err != nil { 189 | t.Fatal(err) 190 | } 191 | expected := "{\"time\":\"2009-11-10T23:00:00Z\",\"uid\":\"123\",\"username\":\"foobar\",\"arguments\":[\"fro\",\"bozz\"],\"body\":\"hello\\nworld\\n\"}\n" 192 | if string(b) != expected { 193 | t.Fatalf("got %q, expected %q", b, expected) 194 | } 195 | } 196 | --------------------------------------------------------------------------------