commit fcfd816548cc1d57d7973ae46529f7de137aedec Author: Pijus Kamandulis Date: Sat May 25 23:11:17 2024 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40ba34d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Ansible configuration files +ansible/inventory.yml +ansible/vars.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3bef017 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Pijus Kamandulis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93b8196 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# mail-server + +This repository contains the scripts, configuration files, and Ansible playbooks for my email server setup. After running my own email service for seven years and redoing the setup multiple times, I've decided to document everything here to streamline maintenance. + +Feel free to read, copy, use, and suggest improvements. + +## Configuration + +Ansible is used for configuration. The playbooks use a `vars.yml` file for settings. This file contains sensitive information, so it is not included in the repository. Below is an example of its structure: + +```yml +--- +ssh_public_key: "ssh-rsa AAAAB3...ak4EsUU=" +mx1_domains: + - mx1.pikami.org +mx1_mail_domain: "mx1.pikami.org" +mail_domains: + - pikami.net + - pikami.org +mail_users: + - user: bob@pikami.org + password: Password123 + virtuals: + - "bob@pikami.net" + - "bob.coolman@pikami.net" + - user: alice@pikami.org + password: Password123 + virtuals: + - "alice@pikami.net" +``` + +The hosts are taken from the `inventory.yml` file: + +```yml +all: + hosts: + mx1: + ansible_host: 51.158.215.227 +``` + +## Environment setup + +Install python on remote hosts: + +```sh +# replace "mx1" with server name +ansible -m raw -i inventory.yml -u root -a "pkg_add python%3.8" mx1 +``` + +Install required ansible collections: + +```sh +ansible-galaxy collection install ansible.posix +ansible-galaxy collection install community.general +``` + +Run playbooks + +```sh +# replace "01-initial_setup.yml" with the playbook you want to run +ansible-playbook -i inventory.yml 01-initial_setup.yml +``` + +Current ansible playbooks: + +- 01-initial_setup.yml + - applies available system patches + - upgrades all installed packages + - installs nano, curl and git + - disables ssh password logins + - adds ssh public key +- 02-ssl.yml - generates ssl certificates and adds a renew cron job +- 03-mail.yml - installs and configures dovecot and opensmtpd diff --git a/ansible/01-initial_setup.yml b/ansible/01-initial_setup.yml new file mode 100644 index 0000000..ed1d9da --- /dev/null +++ b/ansible/01-initial_setup.yml @@ -0,0 +1,41 @@ +- name: Initial System Setup + hosts: mx1 + remote_user: root + become: true + become_method: su + vars_files: + - vars.yml + tasks: + - name: Apply all available system patches + command: syspatch + register: syspatch + failed_when: syspatch.rc != 0 and syspatch.rc != 2 + changed_when: syspatch.rc == 0 + + - name: Update package list and upgrade all packages + command: pkg_add -u + + - name: Install essential packages + community.general.openbsd_pkg: + name: + - nano + - curl + - git + state: present + + - name: Disable SSH password authentication + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: "^#?PasswordAuthentication" + line: "PasswordAuthentication no" + state: present + + - name: Restart SSH service to apply changes + ansible.builtin.service: + name: sshd + state: restarted + + - name: Add SSH public key to authorized_keys + ansible.posix.authorized_key: + user: root + key: "{{ ssh_public_key }}" diff --git a/ansible/02-ssl.yml b/ansible/02-ssl.yml new file mode 100644 index 0000000..994fb5a --- /dev/null +++ b/ansible/02-ssl.yml @@ -0,0 +1,50 @@ +- name: SSL Setup + hosts: mx1 + remote_user: root + vars_files: + - vars.yml + tasks: + - name: Create vhost directories + file: + path: "/var/www/vhosts/{{ item }}" + state: directory + owner: www + with_items: "{{ mx1_domains }}" + + - name: Install httpd.conf + template: + src: "templates/httpd.conf" + dest: "/etc/httpd.conf" + + - name: Enable and start httpd + service: + name: httpd + enabled: yes + state: started + + - name: Install acme-client.conf + template: + src: "templates/acme-client.conf" + dest: "/etc/acme-client.conf" + + - name: Initial acme-client run + command: "/usr/sbin/acme-client {{ item }}" + args: + creates: "/etc/ssl/{{ item }}.fullchain.pem" + with_items: "{{ mx1_domains }}" + notify: + - reload httpd + + - name: Renew certificates via root crontab + cron: + name: "acme-client renew {{ item }}" + minute: "0" + job: "sleep $((RANDOM \\% 2048)) && acme-client {{ item }} && rcctl reload httpd" + user: root + with_items: "{{ mx1_domains }}" + + handlers: + - name: reload httpd + service: + name: httpd + state: reloaded diff --git a/ansible/03-mail.yml b/ansible/03-mail.yml new file mode 100644 index 0000000..1dc6c03 --- /dev/null +++ b/ansible/03-mail.yml @@ -0,0 +1,120 @@ +- name: OpenSMTPD Installation and Configuration + hosts: mx1 + remote_user: root + vars_files: + - vars.yml + tasks: + - name: Install Packages + community.general.openbsd_pkg: + name: + - opensmtpd-filter-dkimsign + - dovecot + - dovecot-pigeonhole + - opensmtpd-extras + state: present + + - name: Create the vmail group + group: + name: vmail + gid: 2000 + + - name: Create vmail user + user: + name: vmail + group: vmail + shell: /sbin/nologin + createhome: yes + home: /var/mail/vmail + uid: 2000 + + - name: Generate dkim keys + shell: | + KEYLEN=1024 + DOMAIN={{ mx1_mail_domain }} + + mkdir -p /etc/mail/dkim + if [ -f /etc/mail/dkim/$DOMAIN.key ]; then + echo "$DOMAIN.key already exists." + exit 0 + fi + + cd /etc/mail/dkim + (umask 337; openssl genrsa -out $DOMAIN.key $KEYLEN) + openssl rsa -in $DOMAIN.key -pubout -out $DOMAIN.pub + group info _dkimsign >/dev/null && chgrp _dkimsign $DOMAIN.key + echo "add the $DOMAIN.dns to the zone file" + echo "selector1._domainkey.$DOMAIN. 3600 IN TXT \"v=DKIM1; k=rsa; p=$(sed -e '1d' -e '$d' $DOMAIN.pub | tr -d '\n')\"" > ~/$DOMAIN.dns + + - name: Configure OpenSMTPD smtpd.conf + template: + src: "templates/smtpd.conf" + dest: /etc/mail/smtpd.conf + notify: + - reload smtpd + + - name: Enable and start OpenSMTPD service + service: + name: smtpd + enabled: yes + state: started + + - name: Delete default dovecot configs + shell: | + if [ -f /etc/dovecot/conf.d/10-ssl.conf ]; then + cd /etc/dovecot/ + rm -rf * + fi + + - name: Install dovecot.conf + template: + src: "templates/dovecot.conf" + dest: "/etc/dovecot/dovecot.conf" + notify: + - reload dovecot + + - name: Configure users + block: + - name: Remove existing + shell: | + echo "" > /etc/dovecot/users + chmod 640 /etc/dovecot/users + chown _smtpd:_dovecot /etc/dovecot/users + + echo "" > /etc/mail/accounts + chmod 640 /etc/mail/accounts + chown _smtpd: /etc/mail/accounts + + echo "" > /etc/mail/virtuals + chown _smtpd: /etc/mail/virtuals + + - name: Add user accounts + loop: "{{ mail_users }}" + no_log: true + shell: | + DOVECOT_PASS=$(doveadm pw -p {{ item.password }}) + SMTP_PASS=$(smtpctl encrypt {{ item.password }}) + + echo "{{ item.user }}:$DOVECOT_PASS::::" >> /etc/dovecot/users + echo "{{ item.user }}:$SMTP_PASS::::" >> /etc/mail/accounts + + - name: Install dovecot.conf + template: + src: "templates/virtuals.conf" + dest: "/etc/mail/virtuals" + + - name: Enable dovecot service + service: + name: dovecot + enabled: true + state: started + + handlers: + - name: reload smtpd + service: + name: smtpd + state: restarted + + - name: reload dovecot + service: + name: dovecot + state: reloaded diff --git a/ansible/templates/acme-client.conf b/ansible/templates/acme-client.conf new file mode 100644 index 0000000..1a54b30 --- /dev/null +++ b/ansible/templates/acme-client.conf @@ -0,0 +1,12 @@ +authority letsencrypt { + api url "https://acme-v02.api.letsencrypt.org/directory" + account key "/etc/acme/letsencrypt-privkey.pem" +} + +{% for domain in mx1_domains %} +domain "{{ domain }}" { + domain key "/etc/ssl/private/{{ domain }}.key" + domain full chain certificate "/etc/ssl/{{ domain }}.fullchain.pem" + sign with letsencrypt +} +{% endfor %} diff --git a/ansible/templates/dovecot.conf b/ansible/templates/dovecot.conf new file mode 100644 index 0000000..1b3d264 --- /dev/null +++ b/ansible/templates/dovecot.conf @@ -0,0 +1,127 @@ +# Enable ssl +ssl = required +ssl_cert = < /etc/ssl/{{ mx1_mail_domain }}.fullchain.pem +ssl_key = < /etc/ssl/private/{{ mx1_mail_domain }}.key + +ssl_min_protocol = TLSv1.2 +ssl_prefer_server_ciphers = yes + +disable_plaintext_auth = yes + +# Define used protocols +protocols = lmtp imap sieve + +service lmtp { + unix_listener lmtp { + user = vmail + group = vmail + } +} + +service imap-login { + inet_listener imap { + port = 143 + } + inet_listener imaps { + port = 993 + } +} + +# Setup users +passdb { + driver = passwd-file + args = scheme=ARGON2ID-CRYPT username_format=%u /etc/dovecot/users +} + +userdb { + driver = passwd-file + args = username_format=%u /etc/dovecot/users + override_fields = uid=vmail gid=vmail home=/var/mail/vmail/%d/%n +} + +# Setup mail location +mail_location = maildir:~/Maildir + +# setup auth listener +service auth { + unix_listener auth-userdb { + mode = 0660 + user = vmail + group = vmail + } +} + +# change the authworker to run as non-root +service auth-worker { + user = $default_internal_user +} + +# setup local delivery options +quota_full_tempfail = yes +protocol lda { + mail_plugins = $mail_plugins sieve +} +# setup some common mailboxes that are used by different clients to consistent destinations +namespace inbox { + inbox = yes + mailbox Spam { + special_use = \Junk + } + mailbox "Deleted Items" { + special_use = \Trash + } +} + +# setup local delivery protocol +protocol lmtp { + mail_plugins = $mail_plugins sieve +} + +# disable verify quota befor replying rcpt to +lmtp_rcpt_check_quota = no + +# setup stats service +service stats { + unix_listener stats-writer { + user = + group = $default_internal_group + mode = 0660 + } +} + +# enable mail plugins +mail_plugins = $mail_plugins notify + +# setup metrics +metric auth_success { + filter = event=auth_request_finished AND success=yes +} +metric auth_failures { + filter = event=auth_request_finished AND NOT success=yes +} +metric imap_command { + filter = event=imap_command_finished + group_by = cmd_name tagged_reply_state +} +metric smtp_command { + filter = event=smtp_server_command_finished + group_by = cmd_name status_code duration:exponential:1:5:10 +} +metric mail_delivery { + filter = event=mail_delivery_finsihed + group_by = duration:exponential:1:5:10 +} + +# enable IMAP protocol +protocol imap { + mail_plugins = $mail_plugins imap_sieve +} + +# setup sieve plugin options +# enable if there needs to be default sieve processing +#plugin { +# sieve_pipe_bin_dir = /usr/local/lib/dovecot/sieve +# sieve_before = /etc/dovecot/sieve/default.sieve +# sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment +# sieve_plugins = sieve_imapsieve sieve_extprograms +#} diff --git a/ansible/templates/httpd.conf b/ansible/templates/httpd.conf new file mode 100644 index 0000000..ce006e5 --- /dev/null +++ b/ansible/templates/httpd.conf @@ -0,0 +1,27 @@ +server "{{ inventory_hostname }}" { + listen on * port 80 + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + location * { + block return 302 "https://$HTTP_HOST$REQUEST_URI" + } +} + +{% for vhost in mx1_domains %} +server "{{ vhost }}" { + listen on * tls port 443 + tls { + certificate "/etc/ssl/{{ vhost }}.fullchain.pem" + key "/etc/ssl/private/{{ vhost }}.key" + } + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + location * { + root "/vhosts/{{ vhost }}" + } +} +{% endfor %} diff --git a/ansible/templates/smtpd.conf b/ansible/templates/smtpd.conf new file mode 100644 index 0000000..894d27d --- /dev/null +++ b/ansible/templates/smtpd.conf @@ -0,0 +1,23 @@ +pki {{ mx1_mail_domain }} cert "/etc/ssl/{{ mx1_mail_domain }}.fullchain.pem" +pki {{ mx1_mail_domain }} key "/etc/ssl/private/{{ mx1_mail_domain }}.key" + +table aliases file:/etc/mail/aliases +table users passwd:/etc/mail/accounts +table virtuals file:/etc/mail/virtuals + +filter dkimsign_rsa proc-exec "filter-dkimsign -d {{ mx1_mail_domain }} -s selector1 \ + -k /etc/mail/dkim/{{ mx1_mail_domain }}.key" user _dkimsign group _dkimsign + +listen on socket filter dkimsign_rsa +listen on all tls pki {{ mx1_mail_domain }} +listen on all port submission tls-require pki {{ mx1_mail_domain }} auth filter dkimsign_rsa +listen on all port smtps tls-require pki {{ mx1_mail_domain }} auth filter dkimsign_rsa + +action "local_mail" lmtp "/var/dovecot/lmtp" rcpt-to virtual +action "outbound" relay + +{% for domain in mail_domains %} +match from any for domain {{ domain }} action "local_mail" +{% endfor %} +match from local for local action "local_mail" +match from local for any action "outbound" diff --git a/ansible/templates/virtuals.conf b/ansible/templates/virtuals.conf new file mode 100644 index 0000000..00cf32b --- /dev/null +++ b/ansible/templates/virtuals.conf @@ -0,0 +1,8 @@ +{% for user in mail_users %} +{{ user.user }}: vmail +{% if (user.virtuals is defined) and user.virtuals %} +{% for virtual in user.virtuals %} +{{ virtual }}: {{ user.user }} +{% endfor %} +{% endif %} +{% endfor %}