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