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 // For new configs, we keep the "info" loglevel set by writeLocalConfig until after
124 // initializing database files, to prevent lots of schema upgrade logging.
125 fallbackLevel := mox.Conf.Static.LogLevel
126 if fallbackLevel == "" {
127 fallbackLevel = "debug"
128 }
129 if existingConfig {
130 loadLoglevel(log, fallbackLevel)
131 }
132
133 // Initialize receivedid.
134 recvidbuf, err := os.ReadFile(filepath.Join(dir, "receivedid.key"))
135 if err == nil && len(recvidbuf) != 16+8 {
136 err = fmt.Errorf("bad length %d, need 16+8", len(recvidbuf))
137 }
138 if err != nil {
139 log.Errorx("reading receivedid.key", err)
140 recvidbuf = make([]byte, 16+8)
141 _, err := cryptorand.Read(recvidbuf)
142 if err != nil {
143 log.Fatalx("read random recvid key", err)
144 }
145 }
146 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
147 log.Fatalx("init receivedid", err)
148 }
149
150 // Make smtp server accept all email and deliver to account "mox".
151 smtpserver.Localserve = true
152 // Tell queue it shouldn't be queuing/delivering.
153 queue.Localserve = true
154 // Tell DKIM not to fail signatures for TLD localhost.
155 dkim.Localserve = true
156
157 const mtastsdbRefresher = false
158 const sendDMARCReports = false
159 const sendTLSReports = false
160 const skipForkExec = true
161 if err := start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec); err != nil {
162 log.Fatalx("starting mox", err)
163 }
164
165 loadLoglevel(log, fallbackLevel)
166
167 golog.Printf("mox, version %s, %s %s/%s", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
168 golog.Print("")
169 golog.Printf("the default user is mox@localhost, with password moxmoxmox")
170 golog.Printf("the default admin password is moxadmin")
171 golog.Printf("port numbers are those common for the services + 1000")
172 golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
173 golog.Printf("all incoming email to any address is accepted (if checks pass), unless the recipient localpart ends with:")
174 golog.Print("")
175 golog.Printf(`- "temperror": fail with a temporary error code.`)
176 golog.Printf(`- "permerror": fail with a permanent error code.`)
177 golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
178 golog.Printf(`- "timeout": no response (for an hour).`)
179 golog.Print("")
180 golog.Print(`if the localpart begins with "mailfrom" or "rcptto", the error is returned`)
181 golog.Print(`during those commands instead of during "data". if the localpart begins with`)
182 golog.Print(`"queue", the submission is accepted but delivery from the queue will fail.`)
183 golog.Print("")
184 golog.Print(" smtp://localhost:1025 - receive email")
185 golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
186 golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
187 golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
188 golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
189 golog.Print("https://localhost:1443/account/ - account https (email mox@localhost, password moxmoxmox)")
190 golog.Print(" http://localhost:1080/account/ - account http (without tls)")
191 golog.Print("https://localhost:1443/webmail/ - webmail https (email mox@localhost, password moxmoxmox)")
192 golog.Print(" http://localhost:1080/webmail/ - webmail http (without tls)")
193 golog.Print("https://localhost:1443/webapi/ - webmail https (email mox@localhost, password moxmoxmox)")
194 golog.Print(" http://localhost:1080/webapi/ - webmail http (without tls)")
195 golog.Print("https://localhost:1443/admin/ - admin https (password moxadmin)")
196 golog.Print(" http://localhost:1080/admin/ - admin http (without tls)")
197 golog.Print("")
198 if existingConfig {
199 golog.Printf("serving from existing config dir %s/", dir)
200 golog.Printf("if urls above don't work, consider resetting by removing config dir")
201 } else {
202 golog.Printf("serving from newly created config dir %s/", dir)
203 }
204
205 ctlpath := mox.DataDirPath("ctl")
206 _ = os.Remove(ctlpath)
207 ctl, err := net.Listen("unix", ctlpath)
208 if err != nil {
209 log.Fatalx("listen on ctl unix domain socket", err)
210 }
211 go func() {
212 for {
213 conn, err := ctl.Accept()
214 if err != nil {
215 log.Printx("accept for ctl", err)
216 continue
217 }
218 cid := mox.Cid()
219 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
220 go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
221 }
222 }()
223
224 // Graceful shutdown.
225 sigc := make(chan os.Signal, 1)
226 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
227 sig := <-sigc
228 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
229 shutdown(log)
230 if num, ok := sig.(syscall.Signal); ok {
231 os.Exit(int(num))
232 } else {
233 os.Exit(1)
234 }
235}
236
237func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
238 defer func() {
239 x := recover()
240 if x != nil {
241 if err, ok := x.(error); ok {
242 rerr = err
243 }
244 }
245 if rerr != nil {
246 err := os.RemoveAll(dir)
247 log.Check(err, "removing config directory", slog.String("dir", dir))
248 }
249 }()
250
251 xcheck := func(err error, msg string) {
252 if err != nil {
253 panic(fmt.Errorf("%s: %s", msg, err))
254 }
255 }
256
257 os.MkdirAll(dir, 0770)
258
259 // Generate key and self-signed certificate for use with TLS.
260 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
261 xcheck(err, "generating ecdsa key for self-signed certificate")
262 privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
263 xcheck(err, "marshal private key to pkcs8")
264 privBlock := &pem.Block{
265 Type: "PRIVATE KEY",
266 Headers: map[string]string{
267 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
268 },
269 Bytes: privKeyDER,
270 }
271 var privPEM bytes.Buffer
272 err = pem.Encode(&privPEM, privBlock)
273 xcheck(err, "pem-encoding private key")
274 err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
275 xcheck(err, "writing private key for self-signed certificate")
276
277 // Now the certificate.
278 template := &x509.Certificate{
279 SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
280 DNSNames: []string{"localhost"},
281 NotBefore: time.Now().Add(-time.Hour),
282 NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
283 Issuer: pkix.Name{
284 Organization: []string{"mox localserve"},
285 },
286 Subject: pkix.Name{
287 Organization: []string{"mox localserve"},
288 CommonName: "localhost",
289 },
290 }
291 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
292 xcheck(err, "making self-signed certificate")
293
294 pubBlock := &pem.Block{
295 Type: "CERTIFICATE",
296 // Comments (header) would cause failure to parse the certificate when we load the config.
297 Bytes: certDER,
298 }
299 var crtPEM bytes.Buffer
300 err = pem.Encode(&crtPEM, pubBlock)
301 xcheck(err, "pem-encoding self-signed certificate")
302 err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
303 xcheck(err, "writing self-signed certificate")
304
305 // Write adminpasswd.
306 adminpw := "moxadmin"
307 adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
308 xcheck(err, "generating hash for admin password")
309 err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
310 xcheck(err, "writing adminpasswd file")
311
312 // Write mox.conf.
313 ips := []string{"127.0.0.1", "::1"}
314 if ip != "" {
315 ips = []string{ip}
316 }
317
318 local := config.Listener{
319 IPs: ips,
320 TLS: &config.TLS{
321 KeyCerts: []config.KeyCert{
322 {
323 CertFile: "localhost.crt",
324 KeyFile: "localhost.key",
325 },
326 },
327 },
328 }
329 local.SMTP.Enabled = true
330 local.SMTP.Port = 1025
331 local.Submission.Enabled = true
332 local.Submission.Port = 1587
333 local.Submission.NoRequireSTARTTLS = true
334 local.Submissions.Enabled = true
335 local.Submissions.Port = 1465
336 local.IMAP.Enabled = true
337 local.IMAP.Port = 1143
338 local.IMAP.NoRequireSTARTTLS = true
339 local.IMAPS.Enabled = true
340 local.IMAPS.Port = 1993
341 local.AccountHTTP.Enabled = true
342 local.AccountHTTP.Port = 1080
343 local.AccountHTTP.Path = "/account/"
344 local.AccountHTTPS.Enabled = true
345 local.AccountHTTPS.Port = 1443
346 local.AccountHTTPS.Path = "/account/"
347 local.WebmailHTTP.Enabled = true
348 local.WebmailHTTP.Port = 1080
349 local.WebmailHTTP.Path = "/webmail/"
350 local.WebmailHTTPS.Enabled = true
351 local.WebmailHTTPS.Port = 1443
352 local.WebmailHTTPS.Path = "/webmail/"
353 local.WebAPIHTTP.Enabled = true
354 local.WebAPIHTTP.Port = 1080
355 local.WebAPIHTTP.Path = "/webapi/"
356 local.WebAPIHTTPS.Enabled = true
357 local.WebAPIHTTPS.Port = 1443
358 local.WebAPIHTTPS.Path = "/webapi/"
359 local.AdminHTTP.Enabled = true
360 local.AdminHTTP.Port = 1080
361 local.AdminHTTPS.Enabled = true
362 local.AdminHTTPS.Port = 1443
363 local.MetricsHTTP.Enabled = true
364 local.MetricsHTTP.Port = 1081
365 local.WebserverHTTP.Enabled = true
366 local.WebserverHTTP.Port = 1080
367 local.WebserverHTTPS.Enabled = true
368 local.WebserverHTTPS.Port = 1443
369
370 uid := os.Getuid()
371 if uid < 0 {
372 uid = 1 // For windows.
373 }
374 static := config.Static{
375 DataDir: ".",
376 LogLevel: "traceauth",
377 Hostname: "localhost",
378 User: fmt.Sprintf("%d", uid),
379 AdminPasswordFile: "adminpasswd",
380 Pedantic: true,
381 Listeners: map[string]config.Listener{
382 "local": local,
383 },
384 }
385 tlsca := struct {
386 AdditionalToSystem bool `sconf:"optional"`
387 CertFiles []string `sconf:"optional"`
388 }{true, []string{"localhost.crt"}}
389 static.TLS.CA = &tlsca
390 static.Postmaster.Account = "mox"
391 static.Postmaster.Mailbox = "Inbox"
392
393 var moxconfBuf bytes.Buffer
394 err = sconf.WriteDocs(&moxconfBuf, static)
395 xcheck(err, "making mox.conf")
396
397 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
398 xcheck(err, "writing mox.conf")
399
400 // Write domains.conf.
401 acc := config.Account{
402 KeepRetiredMessagePeriod: 72 * time.Hour,
403 KeepRetiredWebhookPeriod: 72 * time.Hour,
404 RejectsMailbox: "Rejects",
405 Destinations: map[string]config.Destination{
406 "mox@localhost": {},
407 },
408 NoFirstTimeSenderDelay: true,
409 }
410 acc.AutomaticJunkFlags.Enabled = true
411 acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
412 acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
413 acc.JunkFilter = &config.JunkFilter{
414 Threshold: 0.95,
415 Params: junk.Params{
416 Onegrams: true,
417 MaxPower: .01,
418 TopWords: 10,
419 IgnoreWords: .1,
420 RareWords: 2,
421 },
422 }
423
424 dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
425 xcheck(err, "making dkim key")
426 dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
427 err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
428 xcheck(err, "writing dkim key file")
429
430 dynamic := config.Dynamic{
431 Domains: map[string]config.Domain{
432 "localhost": {
433 LocalpartCatchallSeparator: "+",
434 DKIM: config.DKIM{
435 Sign: []string{"localserve"},
436 Selectors: map[string]config.Selector{
437 "localserve": {
438 Expiration: "72h",
439 PrivateKeyFile: dkimKeyPath,
440 },
441 },
442 },
443 },
444 },
445 Accounts: map[string]config.Account{
446 "mox": acc,
447 },
448 WebHandlers: []config.WebHandler{
449 {
450 LogName: "workdir",
451 Domain: "localhost",
452 PathRegexp: "^/workdir/",
453 DontRedirectPlainHTTP: true,
454 WebStatic: &config.WebStatic{
455 StripPrefix: "/workdir/",
456 Root: ".",
457 ListFiles: true,
458 },
459 },
460 },
461 }
462 var domainsconfBuf bytes.Buffer
463 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
464 xcheck(err, "making domains.conf")
465
466 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
467 xcheck(err, "writing domains.conf")
468
469 // Write receivedid.key.
470 recvidbuf := make([]byte, 16+8)
471 _, err = cryptorand.Read(recvidbuf)
472 xcheck(err, "reading random recvid data")
473 err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
474 xcheck(err, "writing receivedid.key")
475
476 // Load config, so we can access the account.
477 err = localLoadConfig(log, dir)
478 xcheck(err, "loading config")
479
480 // Info so we don't log lots about initializing database.
481 loadLoglevel(log, "info")
482
483 // Set password on account.
484 a, _, err := store.OpenEmail(log, "mox@localhost")
485 xcheck(err, "opening account to set password")
486 password := "moxmoxmox"
487 err = a.SetPassword(log, password)
488 xcheck(err, "setting password")
489 err = a.Close()
490 xcheck(err, "closing account")
491
492 golog.Printf("config created in %s", dir)
493 return nil
494}
495
496func loadLoglevel(log mlog.Log, fallback string) {
497 ll := loglevel
498 if ll == "" {
499 ll = fallback
500 }
501 if level, ok := mlog.Levels[ll]; ok {
502 mox.Conf.Log[""] = level
503 mlog.SetConfig(mox.Conf.Log)
504 } else {
505 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
506 }
507}
508
509func localLoadConfig(log mlog.Log, dir string) error {
510 mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
511 mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
512 errs := mox.LoadConfig(context.Background(), log, true, false)
513 if len(errs) > 1 {
514 log.Error("loading config generated config file: multiple errors")
515 for _, err := range errs {
516 log.Errorx("config error", err)
517 }
518 return fmt.Errorf("stopping after multiple config errors")
519 } else if len(errs) == 1 {
520 return fmt.Errorf("loading config file: %v", errs[0])
521 }
522 return nil
523}
524