Blob


1 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.
3 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:
5 => 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
7 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.
9 A more text-centric approach requires five configuration files:
10 * a passwd-like file
11 * an aliases table
12 * a domains table
13 * an authentication table
14 * a virtuals table
16 The tables are needed to load data into OpenSMTPD, while for Dovecot a single ‘/etc/passwd’-like file is enough.
18 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:
20 ```
21 # these are the paths on a FreeBSD host, on OpenBSD they’re
22 # just /etc/mail.
23 table aliases file:/usr/local/etc/mail/aliases
24 table domains file:/usr/local/etc/mail/domains
25 table passwd file:/usr/local/etc/mail/passwd
26 table virtuals file:/usr/local/etc/mail/virtuals
28 # pki, filters and listen directives omitted
30 action "remote_mail" lmtp "/var/run/dovecot/lmtp" rcpt-to virtual <virtuals>
31 action "local_mail" lmtp "/var/run/dovecot/lmtp" rcpt-to alias <aliases>
32 action "outbound" relay helo example.com
34 match from any for domain <domains> action "remote_mail"
35 match from local for local action "local_mail"
36 match from any auth for any action "outbound"
37 match for any action "outbound"
38 ```
40 The four ‘match’ rules matches in order
41 * incoming emails for the domains we’re serving
42 * local emails from one UNIX user to another
43 * outgoing emails from authenticated users
44 * 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!)
46 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.
48 ### passwd
50 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:
52 ```
53 op@example.com:<hash>::::::
54 ```
56 On the Dovecot site, things are a bit easier because there is no aliasing, resolving or expansions to do on the received emails.
58 ### alias table
60 An alias table looks like this:
62 ```example of an alias table file
63 root: op
64 op: op@example.com
65 ```
67 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.
69 ### domains table
71 Holds all the domains we’re accepting mails from. It can be specified in-line in the configuration file:
73 ```example of a domains table in smtpd.conf
74 table domains { "example.com", "foo.bar.net", … }
75 ```
77 or in a file with one domain name per line
79 ```example of a domains table as plain file
80 example.com
81 foo.bar.net
82 ```
84 ### Credentials table
86 A credentials table file looks like this:
88 ```example of a credentials table
89 user@doma.in password-hash
90 user2@example.com password-hash
91 ```
93 just a simple user ↔ hash mapping. Hashes can be computed with the encrypt subcommand of smtpctl
95 ```example on how to hash a password
96 $ smtpctl encrypt
97 p4ssw0rd
98 $2b$10$jpdOj8WPIMABsMs.LzFbiuSpgZ1TlGUj2ztBxEimoaQylQD/jhelS
99 ^D
100 ```
102 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
104 ``` example of auth-passwdfile.conf.ext file for Dovecot
105 passdb {
106 driver = passwd-file
107 # adjust SHA512-CRYPT eventually!
108 args = scheme=SHA512-CRYPT username_format=%u /usr/local/etc/dovecot/users
111 userdb {
112 driver = passwd-file
113 args = username_format=%u /usr/local/etc/dovecot/users
114 override_fields = home=/var/vmail/%d/%n
116 ```
118 Refer to the Dovecot documentation:
119 => https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ “Password Schemes” in the Dovecot documentation.
121 ### virtuals
123 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
125 ```example of a virtuals table
126 postmaster@example.com: op@example.com
127 aaa@example.com: op@example.com
128 op@example.com: vmail
129 otheruser@example.com: vmail
130 ```
132 ## Making it painless
134 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:
136 ```example of syntax of userdb
137 # local alias
138 alias root op
139 alias op op@example.com
141 # per virtual-domain config
142 example.com:
143 # Indentation is optional, but improves legibility.
144 # The following defines the user op@example.com;
145 # <hash> is the hash of the password computed
146 # with `smtpctl encrypt`
147 user op <hash>
148 # and define an arbitrary number of aliases
149 alias service1
150 alias other-alias
152 user otheruser <hash>
154 # aliases can be to virtual users on other hosts
155 alias abuse someone@example2.com
157 example2.com:
158 user someone <hash>
159 # …
160 ```
162 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.
164 => //git.omarpolo.com/vuserctl/ All the code examples are available in a git repository.
166 The AWK implementation that parses the file is also pretty simple:
168 ``` userctl.awk
169 #!/usr/bin/env awk
171 # expects action to be defined, like -v action=aliases
173 /^[[:space:]]*$/ { next }
174 /^[[:space:]]*#/ { next }
176 /:$/ {
177 # drop the :
178 gsub(":", "", $1);
179 domain = $1;
180 domains[domainslen++] = domain;
181 next;
184 $1 == "user" {
185 user = sprintf("%s@%s", $2, domain);
186 users[user] = $3
188 # change “vmail” to match the local user that
189 # delivers the mail
190 aliases[user] = "vmail";
191 next;
194 $1 == "alias" {
195 if ($3 != "") {
196 target = $3;
197 } else {
198 target = user;
201 if (domain != "") {
202 alias = sprintf("%s@%s", $2, domain);
203 } else {
204 alias = $2;
206 aliases[alias] = target;
209 # output in the correct format
210 END {
211 if (action == "aliases") {
212 for (alias in aliases) {
213 if (match(alias, "@"))
214 continue;
215 printf("%s: %s\n", alias, aliases[alias]);
217 } else if (action == "virtuals") {
218 for (alias in aliases) {
219 if (!match(alias, "@"))
220 continue;
221 printf("%s %s\n", alias, aliases[alias]);
223 } else if (action == "domains") {
224 for (domain in domains) {
225 printf("%s\n", domains[domain]);
227 } else if (action == "users") {
228 for (user in users) {
229 printf("%s %s\n", user, users[user]);
231 } else if (action == "users.passwd") {
232 for (user in users) {
233 # user@doma.in:hash::::::
234 # user@doma.in:hash::::::userdb_quota_rule=*:storage=1G
235 printf("%s:%s::::::\n", user, users[user]);
237 } else if (action == "users.mdirs") {
238 for (user in users) {
239 split(user, m, "@");
240 # adjust the maildir path
241 printf("/var/vmail/%s/%s/Maildir\n", m[2], m[1]);
243 } else {
244 print "unknown action!\n" > "/dev/stderr"
245 exit 1
248 ```
250 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:
252 ```userctl the wrapper script
253 #!/bin/sh
255 if [ ! -f "userctl.awk" ]; then
256 echo "Can't find userctl.awk!" >&2
257 exit 1
258 fi
260 if [ ! -f "userdb" ]; then
261 echo "Can't find userdb!" >&2
262 exit 1
263 fi
265 # run <action>
266 run()
268 awk -f userctl.awk -v action="$1" userdb
271 case "$1" in
272 aliases) run "aliases" ;;
273 virtuals) run "virtuals" ;;
274 domains) run "domains" ;;
275 users) run "users" ;;
276 users.passwd) run "users.passwd" ;;
277 users.mdirs) run "users.mdirs" ;;
278 help)
279 echo "USAGE: $0 <action>"
280 echo "where action is one of"
281 echo " - aliases"
282 echo " - virtuals"
283 echo " - domains"
284 echo " - users"
285 echo " - users.passwd"
286 echo " - users.mdirs"
287 ;;
288 *)
289 echo "Unknown action $1" >&2
290 exit 1
291 ;;
292 esac
293 ```
295 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.
297 ```sync-userdb
298 #!/bin/sh
300 set -e
302 # On OpenBSD these are only /etc/mail/…
303 ./userctl aliases > /usr/local/etc/mail/aliases
304 ./userctl virtuals > /usr/local/etc/mail/virtuals
305 ./userctl domains > /usr/local/etc/mail/domains
306 ./userctl users > /usr/local/etc/mail/passwd
308 ./userctl users.passwd > /usr/local/etc/dovecot/users
310 m()
312 if [ ! -d "$1" ]; then
313 mkdir "$1"
314 chown vmail:vmail "$1"
315 fi
318 # ensure the maildirs exists
319 for dir in $(./userctl users.mdirs); do
320 homedir=$(dirname "$dir")
321 domdir=$(dirname "$homedir")
323 m "$domdir"
324 m "$homedir"
325 m "$dir"
326 done
328 # eventually add something like
329 # service dovecot restart
330 # service smtpd restart
331 # for FreeBSD or
332 # rcctl restart dovecot smtpd
333 # for OpenBSD.
334 ```
336 ## Conclusion
338 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.
340 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.