Blame


1 f3a795ae 2021-08-03 op I just switched my mailserver from a setup with a single UNIX user to a slightly more complex one with virtual users. I don’t know how other admins manages their virtual users, but in this entry I’m going to discuss the method I’m using.
2 f3a795ae 2021-08-03 op
3 f3a795ae 2021-08-03 op This is *not* a tutorial on how to install and configure OpenSMTPD or Dovecot or anything else, as I don’t feel like I’m the most qualified to do so. Instead, if you’re looking on how to deploy your own mail server, I’m going to recommend the tutorial from Gilles Chehade:
4 f3a795ae 2021-08-03 op
5 f3a795ae 2021-08-03 op => https://poolp.org/posts/2019-09-14/setting-up-a-mail-server-with-opensmtpd-dovecot-and-rspamd/index.html Setting up a mail server with OpenSMTPD, Dovecot and Rspamd
6 f3a795ae 2021-08-03 op
7 f3a795ae 2021-08-03 op In the past I’ve used a shared SQLite database to store users authentication data, but this time I wanted to manage the data differently. I don’t need to handle hundreds of users, and every user needs to be manually added by me, so a database is overkill.
8 f3a795ae 2021-08-03 op
9 f3a795ae 2021-08-03 op A more text-centric approach requires five configuration files:
10 f3a795ae 2021-08-03 op * a passwd-like file
11 f3a795ae 2021-08-03 op * an aliases table
12 f3a795ae 2021-08-03 op * a domains table
13 f3a795ae 2021-08-03 op * an authentication table
14 f3a795ae 2021-08-03 op * a virtuals table
15 f3a795ae 2021-08-03 op
16 f3a795ae 2021-08-03 op The tables are needed to load data into OpenSMTPD, while for Dovecot a single ‘/etc/passwd’-like file is enough.
17 f3a795ae 2021-08-03 op
18 f3a795ae 2021-08-03 op Keeping the information in sync between these five files definitely not hard, but I’m particularly lazy, so I’ve wrote a simple AWK script to parse a custom ‘userdb’ file and populate all those files. But before going into that, let’s see an excerpt from my OpenSMTPD configuration:
19 f3a795ae 2021-08-03 op
20 f3a795ae 2021-08-03 op ```
21 f3a795ae 2021-08-03 op # these are the paths on a FreeBSD host, on OpenBSD they’re
22 f3a795ae 2021-08-03 op # just /etc/mail.
23 f3a795ae 2021-08-03 op table aliases file:/usr/local/etc/mail/aliases
24 f3a795ae 2021-08-03 op table domains file:/usr/local/etc/mail/domains
25 f3a795ae 2021-08-03 op table passwd file:/usr/local/etc/mail/passwd
26 f3a795ae 2021-08-03 op table virtuals file:/usr/local/etc/mail/virtuals
27 f3a795ae 2021-08-03 op
28 f3a795ae 2021-08-03 op # pki, filters and listen directives omitted
29 f3a795ae 2021-08-03 op
30 f3a795ae 2021-08-03 op action "remote_mail" lmtp "/var/run/dovecot/lmtp" rcpt-to virtual <virtuals>
31 f3a795ae 2021-08-03 op action "local_mail" lmtp "/var/run/dovecot/lmtp" rcpt-to alias <aliases>
32 f3a795ae 2021-08-03 op action "outbound" relay helo example.com
33 f3a795ae 2021-08-03 op
34 f3a795ae 2021-08-03 op match from any for domain <domains> action "remote_mail"
35 f3a795ae 2021-08-03 op match from local for local action "local_mail"
36 f3a795ae 2021-08-03 op match from any auth for any action "outbound"
37 f3a795ae 2021-08-03 op match for any action "outbound"
38 f3a795ae 2021-08-03 op ```
39 f3a795ae 2021-08-03 op
40 f3a795ae 2021-08-03 op The four ‘match’ rules matches in order
41 f3a795ae 2021-08-03 op * incoming emails for the domains we’re serving
42 f3a795ae 2021-08-03 op * local emails from one UNIX user to another
43 f3a795ae 2021-08-03 op * outgoing emails from authenticated users
44 f3a795ae 2021-08-03 op * outgoing emails from local UNIX users (there’s an implicit ‘from local’, to turn your server into an open relay you need to be really, really explicit!)
45 f3a795ae 2021-08-03 op
46 f3a795ae 2021-08-03 op Two of the three actions deliver the mail over LMTP to Dovecot. An important bit there that I was missing on my first try was the ‘rcpt-to’ keyword: as we’ll see in a moment, all the mail are handled by a local user, but we need to use the recipient email address instead of the local user in the LMTP session, so Dovecot can save the email in the correct maildir.
47 f3a795ae 2021-08-03 op
48 f3a795ae 2021-08-03 op ### passwd
49 f3a795ae 2021-08-03 op
50 f3a795ae 2021-08-03 op Dovecot needs only a single file for the authentication. One of the supported format, and the one I’m using, is a ‘passwd’-like format, like the following:
51 f3a795ae 2021-08-03 op
52 f3a795ae 2021-08-03 op ```
53 f3a795ae 2021-08-03 op op@example.com:<hash>::::::
54 f3a795ae 2021-08-03 op ```
55 f3a795ae 2021-08-03 op
56 f3a795ae 2021-08-03 op On the Dovecot site, things are a bit easier because there is no aliasing, resolving or expansions to do on the received emails.
57 f3a795ae 2021-08-03 op
58 f3a795ae 2021-08-03 op ### alias table
59 f3a795ae 2021-08-03 op
60 f3a795ae 2021-08-03 op An alias table looks like this:
61 f3a795ae 2021-08-03 op
62 f3a795ae 2021-08-03 op ```example of an alias table file
63 f3a795ae 2021-08-03 op root: op
64 f3a795ae 2021-08-03 op op: op@example.com
65 f3a795ae 2021-08-03 op ```
66 f3a795ae 2021-08-03 op
67 f3a795ae 2021-08-03 op It maps *local* users to other local or remote users. In the example above, mail for the UNIX root user are forwarded to the user op, that in turns redirects his mail to op@example.com.
68 f3a795ae 2021-08-03 op
69 f3a795ae 2021-08-03 op ### domains table
70 f3a795ae 2021-08-03 op
71 f3a795ae 2021-08-03 op Holds all the domains we’re accepting mails from. It can be specified in-line in the configuration file:
72 f3a795ae 2021-08-03 op
73 f3a795ae 2021-08-03 op ```example of a domains table in smtpd.conf
74 f3a795ae 2021-08-03 op table domains { "example.com", "foo.bar.net", … }
75 f3a795ae 2021-08-03 op ```
76 f3a795ae 2021-08-03 op
77 f3a795ae 2021-08-03 op or in a file with one domain name per line
78 f3a795ae 2021-08-03 op
79 f3a795ae 2021-08-03 op ```example of a domains table as plain file
80 f3a795ae 2021-08-03 op example.com
81 f3a795ae 2021-08-03 op foo.bar.net
82 f3a795ae 2021-08-03 op ```
83 f3a795ae 2021-08-03 op
84 f3a795ae 2021-08-03 op ### Credentials table
85 f3a795ae 2021-08-03 op
86 f3a795ae 2021-08-03 op A credentials table file looks like this:
87 f3a795ae 2021-08-03 op
88 f3a795ae 2021-08-03 op ```example of a credentials table
89 f3a795ae 2021-08-03 op user@doma.in password-hash
90 f3a795ae 2021-08-03 op user2@example.com password-hash
91 f3a795ae 2021-08-03 op ```
92 f3a795ae 2021-08-03 op
93 f3a795ae 2021-08-03 op just a simple user ↔ hash mapping. Hashes can be computed with the encrypt subcommand of smtpctl
94 f3a795ae 2021-08-03 op
95 f3a795ae 2021-08-03 op ```example on how to hash a password
96 f3a795ae 2021-08-03 op $ smtpctl encrypt
97 f3a795ae 2021-08-03 op p4ssw0rd
98 f3a795ae 2021-08-03 op $2b$10$jpdOj8WPIMABsMs.LzFbiuSpgZ1TlGUj2ztBxEimoaQylQD/jhelS
99 f3a795ae 2021-08-03 op ^D
100 f3a795ae 2021-08-03 op ```
101 f3a795ae 2021-08-03 op
102 f3a795ae 2021-08-03 op NB: on OpenBSD-CURRENT (and as of a couple of releases already at least) the ‘smtpctl encrypt’ computes the BLF-CRYPT hash of the password, but for some reason on FreeBSD it uses SHA512-CRYPT. Dovecot needs to be told the default hashing scheme in ‘conf.d/auth-passwdfile.conf.ext’. Here’s mine
103 f3a795ae 2021-08-03 op
104 f3a795ae 2021-08-03 op ``` example of auth-passwdfile.conf.ext file for Dovecot
105 f3a795ae 2021-08-03 op passdb {
106 f3a795ae 2021-08-03 op driver = passwd-file
107 f3a795ae 2021-08-03 op # adjust SHA512-CRYPT eventually!
108 f3a795ae 2021-08-03 op args = scheme=SHA512-CRYPT username_format=%u /usr/local/etc/dovecot/users
109 f3a795ae 2021-08-03 op }
110 f3a795ae 2021-08-03 op
111 f3a795ae 2021-08-03 op userdb {
112 f3a795ae 2021-08-03 op driver = passwd-file
113 f3a795ae 2021-08-03 op args = username_format=%u /usr/local/etc/dovecot/users
114 f3a795ae 2021-08-03 op override_fields = home=/var/vmail/%d/%n
115 f3a795ae 2021-08-03 op }
116 f3a795ae 2021-08-03 op ```
117 f3a795ae 2021-08-03 op
118 f3a795ae 2021-08-03 op Refer to the Dovecot documentation:
119 f3a795ae 2021-08-03 op => https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ “Password Schemes” in the Dovecot documentation.
120 f3a795ae 2021-08-03 op
121 f3a795ae 2021-08-03 op ### virtuals
122 f3a795ae 2021-08-03 op
123 f3a795ae 2021-08-03 op The virtual table is used to map address to other addresses (i.e. alias) or addresses to local users (to allow the delivery.) It looks like this
124 f3a795ae 2021-08-03 op
125 f3a795ae 2021-08-03 op ```example of a virtuals table
126 f3a795ae 2021-08-03 op postmaster@example.com: op@example.com
127 f3a795ae 2021-08-03 op aaa@example.com: op@example.com
128 f3a795ae 2021-08-03 op op@example.com: vmail
129 f3a795ae 2021-08-03 op otheruser@example.com: vmail
130 f3a795ae 2021-08-03 op ```
131 f3a795ae 2021-08-03 op
132 f3a795ae 2021-08-03 op ## Making it painless
133 f3a795ae 2021-08-03 op
134 f3a795ae 2021-08-03 op Since maintaining this whole bunch of files may not be the easiest thing ever. To be a bit more declarative, I’ve come up with the following ‘userdb’ file. It’s an invented syntax that gets parsed by a super-simple AWK script and generates all the other files. Here’s an example:
135 f3a795ae 2021-08-03 op
136 f3a795ae 2021-08-03 op ```example of syntax of userdb
137 f3a795ae 2021-08-03 op # local alias
138 f3a795ae 2021-08-03 op alias root op
139 f3a795ae 2021-08-03 op alias op op@example.com
140 f3a795ae 2021-08-03 op
141 f3a795ae 2021-08-03 op # per virtual-domain config
142 f3a795ae 2021-08-03 op example.com:
143 f3a795ae 2021-08-03 op # Indentation is optional, but improves legibility.
144 f3a795ae 2021-08-03 op # The following defines the user op@example.com;
145 f3a795ae 2021-08-03 op # <hash> is the hash of the password computed
146 f3a795ae 2021-08-03 op # with `smtpctl encrypt`
147 f3a795ae 2021-08-03 op user op <hash>
148 f3a795ae 2021-08-03 op # and define an arbitrary number of aliases
149 f3a795ae 2021-08-03 op alias service1
150 f3a795ae 2021-08-03 op alias other-alias
151 f3a795ae 2021-08-03 op
152 f3a795ae 2021-08-03 op user otheruser <hash>
153 f3a795ae 2021-08-03 op
154 f3a795ae 2021-08-03 op # aliases can be to virtual users on other hosts
155 f3a795ae 2021-08-03 op alias abuse someone@example2.com
156 f3a795ae 2021-08-03 op
157 f3a795ae 2021-08-03 op example2.com:
158 f3a795ae 2021-08-03 op user someone <hash>
159 f3a795ae 2021-08-03 op # …
160 f3a795ae 2021-08-03 op ```
161 f3a795ae 2021-08-03 op
162 f3a795ae 2021-08-03 op The syntax is as simple as possible, to make the parsing easier. It’s also open for additions: for instance, adding a ‘quota’ keyword to define custom quotas shouldn’t be too hard.
163 f3a795ae 2021-08-03 op
164 f3a795ae 2021-08-03 op => //git.omarpolo.com/vuserctl/ All the code examples are available in a git repository.
165 f3a795ae 2021-08-03 op
166 f3a795ae 2021-08-03 op The AWK implementation that parses the file is also pretty simple:
167 f3a795ae 2021-08-03 op
168 f3a795ae 2021-08-03 op ``` userctl.awk
169 f3a795ae 2021-08-03 op #!/usr/bin/env awk
170 f3a795ae 2021-08-03 op
171 f3a795ae 2021-08-03 op # expects action to be defined, like -v action=aliases
172 f3a795ae 2021-08-03 op
173 f3a795ae 2021-08-03 op /^[[:space:]]*$/ { next }
174 f3a795ae 2021-08-03 op /^[[:space:]]*#/ { next }
175 f3a795ae 2021-08-03 op
176 f3a795ae 2021-08-03 op /:$/ {
177 f3a795ae 2021-08-03 op # drop the :
178 f3a795ae 2021-08-03 op gsub(":", "", $1);
179 f3a795ae 2021-08-03 op domain = $1;
180 f3a795ae 2021-08-03 op domains[domainslen++] = domain;
181 f3a795ae 2021-08-03 op next;
182 f3a795ae 2021-08-03 op }
183 f3a795ae 2021-08-03 op
184 f3a795ae 2021-08-03 op $1 == "user" {
185 f3a795ae 2021-08-03 op user = sprintf("%s@%s", $2, domain);
186 f3a795ae 2021-08-03 op users[user] = $3
187 f3a795ae 2021-08-03 op
188 f3a795ae 2021-08-03 op # change “vmail” to match the local user that
189 f3a795ae 2021-08-03 op # delivers the mail
190 f3a795ae 2021-08-03 op aliases[user] = "vmail";
191 f3a795ae 2021-08-03 op next;
192 f3a795ae 2021-08-03 op }
193 f3a795ae 2021-08-03 op
194 f3a795ae 2021-08-03 op $1 == "alias" {
195 f3a795ae 2021-08-03 op if ($3 != "") {
196 f3a795ae 2021-08-03 op target = $3;
197 f3a795ae 2021-08-03 op } else {
198 f3a795ae 2021-08-03 op target = user;
199 f3a795ae 2021-08-03 op }
200 f3a795ae 2021-08-03 op
201 f3a795ae 2021-08-03 op if (domain != "") {
202 f3a795ae 2021-08-03 op alias = sprintf("%s@%s", $2, domain);
203 f3a795ae 2021-08-03 op } else {
204 f3a795ae 2021-08-03 op alias = $2;
205 f3a795ae 2021-08-03 op }
206 f3a795ae 2021-08-03 op aliases[alias] = target;
207 f3a795ae 2021-08-03 op }
208 f3a795ae 2021-08-03 op
209 f3a795ae 2021-08-03 op # output in the correct format
210 f3a795ae 2021-08-03 op END {
211 f3a795ae 2021-08-03 op if (action == "aliases") {
212 f3a795ae 2021-08-03 op for (alias in aliases) {
213 f3a795ae 2021-08-03 op if (match(alias, "@"))
214 f3a795ae 2021-08-03 op continue;
215 f3a795ae 2021-08-03 op printf("%s: %s\n", alias, aliases[alias]);
216 f3a795ae 2021-08-03 op }
217 f3a795ae 2021-08-03 op } else if (action == "virtuals") {
218 f3a795ae 2021-08-03 op for (alias in aliases) {
219 f3a795ae 2021-08-03 op if (!match(alias, "@"))
220 f3a795ae 2021-08-03 op continue;
221 f3a795ae 2021-08-03 op printf("%s %s\n", alias, aliases[alias]);
222 f3a795ae 2021-08-03 op }
223 f3a795ae 2021-08-03 op } else if (action == "domains") {
224 f3a795ae 2021-08-03 op for (domain in domains) {
225 f3a795ae 2021-08-03 op printf("%s\n", domains[domain]);
226 f3a795ae 2021-08-03 op }
227 f3a795ae 2021-08-03 op } else if (action == "users") {
228 f3a795ae 2021-08-03 op for (user in users) {
229 f3a795ae 2021-08-03 op printf("%s %s\n", user, users[user]);
230 f3a795ae 2021-08-03 op }
231 f3a795ae 2021-08-03 op } else if (action == "users.passwd") {
232 f3a795ae 2021-08-03 op for (user in users) {
233 f3a795ae 2021-08-03 op # user@doma.in:hash::::::
234 f3a795ae 2021-08-03 op # user@doma.in:hash::::::userdb_quota_rule=*:storage=1G
235 f3a795ae 2021-08-03 op printf("%s:%s::::::\n", user, users[user]);
236 f3a795ae 2021-08-03 op }
237 f3a795ae 2021-08-03 op } else if (action == "users.mdirs") {
238 f3a795ae 2021-08-03 op for (user in users) {
239 f3a795ae 2021-08-03 op split(user, m, "@");
240 f3a795ae 2021-08-03 op # adjust the maildir path
241 f3a795ae 2021-08-03 op printf("/var/vmail/%s/%s/Maildir\n", m[2], m[1]);
242 f3a795ae 2021-08-03 op }
243 f3a795ae 2021-08-03 op } else {
244 f3a795ae 2021-08-03 op print "unknown action!\n" > "/dev/stderr"
245 f3a795ae 2021-08-03 op exit 1
246 f3a795ae 2021-08-03 op }
247 f3a795ae 2021-08-03 op }
248 f3a795ae 2021-08-03 op ```
249 f3a795ae 2021-08-03 op
250 f3a795ae 2021-08-03 op The AWK script needs the variable ‘action’ to be defined to dump the correct information. It can be provided with the ‘-v’ flag, but for extra-comfort I wrote also the following wrapper script:
251 f3a795ae 2021-08-03 op
252 f3a795ae 2021-08-03 op ```userctl the wrapper script
253 f3a795ae 2021-08-03 op #!/bin/sh
254 f3a795ae 2021-08-03 op
255 f3a795ae 2021-08-03 op if [ ! -f "userctl.awk" ]; then
256 f3a795ae 2021-08-03 op echo "Can't find userctl.awk!" >&2
257 f3a795ae 2021-08-03 op exit 1
258 f3a795ae 2021-08-03 op fi
259 f3a795ae 2021-08-03 op
260 f3a795ae 2021-08-03 op if [ ! -f "userdb" ]; then
261 f3a795ae 2021-08-03 op echo "Can't find userdb!" >&2
262 f3a795ae 2021-08-03 op exit 1
263 f3a795ae 2021-08-03 op fi
264 f3a795ae 2021-08-03 op
265 f3a795ae 2021-08-03 op # run <action>
266 f3a795ae 2021-08-03 op run()
267 f3a795ae 2021-08-03 op {
268 f3a795ae 2021-08-03 op awk -f userctl.awk -v action="$1" userdb
269 f3a795ae 2021-08-03 op }
270 f3a795ae 2021-08-03 op
271 f3a795ae 2021-08-03 op case "$1" in
272 f3a795ae 2021-08-03 op aliases) run "aliases" ;;
273 f3a795ae 2021-08-03 op virtuals) run "virtuals" ;;
274 f3a795ae 2021-08-03 op domains) run "domains" ;;
275 f3a795ae 2021-08-03 op users) run "users" ;;
276 f3a795ae 2021-08-03 op users.passwd) run "users.passwd" ;;
277 f3a795ae 2021-08-03 op users.mdirs) run "users.mdirs" ;;
278 f3a795ae 2021-08-03 op help)
279 f3a795ae 2021-08-03 op echo "USAGE: $0 <action>"
280 f3a795ae 2021-08-03 op echo "where action is one of"
281 f3a795ae 2021-08-03 op echo " - aliases"
282 f3a795ae 2021-08-03 op echo " - virtuals"
283 f3a795ae 2021-08-03 op echo " - domains"
284 f3a795ae 2021-08-03 op echo " - users"
285 f3a795ae 2021-08-03 op echo " - users.passwd"
286 f3a795ae 2021-08-03 op echo " - users.mdirs"
287 f3a795ae 2021-08-03 op ;;
288 f3a795ae 2021-08-03 op *)
289 f3a795ae 2021-08-03 op echo "Unknown action $1" >&2
290 f3a795ae 2021-08-03 op exit 1
291 f3a795ae 2021-08-03 op ;;
292 f3a795ae 2021-08-03 op esac
293 f3a795ae 2021-08-03 op ```
294 f3a795ae 2021-08-03 op
295 f3a795ae 2021-08-03 op Now that the framework is in place, the only missing piece is to use it to generate the files. I wrote yet another script to (re-)generate the tables and to create the maildir when a user is added.
296 f3a795ae 2021-08-03 op
297 f3a795ae 2021-08-03 op ```sync-userdb
298 f3a795ae 2021-08-03 op #!/bin/sh
299 f3a795ae 2021-08-03 op
300 f3a795ae 2021-08-03 op set -e
301 f3a795ae 2021-08-03 op
302 f3a795ae 2021-08-03 op # On OpenBSD these are only /etc/mail/…
303 f3a795ae 2021-08-03 op ./userctl aliases > /usr/local/etc/mail/aliases
304 f3a795ae 2021-08-03 op ./userctl virtuals > /usr/local/etc/mail/virtuals
305 f3a795ae 2021-08-03 op ./userctl domains > /usr/local/etc/mail/domains
306 f3a795ae 2021-08-03 op ./userctl users > /usr/local/etc/mail/passwd
307 f3a795ae 2021-08-03 op
308 f3a795ae 2021-08-03 op ./userctl users.passwd > /usr/local/etc/dovecot/users
309 f3a795ae 2021-08-03 op
310 f3a795ae 2021-08-03 op m()
311 f3a795ae 2021-08-03 op {
312 f3a795ae 2021-08-03 op if [ ! -d "$1" ]; then
313 f3a795ae 2021-08-03 op mkdir "$1"
314 f3a795ae 2021-08-03 op chown vmail:vmail "$1"
315 f3a795ae 2021-08-03 op fi
316 f3a795ae 2021-08-03 op }
317 f3a795ae 2021-08-03 op
318 f3a795ae 2021-08-03 op # ensure the maildirs exists
319 f3a795ae 2021-08-03 op for dir in $(./userctl users.mdirs); do
320 f3a795ae 2021-08-03 op homedir=$(dirname "$dir")
321 f3a795ae 2021-08-03 op domdir=$(dirname "$homedir")
322 f3a795ae 2021-08-03 op
323 f3a795ae 2021-08-03 op m "$domdir"
324 f3a795ae 2021-08-03 op m "$homedir"
325 f3a795ae 2021-08-03 op m "$dir"
326 f3a795ae 2021-08-03 op done
327 f3a795ae 2021-08-03 op
328 f3a795ae 2021-08-03 op # eventually add something like
329 f3a795ae 2021-08-03 op # service dovecot restart
330 f3a795ae 2021-08-03 op # service smtpd restart
331 f3a795ae 2021-08-03 op # for FreeBSD or
332 f3a795ae 2021-08-03 op # rcctl restart dovecot smtpd
333 f3a795ae 2021-08-03 op # for OpenBSD.
334 f3a795ae 2021-08-03 op ```
335 f3a795ae 2021-08-03 op
336 f3a795ae 2021-08-03 op ## Conclusion
337 f3a795ae 2021-08-03 op
338 f3a795ae 2021-08-03 op I don’t have a proper conclusion for this entry. Tools like this are usually almost always “work in progress”, as they are changed/extended over the time depending on what I need to do. One thing for sure, designing simple database files and managing them with AWK is lots of fun.
339 f3a795ae 2021-08-03 op
340 f3a795ae 2021-08-03 op As always, if you have comment, tips or noticed something that’s missing or not explained properly, don’t refrain from notifying me, so I can update this entry accordingly.