chasquid.go 9.37 KB
Newer Older
1 2
// chasquid is an SMTP (email) server, with a focus on simplicity, security,
// and ease of operation.
3 4
//
// See https://blitiri.com.ar/p/chasquid for more details.
Alberto Bertogli's avatar
Alberto Bertogli committed
5 6 7
package main

import (
8
	"context"
9
	"expvar"
Alberto Bertogli's avatar
Alberto Bertogli committed
10 11
	"flag"
	"fmt"
12
	"html/template"
13
	"io/ioutil"
Alberto Bertogli's avatar
Alberto Bertogli committed
14 15
	"math/rand"
	"net"
Alberto Bertogli's avatar
Alberto Bertogli committed
16
	"os"
Alberto Bertogli's avatar
Alberto Bertogli committed
17
	"path/filepath"
18
	"strconv"
19
	"time"
Alberto Bertogli's avatar
Alberto Bertogli committed
20

Alberto Bertogli's avatar
Alberto Bertogli committed
21
	"blitiri.com.ar/go/chasquid/internal/config"
Alberto Bertogli's avatar
Alberto Bertogli committed
22
	"blitiri.com.ar/go/chasquid/internal/courier"
23
	"blitiri.com.ar/go/chasquid/internal/dovecot"
24
	"blitiri.com.ar/go/chasquid/internal/maillog"
25
	"blitiri.com.ar/go/chasquid/internal/normalize"
26
	"blitiri.com.ar/go/chasquid/internal/smtpsrv"
27
	"blitiri.com.ar/go/chasquid/internal/sts"
Alberto Bertogli's avatar
Alberto Bertogli committed
28
	"blitiri.com.ar/go/chasquid/internal/userdb"
29 30
	"blitiri.com.ar/go/log"
	"blitiri.com.ar/go/systemd"
Alberto Bertogli's avatar
Alberto Bertogli committed
31

32
	"net/http"
Alberto Bertogli's avatar
Alberto Bertogli committed
33
	_ "net/http/pprof"
Alberto Bertogli's avatar
Alberto Bertogli committed
34 35
)

36
// Command-line flags.
Alberto Bertogli's avatar
Alberto Bertogli committed
37 38 39
var (
	configDir = flag.String("config_dir", "/etc/chasquid",
		"configuration directory")
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
	showVer = flag.Bool("version", false, "show version and exit")
)

// Build information, overridden at build time using
// -ldflags="-X main.version=blah".
var (
	version      = "undefined"
	sourceDateTs = "0"
)

var (
	versionVar = expvar.NewString("chasquid/version")

	sourceDate      time.Time
	sourceDateVar   = expvar.NewString("chasquid/sourceDateStr")
	sourceDateTsVar = expvar.NewInt("chasquid/sourceDateTimestamp")
56 57
)

Alberto Bertogli's avatar
Alberto Bertogli committed
58 59
func main() {
	flag.Parse()
60
	log.Init()
Alberto Bertogli's avatar
Alberto Bertogli committed
61

62 63 64 65 66 67
	parseVersionInfo()
	if *showVer {
		fmt.Printf("chasquid %s (source date: %s)\n", version, sourceDate)
		return
	}

68
	log.Infof("chasquid starting (version %s)", version)
69

Alberto Bertogli's avatar
Alberto Bertogli committed
70 71 72
	// Seed the PRNG, just to prevent for it to be totally predictable.
	rand.Seed(time.Now().UnixNano())

Alberto Bertogli's avatar
Alberto Bertogli committed
73 74
	conf, err := config.Load(*configDir + "/chasquid.conf")
	if err != nil {
75
		log.Fatalf("Error reading config: %v", err)
Alberto Bertogli's avatar
Alberto Bertogli committed
76
	}
77
	config.LogConfig(conf)
Alberto Bertogli's avatar
Alberto Bertogli committed
78

79 80 81 82 83 84 85
	// Change to the config dir.
	// This allow us to use relative paths for configuration directories.
	// It also can be useful in unusual environments and for testing purposes,
	// where paths inside the configuration itself could be relative, and this
	// fixes the point of reference.
	os.Chdir(*configDir)

86 87
	initMailLog(conf.MailLogPath)

Alberto Bertogli's avatar
Alberto Bertogli committed
88
	if conf.MonitoringAddress != "" {
89
		launchMonitoringServer(conf.MonitoringAddress)
Alberto Bertogli's avatar
Alberto Bertogli committed
90 91
	}

92
	s := smtpsrv.NewServer()
Alberto Bertogli's avatar
Alberto Bertogli committed
93 94
	s.Hostname = conf.Hostname
	s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
95
	s.PostDataHook = "hooks/post-data"
Alberto Bertogli's avatar
Alberto Bertogli committed
96

97
	s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
98

99 100 101 102
	if conf.DovecotAuth {
		loadDovecot(s, conf.DovecotUserdbPath, conf.DovecotClientPath)
	}

103 104
	// Load certificates from "certs/<directory>/{fullchain,privkey}.pem".
	// The structure matches letsencrypt's, to make it easier for that case.
105
	log.Infof("Loading certificates")
106 107
	for _, info := range mustReadDir("certs/") {
		name := info.Name()
108 109 110 111 112 113
		dir := filepath.Join("certs/", name)
		if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
			// Skip non-directories.
			continue
		}

114
		log.Infof("  %s", name)
115

116
		certPath := filepath.Join(dir, "fullchain.pem")
117 118 119
		if _, err := os.Stat(certPath); os.IsNotExist(err) {
			continue
		}
120
		keyPath := filepath.Join(dir, "privkey.pem")
121 122 123 124 125 126
		if _, err := os.Stat(keyPath); os.IsNotExist(err) {
			continue
		}

		err := s.AddCerts(certPath, keyPath)
		if err != nil {
127
			log.Fatalf("    %v", err)
128
		}
129 130
	}

