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