1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 cryptorand "crypto/rand"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/pem"
12 "fmt"
13 golog "log"
14 "log/slog"
15 "math/big"
16 "net"
17 "os"
18 "os/signal"
19 "path/filepath"
20 "runtime"
21 "syscall"
22 "time"
23
24 "golang.org/x/crypto/bcrypt"
25
26 "github.com/mjl-/sconf"
27
28 "github.com/mjl-/mox/config"
29 "github.com/mjl-/mox/dkim"
30 "github.com/mjl-/mox/dns"
31 "github.com/mjl-/mox/junk"
32 "github.com/mjl-/mox/mlog"
33 "github.com/mjl-/mox/mox-"
34 "github.com/mjl-/mox/moxvar"
35 "github.com/mjl-/mox/queue"
36 "github.com/mjl-/mox/smtpserver"
37 "github.com/mjl-/mox/store"
38)
39
40func cmdLocalserve(c *cmd) {
41 c.help = `Start a local SMTP/IMAP server that accepts all messages, useful when testing/developing software that sends email.
42
43Localserve starts mox with a configuration suitable for local email-related
44software development/testing. It listens for SMTP/Submission(s), IMAP(s) and
45HTTP(s), on the regular port numbers + 1000.
46
47Data is stored in the system user's configuration directory under
48"mox-localserve", e.g. $HOME/.config/mox-localserve/ on linux, but can be
49overridden with the -dir flag. If the directory does not yet exist, it is
50automatically initialized with configuration files, an account with email
51address mox@localhost and password moxmoxmox, and a newly generated self-signed
52TLS certificate.
53
54Incoming messages are delivered as normal, falling back to accepting and
55delivering to the mox account for unknown addresses.
56Submitted messages are added to the queue, which delivers by ignoring the
57destination servers, always connecting to itself instead.
58
59Recipient addresses with the following localpart suffixes are handled specially:
60
61- "temperror": fail with a temporary error code
62- "permerror": fail with a permanent error code
63- [45][0-9][0-9]: fail with the specific error code
64- "timeout": no response (for an hour)
65
66If the localpart begins with "mailfrom" or "rcptto", the error is returned
67during those commands instead of during "data".
68`
69 golog.SetFlags(0)
70
71 userConfDir, _ := os.UserConfigDir()
72 if userConfDir == "" {
73 userConfDir = "."
74 }
75 // If we are being run to gather help output, show a placeholder directory
76 // instead of evaluating to the actual userconfigdir on the host os.
77 if c._gather {
78 userConfDir = "$userconfigdir"
79 }
80
81 var dir, ip string
82 var initOnly bool
83 c.flag.StringVar(&dir, "dir", filepath.Join(userConfDir, "mox-localserve"), "configuration storage directory")
84 c.flag.StringVar(&ip, "ip", "", "serve on this ip instead of default 127.0.0.1 and ::1. only used when writing configuration, at first launch.")
85 c.flag.BoolVar(&initOnly, "initonly", false, "write configuration files and exit")
86 args := c.Parse()
87 if len(args) != 0 {
88 c.Usage()
89 }
90
91 log := c.log
92 mox.FilesImmediate = true
93
94 if initOnly {
95 if _, err := os.Stat(dir); err == nil {
96 log.Print("warning: directory for configuration files already exists, continuing")
97 }
98 log.Print("creating mox localserve config", slog.String("dir", dir))
99 err := writeLocalConfig(log, dir, ip)
100 if err != nil {
101 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
102 }
103 return
104 }
105
106 // Load config, creating a new one if needed.
107 var existingConfig bool
108 if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
109 err := writeLocalConfig(log, dir, ip)
110 if err != nil {
111 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
112 }
113 } else if err != nil {
114 log.Fatalx("stat config dir", err, slog.String("dir", dir))
115 } else if err := localLoadConfig(log, dir); err != nil {
116 log.Fatalx("loading mox localserve config (hint: when creating a new config with -dir, the directory must not yet exist)", err, slog.String("dir", dir))
117 } else if ip != "" {
118 log.Fatal("can only use -ip when writing a new config file")
119 } else {
120 existingConfig = true
121 }
122
123 if level, ok := mlog.Levels[loglevel]; loglevel != "" && ok {
124 mox.Conf.Log[""] = level
125 mlog.SetConfig(mox.Conf.Log)
126 } else if loglevel != "" && !ok {
127 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
128 }
129
130 // Initialize receivedid.
131 recvidbuf, err := os.ReadFile(filepath.Join(dir, "receivedid.key"))
132 if err == nil && len(recvidbuf) != 16+8 {
133 err = fmt.Errorf("bad length %d, need 16+8", len(recvidbuf))
134 }
135 if err != nil {
136 log.Errorx("reading receivedid.key", err)
137 recvidbuf = make([]byte, 16+8)
138 _, err := cryptorand.Read(recvidbuf)
139 if err != nil {
140 log.Fatalx("read random recvid key", err)
141 }
142 }
143 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
144 log.Fatalx("init receivedid", err)
145 }
146
147 // Make smtp server accept all email and deliver to account "mox".
148 smtpserver.Localserve = true
149 // Tell queue it shouldn't be queuing/delivering.
150 queue.Localserve = true
151 // Tell DKIM not to fail signatures for TLD localhost.
152 dkim.Localserve = true
153
154 const mtastsdbRefresher = false
155 const sendDMARCReports = false
156 const sendTLSReports = false
157 const skipForkExec = true
158 if err := start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec); err != nil {
159 log.Fatalx("starting mox", err)
160 }
161 golog.Printf("mox, version %s, %s %s/%s", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
162 golog.Print("")
163 golog.Printf("the default user is mox@localhost, with password moxmoxmox")
164 golog.Printf("the default admin password is moxadmin")
165 golog.Printf("port numbers are those common for the services + 1000")
166 golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
167 golog.Printf("all incoming email to any address is accepted (if checks pass), unless the recipient localpart ends with:")
168 golog.Print("")
169 golog.Printf(`- "temperror": fail with a temporary error code.`)
170 golog.Printf(`- "permerror": fail with a permanent error code.`)
171 golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
172 golog.Printf(`- "timeout": no response (for an hour).`)
173 golog.Print("")
174 golog.Print(`if the localpart begins with "mailfrom" or "rcptto", the error is returned`)
175 golog.Print(`during those commands instead of during "data". if the localpart begins with`)
176 golog.Print(`"queue", the submission is accepted but delivery from the queue will fail.`)
177 golog.Print("")
178 golog.Print(" smtp://localhost:1025 - receive email")
179 golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
180 golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
181 golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
182 golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
183 golog.Print("https://localhost:1443/account/ - account https (email mox@localhost, password moxmoxmox)")
184 golog.Print(" http://localhost:1080/account/ - account http (without tls)")
185 golog.Print("https://localhost:1443/webmail/ - webmail https (email mox@localhost, password moxmoxmox)")
186 golog.Print(" http://localhost:1080/webmail/ - webmail http (without tls)")
187 golog.Print("https://localhost:1443/webapi/ - webmail https (email mox@localhost, password moxmoxmox)")
188 golog.Print(" http://localhost:1080/webapi/ - webmail http (without tls)")
189 golog.Print("https://localhost:1443/admin/ - admin https (password moxadmin)")
190 golog.Print(" http://localhost:1080/admin/ - admin http (without tls)")
191 golog.Print("")
192 if existingConfig {
193 golog.Printf("serving from existing config dir %s/", dir)
194 golog.Printf("if urls above don't work, consider resetting by removing config dir")
195 } else {
196 golog.Printf("serving from newly created config dir %s/", dir)
197 }
198
199 ctlpath := mox.DataDirPath("ctl")
200 _ = os.Remove(ctlpath)
201 ctl, err := net.Listen("unix", ctlpath)
202 if err != nil {
203 log.Fatalx("listen on ctl unix domain socket", err)
204 }
205 go func() {
206 for {
207 conn, err := ctl.Accept()
208 if err != nil {
209 log.Printx("accept for ctl", err)
210 continue
211 }
212 cid := mox.Cid()
213 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
214 go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
215 }
216 }()
217
218 // Graceful shutdown.
219 sigc := make(chan os.Signal, 1)
220 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
221 sig := <-sigc
222 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
223 shutdown(log)
224 if num, ok := sig.(syscall.Signal); ok {
225 os.Exit(int(num))
226 } else {
227 os.Exit(1)
228 }
229}
230
231func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
232 defer func() {
233 x := recover()
234 if x != nil {
235 if err, ok := x.(error); ok {
236 rerr = err
237 }
238 }
239 if rerr != nil {
240 err := os.RemoveAll(dir)
241 log.Check(err, "removing config directory", slog.String("dir", dir))
242 }
243 }()
244
245 xcheck := func(err error, msg string) {
246 if err != nil {
247 panic(fmt.Errorf("%s: %s", msg, err))
248 }
249 }
250
251 os.MkdirAll(dir, 0770)
252
253 // Generate key and self-signed certificate for use with TLS.
254 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
255 xcheck(err, "generating ecdsa key for self-signed certificate")
256 privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
257 xcheck(err, "marshal private key to pkcs8")
258 privBlock := &pem.Block{
259 Type: "PRIVATE KEY",
260 Headers: map[string]string{
261 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
262 },
263 Bytes: privKeyDER,
264 }
265 var privPEM bytes.Buffer
266 err = pem.Encode(&privPEM, privBlock)
267 xcheck(err, "pem-encoding private key")
268 err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
269 xcheck(err, "writing private key for self-signed certificate")
270
271 // Now the certificate.
272 template := &x509.Certificate{
273 SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
274 DNSNames: []string{"localhost"},
275 NotBefore: time.Now().Add(-time.Hour),
276 NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
277 Issuer: pkix.Name{
278 Organization: []string{"mox localserve"},
279 },
280 Subject: pkix.Name{
281 Organization: []string{"mox localserve"},
282 CommonName: "localhost",
283 },
284 }
285 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
286 xcheck(err, "making self-signed certificate")
287
288 pubBlock := &pem.Block{
289 Type: "CERTIFICATE",
290 // Comments (header) would cause failure to parse the certificate when we load the config.
291 Bytes: certDER,
292 }
293 var crtPEM bytes.Buffer
294 err = pem.Encode(&crtPEM, pubBlock)
295 xcheck(err, "pem-encoding self-signed certificate")
296 err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
297 xcheck(err, "writing self-signed certificate")
298
299 // Write adminpasswd.
300 adminpw := "moxadmin"
301 adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
302 xcheck(err, "generating hash for admin password")
303 err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
304 xcheck(err, "writing adminpasswd file")
305
306 // Write mox.conf.
307 ips := []string{"127.0.0.1", "::1"}
308 if ip != "" {
309 ips = []string{ip}
310 }
311
312 local := config.Listener{
313 IPs: ips,
314 TLS: &config.TLS{
315 KeyCerts: []config.KeyCert{
316 {
317 CertFile: "localhost.crt",
318 KeyFile: "localhost.key",
319 },
320 },
321 },
322 }
323 local.SMTP.Enabled = true
324 local.SMTP.Port = 1025
325 local.Submission.Enabled = true
326 local.Submission.Port = 1587
327 local.Submission.NoRequireSTARTTLS = true
328 local.Submissions.Enabled = true
329 local.Submissions.Port = 1465
330 local.IMAP.Enabled = true
331 local.IMAP.Port = 1143
332 local.IMAP.NoRequireSTARTTLS = true
333 local.IMAPS.Enabled = true
334 local.IMAPS.Port = 1993
335 local.AccountHTTP.Enabled = true
336 local.AccountHTTP.Port = 1080
337 local.AccountHTTP.Path = "/account/"
338 local.AccountHTTPS.Enabled = true
339 local.AccountHTTPS.Port = 1443
340 local.AccountHTTPS.Path = "/account/"
341 local.WebmailHTTP.Enabled = true
342 local.WebmailHTTP.Port = 1080
343 local.WebmailHTTP.Path = "/webmail/"
344 local.WebmailHTTPS.Enabled = true
345 local.WebmailHTTPS.Port = 1443
346 local.WebmailHTTPS.Path = "/webmail/"
347 local.WebAPIHTTP.Enabled = true
348 local.WebAPIHTTP.Port = 1080
349 local.WebAPIHTTP.Path = "/webapi/"
350 local.WebAPIHTTPS.Enabled = true
351 local.WebAPIHTTPS.Port = 1443
352 local.WebAPIHTTPS.Path = "/webapi/"
353 local.AdminHTTP.Enabled = true
354 local.AdminHTTP.Port = 1080
355 local.AdminHTTPS.Enabled = true
356 local.AdminHTTPS.Port = 1443
357 local.MetricsHTTP.Enabled = true
358 local.MetricsHTTP.Port = 1081
359 local.WebserverHTTP.Enabled = true
360 local.WebserverHTTP.Port = 1080
361 local.WebserverHTTPS.Enabled = true
362 local.WebserverHTTPS.Port = 1443
363
364 uid := os.Getuid()
365 if uid < 0 {
366 uid = 1 // For windows.
367 }
368 static := config.Static{
369 DataDir: ".",
370 LogLevel: "traceauth",
371 Hostname: "localhost",
372 User: fmt.Sprintf("%d", uid),
373 AdminPasswordFile: "adminpasswd",
374 Pedantic: true,
375 Listeners: map[string]config.Listener{
376 "local": local,
377 },
378 }
379 tlsca := struct {
380 AdditionalToSystem bool `sconf:"optional"`
381 CertFiles []string `sconf:"optional"`
382 }{true, []string{"localhost.crt"}}
383 static.TLS.CA = &tlsca
384 static.Postmaster.Account = "mox"
385 static.Postmaster.Mailbox = "Inbox"
386
387 var moxconfBuf bytes.Buffer
388 err = sconf.WriteDocs(&moxconfBuf, static)
389 xcheck(err, "making mox.conf")
390
391 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
392 xcheck(err, "writing mox.conf")
393
394 // Write domains.conf.
395 acc := config.Account{
396 KeepRetiredMessagePeriod: 72 * time.Hour,
397 KeepRetiredWebhookPeriod: 72 * time.Hour,
398 RejectsMailbox: "Rejects",
399 Destinations: map[string]config.Destination{
400 "mox@localhost": {},
401 },
402 NoFirstTimeSenderDelay: true,
403 }
404 acc.AutomaticJunkFlags.Enabled = true
405 acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
406 acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
407 acc.JunkFilter = &config.JunkFilter{
408 Threshold: 0.95,
409 Params: junk.Params{
410 Onegrams: true,
411 MaxPower: .01,
412 TopWords: 10,
413 IgnoreWords: .1,
414 RareWords: 2,
415 },
416 }
417
418 dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
419 xcheck(err, "making dkim key")
420 dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
421 err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
422 xcheck(err, "writing dkim key file")
423
424 dynamic := config.Dynamic{
425 Domains: map[string]config.Domain{
426 "localhost": {
427 LocalpartCatchallSeparator: "+",
428 DKIM: config.DKIM{
429 Sign: []string{"localserve"},
430 Selectors: map[string]config.Selector{
431 "localserve": {
432 Expiration: "72h",
433 PrivateKeyFile: dkimKeyPath,
434 },
435 },
436 },
437 },
438 },
439 Accounts: map[string]config.Account{
440 "mox": acc,
441 },
442 WebHandlers: []config.WebHandler{
443 {
444 LogName: "workdir",
445 Domain: "localhost",
446 PathRegexp: "^/workdir/",
447 DontRedirectPlainHTTP: true,
448 WebStatic: &config.WebStatic{
449 StripPrefix: "/workdir/",
450 Root: ".",
451 ListFiles: true,
452 },
453 },
454 },
455 }
456 var domainsconfBuf bytes.Buffer
457 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
458 xcheck(err, "making domains.conf")
459
460 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
461 xcheck(err, "writing domains.conf")
462
463 // Write receivedid.key.
464 recvidbuf := make([]byte, 16+8)
465 _, err = cryptorand.Read(recvidbuf)
466 xcheck(err, "reading random recvid data")
467 err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
468 xcheck(err, "writing receivedid.key")
469
470 // Load config, so we can access the account.
471 err = localLoadConfig(log, dir)
472 xcheck(err, "loading config")
473
474 // Set password on account.
475 a, _, err := store.OpenEmail(log, "mox@localhost")
476 xcheck(err, "opening account to set password")
477 password := "moxmoxmox"
478 err = a.SetPassword(log, password)
479 xcheck(err, "setting password")
480 err = a.Close()
481 xcheck(err, "closing account")
482
483 golog.Printf("config created in %s", dir)
484 return nil
485}
486
487func localLoadConfig(log mlog.Log, dir string) error {
488 mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
489 mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
490 errs := mox.LoadConfig(context.Background(), log, true, false)
491 if len(errs) > 1 {
492 log.Error("loading config generated config file: multiple errors")
493 for _, err := range errs {
494 log.Errorx("config error", err)
495 }
496 return fmt.Errorf("stopping after multiple config errors")
497 } else if len(errs) == 1 {
498 return fmt.Errorf("loading config file: %v", errs[0])
499 }
500 return nil
501}
502