131
	// Load domains from "domains/".
132
	log.Infof("Domain config paths:")
133
	for _, info := range mustReadDir("domains/") {
Alberto Bertogli's avatar
Alberto Bertogli committed
134 135
		domain, err := normalize.Domain(info.Name())
		if err != nil {
136
			log.Fatalf("Invalid name %+q: %v", info.Name(), err)
Alberto Bertogli's avatar
Alberto Bertogli committed
137 138 139
		}
		dir := filepath.Join("domains", info.Name())
		loadDomain(domain, dir, s)
Alberto Bertogli's avatar
Alberto Bertogli committed
140 141
	}

Alberto Bertogli's avatar
Alberto Bertogli committed
142 143 144 145 146
	// Always include localhost as local domain.
	// This can prevent potential trouble if we were to accidentally treat it
	// as a remote domain (for loops, alias resolutions, etc.).
	s.AddDomain("localhost")

147
	dinfo := s.InitDomainInfo(conf.DataDir + "/domaininfo")
148

149 150 151 152 153 154
	stsCache, err := sts.NewCache(conf.DataDir + "/sts-cache")
	if err != nil {
		log.Fatalf("Failed to initialize STS cache: %v", err)
	}
	go stsCache.PeriodicallyRefresh(context.Background())

155 156 157 158 159
	localC := &courier.Procmail{
		Binary:  conf.MailDeliveryAgentBin,
		Args:    conf.MailDeliveryAgentArgs,
		Timeout: 30 * time.Second,
	}
160
	remoteC := &courier.SMTP{Dinfo: dinfo, STSCache: stsCache}
161
	s.InitQueue(conf.DataDir+"/queue", localC, remoteC)
162

163 164 165
	// Load the addresses and listeners.
	systemdLs, err := systemd.Listeners()
	if err != nil {
166
		log.Fatalf("Error getting systemd listeners: %v", err)
167 168
	}

169
	naddr := loadAddresses(s, conf.SmtpAddress,
170
		systemdLs["smtp"], smtpsrv.ModeSMTP)
171
	naddr += loadAddresses(s, conf.SubmissionAddress,
172
		systemdLs["submission"], smtpsrv.ModeSubmission)
173
	naddr += loadAddresses(s, conf.SubmissionOverTlsAddress,
174
		systemdLs["submission_tls"], smtpsrv.ModeSubmissionTLS)
175

176 177 178 179
	if naddr == 0 {
		log.Fatalf("No address to listen on")
	}

180 181 182
	s.ListenAndServe()
}

183 184
func loadAddresses(srv *smtpsrv.Server, addrs []string, ls []net.Listener, mode smtpsrv.SocketMode) int {
	naddr := 0
185
	for _, addr := range addrs {
186
		// The "systemd" address indicates we get listeners via systemd.
Alberto Bertogli's avatar
Alberto Bertogli committed
187
		if addr == "systemd" {
188
			srv.AddListeners(ls, mode)
189
			naddr += len(ls)
Alberto Bertogli's avatar
Alberto Bertogli committed
190
		} else {
191
			srv.AddAddr(addr, mode)
192
			naddr++
Alberto Bertogli's avatar
Alberto Bertogli committed
193 194
		}
	}
Alberto Bertogli's avatar
Alberto Bertogli committed
195

196 197
	if naddr == 0 {
		log.Errorf("Warning: No %v addresses/listeners", mode)
198
		log.Errorf("If using systemd, check that you named the sockets")
199
	}
200
	return naddr
Alberto Bertogli's avatar
Alberto Bertogli committed
201 202
}

203 204 205 206 207 208 209 210
func initMailLog(path string) {
	var err error

	if path == "<syslog>" {
		maillog.Default, err = maillog.NewSyslog()
	} else {
		os.MkdirAll(filepath.Dir(path), 0775)
		var f *os.File
211
		f, err = os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0664)
212 213 214 215
		maillog.Default = maillog.New(f)
	}

	if err != nil {
216
		log.Fatalf("Error opening mail log: %v", err)
217 218 219
	}
}

