main.go (view raw)
1package main
2
3import (
4 "cmp"
5 "context"
6 "crypto/tls"
7 "fmt"
8 "log"
9 "log/slog"
10 "os"
11 "path"
12 "path/filepath"
13 "runtime"
14 "time"
15
16 "postern/internal/cli"
17 "postern/internal/db"
18 "postern/internal/dkim"
19 "postern/internal/imap"
20 "postern/internal/inbound"
21 "postern/internal/persistence"
22 "postern/internal/policy"
23 "postern/internal/submission"
24)
25
26func main() {
27 socketPath := resolveSocketPath()
28
29 // Create a bootstrap closure that encapsulates all the resource initialization
30 // that is only needed when the "serve" command is executed.
31 bootstrap := func() (*cli.BootstrapDeps, error) {
32 posternPersistenceDir := cmp.Or(os.Getenv("POSTERN_PERSISTENCE_DIR"), os.Getenv("STATE_DIRECTORY"))
33 if posternPersistenceDir == "" {
34 return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR or STATE_DIRECTORY is required")
35 }
36 err := persistence.CheckDirWritable(posternPersistenceDir)
37 if err != nil {
38 return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR: %w", err)
39 }
40 slog.Info("using postern persistence", "dir", posternPersistenceDir)
41
42 persistenceMasterkey := os.Getenv("POSTERN_MASTERKEY_FILE")
43 if persistenceMasterkey == "" {
44 persistenceMasterkey = path.Join(os.Getenv("CREDENTIALS_DIRECTORY"), "masterkey")
45 }
46 p, err := persistence.New(persistence.Config{
47 BlobRoot: path.Join(posternPersistenceDir, "blobs"),
48 MasterkeyFile: persistenceMasterkey,
49 })
50 if err != nil {
51 return nil, fmt.Errorf("failed to initialize persistence: %w", err)
52 }
53
54 posternDB, err := db.OpenDB(&db.Config{
55 DBPath: path.Join(posternPersistenceDir, "meta.db"),
56 })
57 if err != nil {
58 return nil, fmt.Errorf("failed to open database: %w", err)
59 }
60
61 engine, err := policy.NewEngine(path.Join(os.Getenv("CONFIGURATION_DIRECTORY"), "policy.star"), posternDB)
62 if err != nil {
63 return nil, fmt.Errorf("failed to initialize policy engine: %w", err)
64 }
65 engine.StartHotReload(context.Background(), 10*time.Second)
66
67 dkimSvc := dkim.New(&dkim.Config{
68 DB: posternDB,
69 Persistence: p,
70 })
71
72 listenAddrs := make(map[string]policy.ListenerConfig)
73 for _, l := range engine.Listeners {
74 listenAddrs[l.Role] = l
75 }
76
77 services := make(map[string]cli.Service)
78
79 if listener, ok := listenAddrs["imap"]; ok {
80 cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile)
81 if err != nil {
82 return nil, fmt.Errorf("failed to load IMAP TLS cert: %w", err)
83 }
84
85 tlsConfig := &tls.Config{
86 Certificates: []tls.Certificate{cert},
87 }
88
89 imapServer, err := imap.NewServer(&imap.Config{
90 DB: posternDB,
91 Listen: listener.Address.String(),
92 Persistence: p,
93 TLSConfig: tlsConfig,
94 PolicyEngine: engine,
95 })
96 if err != nil {
97 return nil, fmt.Errorf("failed to create IMAP server: %w", err)
98 }
99 services["imap"] = imapServer
100 }
101
102 if listener, ok := listenAddrs["smtp_inbound"]; ok {
103 cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile)
104 if err != nil {
105 return nil, fmt.Errorf("failed to load SMTP inbound TLS cert: %w", err)
106 }
107
108 tlsConfig := &tls.Config{
109 Certificates: []tls.Certificate{cert},
110 }
111
112 receptionServer, err := inbound.NewServer(&inbound.Config{
113 DB: posternDB,
114 Deliverer: services["imap"].(inbound.MessageDeliverer),
115 Listen: listener.Address.String(),
116 TLS: tlsConfig,
117 PolicyEngine: engine,
118 })
119 if err != nil {
120 return nil, fmt.Errorf("failed to create SMTP inbound server: %w", err)
121 }
122 services["smtp_inbound"] = receptionServer
123 }
124
125 if listener, ok := listenAddrs["smtp_submission"]; ok {
126 cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile)
127 if err != nil {
128 return nil, fmt.Errorf("failed to load SMTP submission TLS cert: %w", err)
129 }
130
131 tlsConfig := &tls.Config{
132 Certificates: []tls.Certificate{cert},
133 }
134
135 submissionServer, err := submission.NewServer(&submission.Config{
136 DB: posternDB,
137 DKIM: dkimSvc,
138 Listen: listener.Address.String(),
139 TLS: tlsConfig,
140 PolicyEngine: engine,
141 })
142 if err != nil {
143 return nil, fmt.Errorf("failed to create SMTP submission server: %w", err)
144 }
145 services["smtp_submission"] = submissionServer
146 }
147
148 if listener, ok := listenAddrs["smtp_local_submission"]; ok {
149 // Check that address is loopback. Other addresses are not allowed for security reasons.
150 if !listener.Address.Addr().IsLoopback() {
151 return nil, fmt.Errorf("smtp_local_submission address is not a loopback address")
152 }
153
154 submissionServer, err := submission.NewServer(&submission.Config{
155 DB: posternDB,
156 DKIM: dkimSvc,
157 Listen: listener.Address.String(),
158 PolicyEngine: engine,
159 IsLocalSubmission: true,
160 })
161 if err != nil {
162 return nil, fmt.Errorf("failed to create local SMTP submission server: %w", err)
163 }
164 services["smtp_local_submission"] = submissionServer
165 }
166
167 return &cli.BootstrapDeps{
168 DB: posternDB,
169 Persistence: p,
170 DKIM: dkimSvc,
171 Services: services,
172 }, nil
173 }
174
175 posternCLI, err := cli.New(&cli.Config{
176 SocketPath: socketPath,
177 Bootstrap: bootstrap,
178 })
179 if err != nil {
180 log.Fatal(err)
181 }
182
183 if err := posternCLI.Execute(); err != nil {
184 os.Exit(1)
185 }
186}
187
188// resolveSocketPath is runtime agnostic (Linux, MacOS) to choose the admin http socket path
189func resolveSocketPath() string {
190 if p := os.Getenv("POSTERN_SOCKET"); p != "" {
191 return p
192 }
193 if rd := os.Getenv("RUNTIME_DIRECTORY"); rd != "" {
194 return filepath.Join(rd, "postern.sock")
195 }
196 if runtime.GOOS == "linux" {
197 return "/run/postern/postern.sock"
198 }
199 if home, err := os.UserHomeDir(); err == nil {
200 return filepath.Join(home, ".postern", "postern.sock")
201 }
202 return "/tmp/postern.sock"
203}