Alberto Bertogli's avatar
Alberto Bertogli committed
220
// Helper to load a single domain configuration into the server.
221
func loadDomain(name, dir string, s *smtpsrv.Server) {
222
	log.Infof("  %s", name)
Alberto Bertogli's avatar
Alberto Bertogli committed
223 224 225
	s.AddDomain(name)

	if _, err := os.Stat(dir + "/users"); err == nil {
226
		log.Infof("    adding users")
227
		udb, err := userdb.Load(dir + "/users")
Alberto Bertogli's avatar
Alberto Bertogli committed
228
		if err != nil {
229
			log.Errorf("      error: %v", err)
Alberto Bertogli's avatar
Alberto Bertogli committed
230 231 232 233
		} else {
			s.AddUserDB(name, udb)
		}
	}
234

235
	log.Infof("    adding aliases")
236
	err := s.AddAliasesFile(name, dir+"/aliases")
237
	if err != nil {
238
		log.Errorf("      error: %v", err)
239 240 241
	}
}

242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
func loadDovecot(s *smtpsrv.Server, userdb, client string) {
	a := dovecot.Autodetect(userdb, client)
	if a == nil {
		log.Errorf("Dovecot autodetection failed, no dovecot fallback")
		return
	}

	if a != nil {
		s.SetAuthFallback(a)
		log.Infof("Fallback authenticator: %v", a)
		if err := a.Check(); err != nil {
			log.Errorf("Failed dovecot authenticator check: %v", err)
		}
	}
}

258 259 260 261
// Read a directory, which must have at least some entries.
func mustReadDir(path string) []os.FileInfo {
	dirs, err := ioutil.ReadDir(path)
	if err != nil {
262
		log.Fatalf("Error reading %q directory: %v", path, err)
263 264
	}
	if len(dirs) == 0 {
265
		log.Fatalf("No entries found in %q", path)
266 267 268 269 270
	}

	return dirs
}

271 272 273 274 275 276 277 278 279 280 281 282 283
func parseVersionInfo() {
	versionVar.Set(version)

	sdts, err := strconv.ParseInt(sourceDateTs, 10, 0)
	if err != nil {
		panic(err)
	}

	sourceDate = time.Unix(sdts, 0)
	sourceDateVar.Set(sourceDate.Format("2006-01-02 15:04:05 -0700"))
	sourceDateTsVar.Set(sdts)
}

284
func launchMonitoringServer(addr string) {
285
	log.Infof("Monitoring HTTP server listening on %s", addr)
286

287 288 289 290 291 292 293 294 295 296
	indexData := struct {
		Version    string
		SourceDate time.Time
		StartTime  time.Time
	}{
		Version:    version,
		SourceDate: sourceDate,
		StartTime:  time.Now(),
	}

297 298 299 300 301
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.NotFound(w, r)
			return
		}
302
		if err := monitoringHTMLIndex.Execute(w, indexData); err != nil {
303
			log.Infof("monitoring handler error: %v", err)
304
		}
305 306 307 308 309 310 311 312 313 314 315
	})

	flags := dumpFlags()
	http.HandleFunc("/debug/flags", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(flags))
	})

	go http.ListenAndServe(addr, nil)
}

// Static index for the monitoring website.
316 317 318
var monitoringHTMLIndex = template.Must(template.New("index").Funcs(
	template.FuncMap{"since": time.Since}).Parse(
	`<!DOCTYPE html>
319 320 321 322 323 324
<html>
  <head>
    <title>chasquid monitoring</title>
  </head>
  <body>
    <h1>chasquid monitoring</h1>
325 326 327 328 329 330 331

	chasquid {{.Version}}<br>
	source date {{.SourceDate.Format "2006-01-02 15:04:05 -0700"}}<p>

	started {{.StartTime.Format "Mon, 2006-01-02 15:04:05 -0700"}}<br>
	up since {{.StartTime | since}}<p>

332 333
    <ul>
      <li><a href="/debug/queue">queue</a>
334 335 336
      <li><a href="/debug/vars">exported variables</a>
	       <small><a href="https://golang.org/pkg/expvar/">(ref)</a></small>
	  <li>traces <small><a href="https://godoc.org/golang.org/x/net/trace">
337
            (ref)</a></small>
338 339 340 341
        <ul>
          <li><a href="/debug/requests?exp=1">requests (short-lived)</a>
          <li><a href="/debug/events?exp=1">events (long-lived)</a>
        </ul>
342 343 344 345 346 347 348 349 350 351
      <li><a href="/debug/flags">flags</a>
      <li><a href="/debug/pprof">pprof</a>
          <small><a href="https://golang.org/pkg/net/http/pprof/">
            (ref)</a></small>
        <ul>
          <li><a href="/debug/pprof/goroutine?debug=1">goroutines</a>
        </ul>
    </ul>
  </body>
</html>
352
`))
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373

// dumpFlags to a string, for troubleshooting purposes.
func dumpFlags() string {
	s := ""
	visited := make(map[string]bool)

	// Print set flags first, then the rest.
	flag.Visit(func(f *flag.Flag) {
		s += fmt.Sprintf("-%s=%s\n", f.Name, f.Value.String())
		visited[f.Name] = true
	})

	s += "\n"
	flag.VisitAll(func(f *flag.Flag) {
		if !visited[f.Name] {
			s += fmt.Sprintf("-%s=%s\n", f.Name, f.Value.String())
		}
	})

	return s
}