From 1204730924436ef9e1c7c49c9557837f9a5ed0e8 Mon Sep 17 00:00:00 2001 From: clarkzjw Date: Wed, 8 Feb 2023 00:40:09 -0800 Subject: fork https://github.com/mattsta/mailweb --- ansible/README.md | 67 ++++- ansible/inventory/host_vars/mailmash/backup.yml | 18 ++ ansible/inventory/host_vars/mailmash/certs.yml | 13 + ansible/inventory/host_vars/mailmash/network.yml | 37 +++ ansible/inventory/host_vars/mailmash/sieve.yml | 9 + ansible/inventory/host_vars/webby/certs.yml | 23 ++ ansible/inventory/host_vars/webby/network.yml | 12 + ansible/inventory/host_vars/webby/nginx.yml | 77 +++++ ansible/inventory/inventory | 5 + ansible/mailmash.yml | 15 + ansible/roles/backup/meta/main.yml | 4 + ansible/roles/backup/tasks/main.yml | 40 +++ ansible/roles/backup/templates/borgmatic.yml.j2 | 36 +++ ansible/roles/certreload/tasks/main.yml | 21 ++ ansible/roles/certs/files/leforward.py | 68 +++++ .../certs/files/lets-encrypt-x3-cross-signed.pem | 27 ++ ansible/roles/certs/tasks/main.yml | 153 ++++++++++ ansible/roles/common/defaults/main.yml | 3 + ansible/roles/common/files/ffdhe2048.pem | 8 + ansible/roles/common/files/ffdhe3072.pem | 11 + ansible/roles/common/files/ffdhe4096.pem | 13 + ansible/roles/common/files/inputrc | 61 ++++ ansible/roles/common/files/net-listeners.py | 334 +++++++++++++++++++++ ansible/roles/common/files/ssh-transfer-only.sh | 11 + ansible/roles/common/files/vimrc.local | 25 ++ ansible/roles/common/handlers/main.yml | 20 ++ ansible/roles/common/tasks/main.yml | 301 +++++++++++++++++++ .../files/modprobe.d/blacklist-iptables.conf | 13 + ansible/roles/disableFirewall/tasks/main.yml | 22 ++ .../dovecot/files/dovecot/authdb.sqlite3.empty | Bin 0 -> 2048 bytes .../roles/dovecot/files/dovecot/conf.d/10-acl.conf | 15 + .../dovecot/files/dovecot/conf.d/10-auth.conf | 46 +++ .../dovecot/files/dovecot/conf.d/10-mail.conf | 28 ++ .../dovecot/files/dovecot/conf.d/10-master.conf | 82 +++++ .../roles/dovecot/files/dovecot/conf.d/15-lda.conf | 7 + .../dovecot/files/dovecot/conf.d/15-mailboxes.conf | 24 ++ .../dovecot/files/dovecot/conf.d/20-imap.conf | 10 + .../dovecot/files/dovecot/conf.d/20-lmtp.conf | 5 + .../dovecot/files/dovecot/conf.d/90-imapsieve.conf | 18 ++ .../dovecot/files/dovecot/conf.d/90-sieve.conf | 34 +++ .../dovecot/files/dovecot/dovecot-sql.conf.ext | 150 +++++++++ ansible/roles/dovecot/files/dovecot/dovecot.conf | 19 ++ .../files/dovecot/sieve-before.d/10-rspamd.sieve | 5 + .../dovecot/files/dovecot/sieve/report-ham.sieve | 23 ++ .../dovecot/files/dovecot/sieve/report-spam.sieve | 7 + .../roles/dovecot/files/dovecot/sieve/train-ham.sh | 1 + .../dovecot/files/dovecot/sieve/train-spam.sh | 1 + ansible/roles/dovecot/handlers/main.yml | 31 ++ ansible/roles/dovecot/tasks/main.yml | 110 +++++++ .../templates/dovecot/conf.d/10-ssl.conf.j2 | 22 ++ .../roles/fail2ban/files/fail2ban/fail2ban.local | 0 .../files/fail2ban/filter.d/postfix-rspamd.conf | 11 + ansible/roles/fail2ban/files/fail2ban/jail.local | 59 ++++ ansible/roles/fail2ban/handlers/main.yml | 5 + ansible/roles/fail2ban/tasks/main.yml | 28 ++ ansible/roles/gpg/tasks/main.yml | 6 + ansible/roles/network/tasks/main.yml | 40 +++ .../roles/network/templates/network/custom.link | 6 + .../roles/network/templates/network/custom.network | 38 +++ ansible/roles/nginx/defaults/main.yml | 6 + ansible/roles/nginx/files/conf.d/http.custom.conf | 84 ++++++ .../roles/nginx/files/tls/ssl_ciphers_intermediate | 10 + ansible/roles/nginx/files/tls/ssl_ciphers_modern | 7 + ansible/roles/nginx/files/tls/ssl_ciphers_tls13 | 7 + ansible/roles/nginx/files/tls/ssl_params | 55 ++++ ansible/roles/nginx/handlers/main.yml | 3 + ansible/roles/nginx/tasks/main.yml | 118 ++++++++ ansible/roles/nginx/templates/basic-site.conf.j2 | 68 +++++ ansible/roles/pip3/tasks/main.yml | 6 + ansible/roles/postfix/files/postfix/master.cf | 113 +++++++ ansible/roles/postfix/files/postfix/remap.sh | 9 + ansible/roles/postfix/handlers/main.yml | 13 + ansible/roles/postfix/tasks/main.yml | 50 +++ ansible/roles/postfix/templates/postfix/main.cf.j2 | 194 ++++++++++++ ansible/roles/ramdisk/tasks/main.yml | 13 + ansible/roles/rspamd/files/redis/redis.conf | 21 ++ .../files/rspamd/local.d/classifier-bayes.conf | 4 + .../rspamd/files/rspamd/local.d/greylist.conf | 1 + .../files/rspamd/local.d/milter_headers.conf | 3 + .../rspamd/files/rspamd/local.d/mx_check.conf | 2 + .../roles/rspamd/files/rspamd/local.d/neural.conf | 33 ++ .../rspamd/files/rspamd/local.d/neural_group.conf | 29 ++ .../rspamd/files/rspamd/local.d/phishing.conf | 3 + .../roles/rspamd/files/rspamd/local.d/redis.conf | 2 + .../roles/rspamd/files/rspamd/local.d/replies.conf | 2 + .../roles/rspamd/files/rspamd/local.d/surbl.conf | 2 + .../files/rspamd/local.d/url_reputation.conf | 2 + .../rspamd/files/rspamd/local.d/url_tags.conf | 2 + .../files/rspamd/local.d/worker-controller.inc | 39 +++ .../rspamd/files/rspamd/local.d/worker-normal.inc | 3 + .../rspamd/files/rspamd/local.d/worker-proxy.inc | 16 + ansible/roles/rspamd/handlers/main.yml | 11 + ansible/roles/rspamd/meta/main.yml | 3 + ansible/roles/rspamd/tasks/main.yml | 54 ++++ ansible/roles/sieve/tasks/main.yml | 20 ++ ansible/runner.sh | 41 +++ 96 files changed, 3325 insertions(+), 2 deletions(-) create mode 100644 ansible/inventory/host_vars/mailmash/backup.yml create mode 100644 ansible/inventory/host_vars/mailmash/certs.yml create mode 100644 ansible/inventory/host_vars/mailmash/network.yml create mode 100644 ansible/inventory/host_vars/mailmash/sieve.yml create mode 100644 ansible/inventory/host_vars/webby/certs.yml create mode 100644 ansible/inventory/host_vars/webby/network.yml create mode 100644 ansible/inventory/host_vars/webby/nginx.yml create mode 100644 ansible/inventory/inventory create mode 100644 ansible/mailmash.yml create mode 100644 ansible/roles/backup/meta/main.yml create mode 100644 ansible/roles/backup/tasks/main.yml create mode 100644 ansible/roles/backup/templates/borgmatic.yml.j2 create mode 100644 ansible/roles/certreload/tasks/main.yml create mode 100755 ansible/roles/certs/files/leforward.py create mode 100644 ansible/roles/certs/files/lets-encrypt-x3-cross-signed.pem create mode 100644 ansible/roles/certs/tasks/main.yml create mode 100644 ansible/roles/common/defaults/main.yml create mode 100644 ansible/roles/common/files/ffdhe2048.pem create mode 100644 ansible/roles/common/files/ffdhe3072.pem create mode 100644 ansible/roles/common/files/ffdhe4096.pem create mode 100644 ansible/roles/common/files/inputrc create mode 100755 ansible/roles/common/files/net-listeners.py create mode 100755 ansible/roles/common/files/ssh-transfer-only.sh create mode 100644 ansible/roles/common/files/vimrc.local create mode 100644 ansible/roles/common/handlers/main.yml create mode 100644 ansible/roles/common/tasks/main.yml create mode 100644 ansible/roles/disableFirewall/files/modprobe.d/blacklist-iptables.conf create mode 100644 ansible/roles/disableFirewall/tasks/main.yml create mode 100644 ansible/roles/dovecot/files/dovecot/authdb.sqlite3.empty create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/10-acl.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/10-auth.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/10-mail.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/10-master.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/15-lda.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/15-mailboxes.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/20-imap.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/20-lmtp.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/90-imapsieve.conf create mode 100644 ansible/roles/dovecot/files/dovecot/conf.d/90-sieve.conf create mode 100644 ansible/roles/dovecot/files/dovecot/dovecot-sql.conf.ext create mode 100644 ansible/roles/dovecot/files/dovecot/dovecot.conf create mode 100644 ansible/roles/dovecot/files/dovecot/sieve-before.d/10-rspamd.sieve create mode 100644 ansible/roles/dovecot/files/dovecot/sieve/report-ham.sieve create mode 100644 ansible/roles/dovecot/files/dovecot/sieve/report-spam.sieve create mode 100755 ansible/roles/dovecot/files/dovecot/sieve/train-ham.sh create mode 100755 ansible/roles/dovecot/files/dovecot/sieve/train-spam.sh create mode 100644 ansible/roles/dovecot/handlers/main.yml create mode 100644 ansible/roles/dovecot/tasks/main.yml create mode 100644 ansible/roles/dovecot/templates/dovecot/conf.d/10-ssl.conf.j2 create mode 100644 ansible/roles/fail2ban/files/fail2ban/fail2ban.local create mode 100644 ansible/roles/fail2ban/files/fail2ban/filter.d/postfix-rspamd.conf create mode 100644 ansible/roles/fail2ban/files/fail2ban/jail.local create mode 100644 ansible/roles/fail2ban/handlers/main.yml create mode 100644 ansible/roles/fail2ban/tasks/main.yml create mode 100644 ansible/roles/gpg/tasks/main.yml create mode 100644 ansible/roles/network/tasks/main.yml create mode 100644 ansible/roles/network/templates/network/custom.link create mode 100644 ansible/roles/network/templates/network/custom.network create mode 100644 ansible/roles/nginx/defaults/main.yml create mode 100644 ansible/roles/nginx/files/conf.d/http.custom.conf create mode 100644 ansible/roles/nginx/files/tls/ssl_ciphers_intermediate create mode 100644 ansible/roles/nginx/files/tls/ssl_ciphers_modern create mode 100644 ansible/roles/nginx/files/tls/ssl_ciphers_tls13 create mode 100644 ansible/roles/nginx/files/tls/ssl_params create mode 100644 ansible/roles/nginx/handlers/main.yml create mode 100644 ansible/roles/nginx/tasks/main.yml create mode 100644 ansible/roles/nginx/templates/basic-site.conf.j2 create mode 100644 ansible/roles/pip3/tasks/main.yml create mode 100644 ansible/roles/postfix/files/postfix/master.cf create mode 100755 ansible/roles/postfix/files/postfix/remap.sh create mode 100644 ansible/roles/postfix/handlers/main.yml create mode 100644 ansible/roles/postfix/tasks/main.yml create mode 100644 ansible/roles/postfix/templates/postfix/main.cf.j2 create mode 100644 ansible/roles/ramdisk/tasks/main.yml create mode 100644 ansible/roles/rspamd/files/redis/redis.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/classifier-bayes.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/greylist.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/milter_headers.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/mx_check.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/neural.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/neural_group.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/phishing.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/redis.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/replies.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/surbl.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/url_reputation.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/url_tags.conf create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/worker-controller.inc create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/worker-normal.inc create mode 100644 ansible/roles/rspamd/files/rspamd/local.d/worker-proxy.inc create mode 100644 ansible/roles/rspamd/handlers/main.yml create mode 100644 ansible/roles/rspamd/meta/main.yml create mode 100644 ansible/roles/rspamd/tasks/main.yml create mode 100644 ansible/roles/sieve/tasks/main.yml create mode 100755 ansible/runner.sh diff --git a/ansible/README.md b/ansible/README.md index f26ed5a..7b2591b 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -1,3 +1,66 @@ -# mail +mailweb: Matt's Mail and Web Ansible Config +=========================================== -Self hosted mail server +## What Is It? + +This is an Ansible playbook containing Ansible roles to configure +my personal mail infrastructure components including: + +- postfix +- dovecot +- rspamd +- borg-backup +- fail2ban + +Also includes web components for installing multi-site `nginx` servers. + +`mailweb` was created to apply on modern Ubuntu servers with a current +release version of Bionic 18.04 LTS (meaning: packages are deployed using +the `apt` module only currently). + +A full writeup about this architecutre is at [Building a Production Mail Server in 2018](https://matt.sh/email2018) + +## Organization + +To avoid mistakes like accidentally publishing all your private keys or +backup passphrases, we take advantage of Ansible's directory search hierarchy +to isolate non-public content from role directories. + +For example: to avoid committing our private keys to the public repository, +instead of putting keys in a subdirectory of the role itself (e.g. `./roles/certs/files/tls/site-key.pem`), +we place them at the top level `file` path Ansible also searches (e.g. `./files/certs/tls/site-key.pem`). + +The same goes for `hosts_vars` and `group_vars` using this insight from the +[Ansible docs](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html#splitting-out-host-and-group-specific-data): + +> Tip: The `group_vars/` and `host_vars/` directories can exist in the playbook directory OR the inventory directory. If both paths exist, variables in the playbook directory will override variables set in the inventory directory. + +So, we place our sample vars in `inventory/{group,host}_vars` for publishing in this +repository, then for actual usage we write production vars at the top level (which overrides the `inventory/*` vars). + +Now all we have to do is _not_ commit top level `files`, `group_vars`, and `host_vars` directories +into the public repository (only commit on local internal branches). This is helped +by our `.gitignore` in the public branch. View comments in `.gitignore` for more details +about private usage. + + +## Contributing + +Contributions welcome! Any PRs about improving configs towards security, usability, performance, and cross platform growth is encouraged. + +If you want to make changes for your own needs (but maybe not for _everybody's_ needs), feel free to submit the changes and just guard them with enable `when` blocks activated by config variables. + +Hopefully we can keep this architecture alive as its package components and underlying distributions grow over time. + +### Potential Problems + +- Not extensively tested outside my personal environment + - there's probably default vars missing in places; feel free to submit fixes + +### Acceptable Improvements + +- Feel free to submit better cross-platform integration + - cross-OS package management (`if centos` vs. `if debian` etc) + - should include better version checking/version pinning so we don't try to load 2018 configs into older servers not supporting modern options + - cross-OS config file locations, handlers, etc +- Update config files when [newer standards or features](https://matt.sh/web2018) get implemented and released diff --git a/ansible/inventory/host_vars/mailmash/backup.yml b/ansible/inventory/host_vars/mailmash/backup.yml new file mode 100644 index 0000000..aa5fcc4 --- /dev/null +++ b/ansible/inventory/host_vars/mailmash/backup.yml @@ -0,0 +1,18 @@ +--- +backup: + # Define a host in your ~/.ssh/config with username, host, and private key. + # Maintaining your ~/.ssh/config is out of scope for our implementation here. + host: rsn-backup + + # directories to backup can be amended at any time + dirs: + - /var/mail + - /var/lib/fail2ban + - /var/lib/redis + + # phrase is from: borg init --remote-path=borg1 --encryption=repokey-blake2 rsn-backup:mailmash + phrase: your phrase goes here + + # your runAs user should be able to ssh to the backup host without a password + # (remote login user is configured by Host->User in this user's ~/.ssh/config) + runAs: root diff --git a/ansible/inventory/host_vars/mailmash/certs.yml b/ansible/inventory/host_vars/mailmash/certs.yml new file mode 100644 index 0000000..fd6dde0 --- /dev/null +++ b/ansible/inventory/host_vars/mailmash/certs.yml @@ -0,0 +1,13 @@ +--- +certs: + # Copy only these private keys and certs from ansible into the system + requested: + - yourmail.server.com + + # we receive our certs by scp from the main cert hosting service elsewhere + receiver: true + + # These users have ansible-controlled ssh private keys + # (mainly for automated backups right now) + sshKeysForUsers: + - root diff --git a/ansible/inventory/host_vars/mailmash/network.yml b/ansible/inventory/host_vars/mailmash/network.yml new file mode 100644 index 0000000..e290f7b --- /dev/null +++ b/ansible/inventory/host_vars/mailmash/network.yml @@ -0,0 +1,37 @@ +--- +network: + # These interface names are used to pull IP addresses into templates. + # interface.public has the IP we use to open ports to the world (mail, web, etc) + # interface.private has the IP for private services (ssh login, reporting, etc) + # Right now we don't support multiple IPs per interface, we just grab the IPv4 + # address as presented by ansible fact e.g. 'ansible_{{interface.public}}.ipv4.address' + interface: + private: ens3 + public: ens4 + hostname: + # network.hostname.public is used populate templates with + # server's public hostname, including: + # - TLS certs to use with this pattern: + # /etc/ssl/[hostname]-cert-combined.pem + # /etc/ssl/[hostname]-key.pem + # - Postfix config parameter "myhostname" + public: yourmail.server.com + + # Below is almost (almost!) the netplan schema with three changes: + # - only one "interface" per system because ansible can't seem to generate multiple + # templates from one yaml dict? + # - so, the 'interface' key is added, other wise it would just be ethernets: ens4: ... + # - also, 'addresses' needs per-address broadcast, so addresses are now lists + # of dicts, so 'ip' is a new name as is 'broadcast' + ethernets: + interface: ens4 + macaddress: 02:00:00:7d:ca:ab + networks: + - subnet: 4.4.4.0/30 + gateway: 5.5.5.5 + addresses: + - 4.4.4.4/32 + routes: + - to: 0.0.0.0/0 + via: 5.5.5.5 + on-link: true diff --git a/ansible/inventory/host_vars/mailmash/sieve.yml b/ansible/inventory/host_vars/mailmash/sieve.yml new file mode 100644 index 0000000..635fb4e --- /dev/null +++ b/ansible/inventory/host_vars/mailmash/sieve.yml @@ -0,0 +1,9 @@ +--- +sieve: + users: + # username field is the filename [username].sieve in files/ + # The sieve file gets stored in vmail user directory [domain]/[user]/sieve/ + # We could improve this because it's clearly redundant... + - username: username@server.com + domain: server.com + user: username diff --git a/ansible/inventory/host_vars/webby/certs.yml b/ansible/inventory/host_vars/webby/certs.yml new file mode 100644 index 0000000..c38b80d --- /dev/null +++ b/ansible/inventory/host_vars/webby/certs.yml @@ -0,0 +1,23 @@ +--- +certs: + # Copy only these private keys and certs from ansible into the system + keyTypes: + - rsa2048 + - prime256v1 + +# requested can EITHER be: +# - just a list of hostnames (then we depoly all 'keyTypes' for each hostname) +# - or, a mapping of, e.g.: +# - host: example1.com +# type: rsa2048 +# - host: example1.com +# type: prime256v1 + required: + - example1.com + - example2.com + - example3.com + + # These users have ansible-controlled ssh private keys + # (mainly for automated backups right now) + sshKeysForUsers: [] + # - root diff --git a/ansible/inventory/host_vars/webby/network.yml b/ansible/inventory/host_vars/webby/network.yml new file mode 100644 index 0000000..39eb141 --- /dev/null +++ b/ansible/inventory/host_vars/webby/network.yml @@ -0,0 +1,12 @@ +--- +network: + # These interface names are used to pull IP addresses into templates. + # interface.public has the IP we use to open ports to the world (mail, web, etc) + # interface.private has the IP for private services (ssh login, reporting, etc) + # Right now we don't support multiple IPs per interface, we just grab the IPv4 + # address as presented by ansible fact e.g. 'ansible_{{interface.public}}.ipv4.address' + interface: + private: ens3 + public: ens4 + hostname: + public: webby diff --git a/ansible/inventory/host_vars/webby/nginx.yml b/ansible/inventory/host_vars/webby/nginx.yml new file mode 100644 index 0000000..87976dc --- /dev/null +++ b/ansible/inventory/host_vars/webby/nginx.yml @@ -0,0 +1,77 @@ +--- +nginx: + # Google webmaster tools wants this same filename on all hosts + google: + siteKey: googleYOURKEYHERE + siteKeyServeDir: /srv/web/files/ + + # ssl can be "modern" or "tls13" or anything else means default + ssl: default + + # These configs are directly uploaded as saved config files from files/nginx/ + complex: + - example4.com + - example5.com + + basic: [] + # These configs are generated by template description below. + # 'customConfig' is nginx directives placed for your http2 server block. + # basic: + # - domain: "example.com" + # uri: + # - path: / + # + # - domain: example2.com + # uri: + # - path: / + # appServer: "http://127.0.0.1:7780" + # customConfig: | + # keepalive_timeout 5 5; + # keepalive_requests 200; + # + # proxy_intercept_errors on; + # error_page 502 =503 @noserver; + # error_page 503 =503 @noserver; + # error_page 504 =503 @noserver; + # + # location @noserver { + # root /home/matt/repos/matt-prod/priv; + # charset utf-8; + # rewrite ^(.*)$ /noserver.txt break; + # } + # + # location /src/ { + # proxy_pass http://127.0.0.1:7780/$request_uri; + # add_header Cache-Control public; + # expires +5m; + # if ($args) { + # expires +1y; + # } + # } + # + # location /style { + # proxy_pass http://127.0.0.1:7780/$request_uri; + # add_header Cache-Control public; + # expires +5m; + # if ($args) { + # expires +1y; + # } + # } + # + # location /js { + # proxy_pass http://127.0.0.1:7780/$request_uri; + # add_header Cache-Control public; + # expires +5m; + # if ($args) { + # expires +1y; + # } + # } + # + # location /favicon.ico { + # empty_gif; + # } + # + # location /files { + # expires max; + # root /srv/web/matt.sh; + # } diff --git a/ansible/inventory/inventory b/ansible/inventory/inventory new file mode 100644 index 0000000..3999869 --- /dev/null +++ b/ansible/inventory/inventory @@ -0,0 +1,5 @@ +[mail] +mailmash ansible_python_interpreter=/usr/bin/python3 + +[web] +webby ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/mailmash.yml b/ansible/mailmash.yml new file mode 100644 index 0000000..15603b4 --- /dev/null +++ b/ansible/mailmash.yml @@ -0,0 +1,15 @@ +--- +- hosts: mail + remote_user: clarkzjw + become: yes + roles: + - common + - certs + - network + - disableFirewall + - rspamd + - dovecot + - sieve + - postfix + - fail2ban + - backup diff --git a/ansible/roles/backup/meta/main.yml b/ansible/roles/backup/meta/main.yml new file mode 100644 index 0000000..023023d --- /dev/null +++ b/ansible/roles/backup/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + # borgmatic is inside a pip3 package + - role: pip3 diff --git a/ansible/roles/backup/tasks/main.yml b/ansible/roles/backup/tasks/main.yml new file mode 100644 index 0000000..fedc68b --- /dev/null +++ b/ansible/roles/backup/tasks/main.yml @@ -0,0 +1,40 @@ +--- +- name: install borgbackup + apt: + pkg: borgbackup + state: latest + +- name: install borgmatic + pip: + name: borgmatic + state: latest + +- name: create backup config dir + file: + path: /etc/borgmatic.d + owner: "{{ backup.runAs }}" + mode: 0700 + state: directory + +# Create backup config for entire server +# Ideally we only have one type of data to backup per server and the rest +# can be re-constructed as necessary through auto-deploy processes +- name: populate borgmatic config with details for hosts + template: + src: borgmatic.yml.j2 + dest: /etc/borgmatic.d/system.backup.yml + owner: "{{ backup.runAs }}" + mode: 0600 + +# Note: right now we aren't populating an 'excludes' file +# If we need 'excludes' in the future, append '--excludes [excludesDirsFile]' +# ALSO NOTE: your backup.runAs user MUST MANUALLY ACCEPT THE BACKUP HOST SSH KEY +# Backup will stall if unattended ssh sees new host fingerprint needing approval +- name: install backup crontab + cron: + name: "Backup Offsite" + minute: 32 + hour: 3 + job: "borgmatic --verbosity 1 -c /etc/borgmatic.d/system.backup.yml" + user: "{{ backup.runAs }}" + cron_file: backup_offsite diff --git a/ansible/roles/backup/templates/borgmatic.yml.j2 b/ansible/roles/backup/templates/borgmatic.yml.j2 new file mode 100644 index 0000000..864c8a6 --- /dev/null +++ b/ansible/roles/backup/templates/borgmatic.yml.j2 @@ -0,0 +1,36 @@ +location: + # List of source directories to backup. Globs are expanded. + source_directories: +{% for dir in backup.dirs %} + - {{ dir }} +{% endfor %} + + # Paths to local or remote repositories. + repositories: + - {{ backup.host }}:{{ inventory_hostname }} + + one_file_system: True + remote_path: borg1 + + # Any paths matching these patterns are excluded from backups. + exclude_patterns: + - /home/*/.cache + +storage: + encryption_passphrase: {{ backup.phrase }} + compression: lz4 + +retention: + # Retention policy for how many backups to keep in each category. + keep_within: 3H + keep_daily: 7 + keep_weekly: 2 + keep_monthly: 3 + +consistency: + # List of consistency checks to run: "repository", "archives", or both. + checks: + - repository + - archives + + check_last: 1 diff --git a/ansible/roles/certreload/tasks/main.yml b/ansible/roles/certreload/tasks/main.yml new file mode 100644 index 0000000..0e4fcc7 --- /dev/null +++ b/ansible/roles/certreload/tasks/main.yml @@ -0,0 +1,21 @@ +--- +# This is a hack because ansible can't trigger handlers if they don't +# exist, so we can't have our 'certs' role unconditionally fire things +# like "reload nginx" and "reload postfix" because those don't exist +# in every deployment. +# As a hack, just check if /etc/ssl was recently modified then reload +- name: check certificate update recency + stat: + path: /etc/ssl + register: statSSL + +# Have to mock a command resulting in some "changed" status so ansible +# allows itself to trigger handlers. +# The actual restriction on this handler is the 'when' clause, not +# the command itself. +- name: reload because certs are newish + command: /bin/true + when: ((ansible_date_time.epoch |int) - (statSSL.stat.mtime |int)) < 300 + notify: + - "{{ item }}" + loop: "{{ certreload.notifiers }}" diff --git a/ansible/roles/certs/files/leforward.py b/ansible/roles/certs/files/leforward.py new file mode 100755 index 0000000..dccbac1 --- /dev/null +++ b/ansible/roles/certs/files/leforward.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +""" Run a single-purpose HTTP server. + +Server takes all GET requests and redirects them to a new host +if the request URI starts with SUBPATH, otherwise returns 404. + +Requests are redirected to the URL provided by --baseurl. """ + +import socketserver +import http.server +import argparse +import sys + + +CHALLENGE_HOST = None +SUBPATH = "/.well-known/acme-challenge" + + +class RedirectChallenges(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith(SUBPATH): + self.send_response(301) + self.send_header('Location', f"{CHALLENGE_HOST}{self.path}") + else: + self.send_response(404) + + self.end_headers() + + +class ReusableServer(socketserver.TCPServer): + """ Allow TCPServer to reuse host address. + + Without setting 'allow_reuse_address', we can get stuck in + TIME_WAIT after being killed and the stale state stops a new + server from attaching to the port.""" + + allow_reuse_address = True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Redirect all URIs with matching prefix to another host") + parser.add_argument( + '--baseurl', + dest='baseurl', + required=True, + help="Destination URL for all matching URIs on this server") + + args = parser.parse_args() + CHALLENGE_HOST = args.baseurl + + if not CHALLENGE_HOST.startswith("http"): + print("Redirect URL must be a full URL starting with http") + sys.exit(1) + + # If user gave us a trailing slash URL, remove slash. + if CHALLENGE_HOST[-1] == "/": + CHALLENGE_HOST = CHALLENGE_HOST[:-1] + + serverAddress = ('', 80) + + # Note: if running remotely by an SSH command, you MUST launch with '-t': + # > ssh -t me@otherhost leforward.py --baseurl http://otherserver.com + # If you omit '-t' the listening server won't terminate when you kill the + # ssh session, which probably isn't what you want. + with ReusableServer(serverAddress, RedirectChallenges) as httpd: + httpd.serve_forever() diff --git a/ansible/roles/certs/files/lets-encrypt-x3-cross-signed.pem b/ansible/roles/certs/files/lets-encrypt-x3-cross-signed.pem new file mode 100644 index 0000000..0002462 --- /dev/null +++ b/ansible/roles/certs/files/lets-encrypt-x3-cross-signed.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow +SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT +GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF +q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8 +SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0 +Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA +a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj +/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T +AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG +CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv +bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k +c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw +VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC +ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz +MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu +Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF +AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo +uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/ +wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu +X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG +PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6 +KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg== +-----END CERTIFICATE----- diff --git a/ansible/roles/certs/tasks/main.yml b/ansible/roles/certs/tasks/main.yml new file mode 100644 index 0000000..e83a640 --- /dev/null +++ b/ansible/roles/certs/tasks/main.yml @@ -0,0 +1,153 @@ +--- +- name: remove default ubuntu key + file: + path: /etc/ssl/private/ssl-cert-snakeoil.key + state: absent + +- name: create cert maint group + group: + name: certmaint + gid: 1070 + state: present + +- name: create cert maint user + user: + name: certmaint + uid: 1070 + group: ssl-cert + groups: + - certmaint + shell: /bin/sh + create_home: yes + state: present + +#- name: allow certmaint to maint certs and keys (default) +# acl: +# path: /etc/ssl/ +# etype: user +# entity: certmaint +# permissions: rw +# default: yes +# recursive: yes +# state: present +# no_log: true + +#- name: allow certmaint to maint certs and keys (actual certs) +# acl: +# path: /etc/ssl/ +# etype: user +# entity: certmaint +# permissions: rwx +# state: present +# no_log: true + +#- name: allow certmaint to maint certs and keys (actual keys) +# acl: +# path: /etc/ssl/private/ +# etype: user +# entity: certmaint +# permissions: rwx +# state: present +# no_log: true + +# Keys are private: only owner can read/write, and only group can read +- name: populate required keys (common types) + copy: + src: "tls/private/{{ item[0] }}-key.{{ item[1] }}.pem" + dest: /etc/ssl/private/ + mode: 0640 + owner: certmaint + group: ssl-cert + loop: "{{ certs.required |product(certs.keyTypes) |list }}" + when: certs.required[0] is string + + +# Certs are owned by 'certmaint' so user 'certmaint' can update them over scp +# Certs are public (obviously) +- name: populate required certs (common types) + copy: + src: "tls/{{ item[0] }}-cert-combined.{{ item[1] }}.pem" + dest: /etc/ssl/ + mode: 0644 + owner: certmaint + loop: "{{ certs.required |product(certs.keyTypes) |list }}" + when: certs.required[0] is string + + + +# Keys are private: only owner can read/write, and only group can read +- name: populate required keys (specific types) + copy: + src: "tls/private/{{ item.host }}-key.{{ item.type }}.pem" + dest: /etc/ssl/private/ + mode: 0640 + owner: certmaint + group: ssl-cert + loop: "{{ certs.required }}" + when: certs.required[0] is mapping + +# Certs are owned by 'certmaint' so user 'certmaint' can update them over scp +# Certs are public (obviously) +- name: populate required certs (specific types) + copy: + src: "tls/{{ item.host }}-cert-combined.{{ item.type }}.pem" + dest: /etc/ssl/ + mode: 0644 + owner: certmaint + loop: "{{ certs.required }}" + when: certs.required[0] is mapping + + + +- name: plop LE cert chain + copy: + src: "tls/lets-encrypt-x3-cross-signed.pem" + dest: /etc/ssl/ + mode: 0644 + owner: certmaint + +- name: plop remote LE challenge redirector + copy: + src: leforward.py + dest: /usr/local/bin/ + mode: 0755 + when: + - certs.receiver is defined and certs.receiver + + +# Retrieve all users on this host (creates variable 'passwd' containing results) +- name: get all user details so we can populate home directories + getent: + database: passwd + +# Copy users/hostname/username contents into remote home directory +- name: verify explicit user keys exist as expected + copy: + src: "users/{{ inventory_hostname }}/{{ item }}/" + # [item][4] is [username][homedir] where /etc/passwd is tokenized on ':' + # and username becomes the key with remaining fields indexed by integers + dest: "{{ getent_passwd[item][4] }}" + mode: 0600 + owner: "{{ item }}" + directory_mode: 0700 + loop: "{{ certs.sshKeysForUsers }}" + +# TODO: we could make one key per action then restrict actions by ssh key. +# (postfix key, dovecot key, nginx key, leforward key) +- name: verify certmaint receiver key exists + copy: + src: "users/certmaint/" + dest: "{{ getent_passwd[item][4] }}" + mode: 0600 + owner: "{{ item }}" + directory_mode: 0700 + loop: + - certmaint + +- name: allow certmaint group to sudo reload relevant services + lineinfile: + path: /etc/sudoers.d/certmaint_reloads + regexp: "^%certmaint" + line: "%certmaint ALL = (root) NOPASSWD: /usr/sbin/service postfix reload, /usr/sbin/service dovecot reload, /usr/sbin/service nginx reload" + create: yes + mode: 0440 diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000..2410e85 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,3 @@ +--- +grub: + extras: "" diff --git a/ansible/roles/common/files/ffdhe2048.pem b/ansible/roles/common/files/ffdhe2048.pem new file mode 100644 index 0000000..9b182b7 --- /dev/null +++ b/ansible/roles/common/files/ffdhe2048.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== +-----END DH PARAMETERS----- diff --git a/ansible/roles/common/files/ffdhe3072.pem b/ansible/roles/common/files/ffdhe3072.pem new file mode 100644 index 0000000..fb31ccd --- /dev/null +++ b/ansible/roles/common/files/ffdhe3072.pem @@ -0,0 +1,11 @@ +-----BEGIN DH PARAMETERS----- +MIIBiAKCAYEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEfz9zeNVs7ZRkDW7w09N75nAI4YbRvydbmyQd62R0mkff3 +7lmMsPrBhtkcrv4TCYUTknC0EwyTvEN5RPT9RFLi103TZPLiHnH1S/9croKrnJ32 +nuhtK8UiNjoNq8Uhl5sN6todv5pC1cRITgq80Gv6U93vPBsg7j/VnXwl5B0rZsYu +N///////////AgEC +-----END DH PARAMETERS----- diff --git a/ansible/roles/common/files/ffdhe4096.pem b/ansible/roles/common/files/ffdhe4096.pem new file mode 100644 index 0000000..3cf0fcb --- /dev/null +++ b/ansible/roles/common/files/ffdhe4096.pem @@ -0,0 +1,13 @@ +-----BEGIN DH PARAMETERS----- +MIICCAKCAgEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEfz9zeNVs7ZRkDW7w09N75nAI4YbRvydbmyQd62R0mkff3 +7lmMsPrBhtkcrv4TCYUTknC0EwyTvEN5RPT9RFLi103TZPLiHnH1S/9croKrnJ32 +nuhtK8UiNjoNq8Uhl5sN6todv5pC1cRITgq80Gv6U93vPBsg7j/VnXwl5B0rZp4e +8W5vUsMWTfT7eTDp5OWIV7asfV9C1p9tGHdjzx1VA0AEh/VbpX4xzHpxNciG77Qx +iu1qHgEtnmgyqQdgCpGBMMRtx3j5ca0AOAkpmaMzy4t6Gh25PXFAADwqTs6p+Y0K +zAqCkc3OyX3Pjsm1Wn+IpGtNtahR9EGC4caKAH5eZV9q//////////8CAQI= +-----END DH PARAMETERS----- diff --git a/ansible/roles/common/files/inputrc b/ansible/roles/common/files/inputrc new file mode 100644 index 0000000..47e7275 --- /dev/null +++ b/ansible/roles/common/files/inputrc @@ -0,0 +1,61 @@ +# do not bell on tab-completion +#set bell-style none + +set meta-flag on +set input-meta on +set convert-meta off +set output-meta on + +# Completed names which are symbolic links to +# directories have a slash appended. +set mark-symlinked-directories on + +$if mode=emacs + +# for linux console and RH/Debian xterm +"\e[1~": beginning-of-line +"\e[4~": end-of-line +# commented out keymappings for pgup/pgdown to reach begin/end of history +#"\e[5~": beginning-of-history +#"\e[6~": end-of-history +"\e[5~": history-search-backward +"\e[6~": history-search-forward +"\e[3~": delete-char +"\e[2~": quoted-insert +"\e[5C": forward-word +"\e[5D": backward-word +"\e[1;5C": forward-word +"\e[1;5D": backward-word + +# for rxvt +"\e[8~": end-of-line +"\eOc": forward-word +"\eOd": backward-word + +# for non RH/Debian xterm, can't hurt for RH/DEbian xterm +"\eOH": beginning-of-line +"\eOF": end-of-line + +# for freebsd console +"\e[H": beginning-of-line +"\e[F": end-of-line +$endif + +# cd d will match documents or Documents +set completion-ignore-case on + +# front-of-command up and down completion +"\e[A":history-search-backward +"\e[B":history-search-forward + +# This is the magic command. +# Enables sane tcsh-like ctrl-d completion showing. +Control-d:delete-char-or-list + +# oddly, menu-complete makes the menu *not* appear +# just cycle through each option +Tab:menu-complete + +# stop asking if I "Really want to see 102 completions" +set completion-query-items 350 +set page-completions off diff --git a/ansible/roles/common/files/net-listeners.py b/ansible/roles/common/files/net-listeners.py new file mode 100755 index 0000000..f8b39cd --- /dev/null +++ b/ansible/roles/common/files/net-listeners.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 + +""" Output a colorized list of listening addresses with owners. + +This tool parses files in /proc directly to obtain the list +of IPv4 and IPv6 addresses listening on tcp, tcp6, udp, and udp6 ports +also with pids of processes responsible for the listening. + +Due to permission restrictions on Linux, script must be run as root +to determine which pids match which listening sockets. + +This is also something like: + osqueryi "select po.pid, rtrim(p.cmdline), po.family, po.local_address, po.local_port from process_open_sockets as po JOIN processes as p ON po.pid=p.pid WHERE po.state='LISTEN';" + +""" + +import collections +import subprocess +import codecs +import socket +import struct +import glob +import sys +import re +import os + +TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size" + +ONLY_LOWEST_PID = False + +# oooh, look, a big dirty global dict collecting all our data without being +# passed around! call the programming police! +inodes = {} + + +class Color: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + END = '\033[0m' + + +COLOR_HEADER = Color.HEADER +COLOR_OKAY = Color.OKBLUE +COLOR_WARNING = Color.FAIL +COLOR_END = Color.END + +# This should capture: +# 127.0.0.0/8 +# 192.168.0.0/16 +# 10.0.0.0/8 +# 169.254.0.0/16 +# 172.16.0.0/12 +# ::1 +# fe80::/10 +# fc00::/7 +# fd00::/8 +NON_ROUTABLE_REGEX = r"""^((127\.) | + (192\.168\.) | + (10\.) | + (169\.254\.) | + (172\.1[6-9]\.) | + (172\.2[0-9]\.) | + (172\.3[0-1]\.) | + (::1) | + ([fF][eE]80) + ([fF][cCdD]))""" +likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE) + + +def run(thing): + """ Run any string as an async command invocation. """ + # We don't use subprocess.check_output because we want to run all + # processes async + return subprocess.Popen(thing.split(), stdout=subprocess.PIPE) + + +def readOutput(ranCommand): + """ Return array of rows split by newline from previous invocation. """ + stdout, stderr = ranCommand.communicate() + return stdout.decode('utf-8').strip().splitlines() + + +def procListeners(): + """ Wrapper to parse all IPv4 tcp udp, and, IPv6 tcp6 udp6 listeners. """ + + def processProc(name): + """ Process IPv4 and IPv6 versions of listeners based on ``name``. + + ``name`` is either 'udp' or 'tcp' so we parse, for each ``name``: + - /proc/net/[name] + - /proc/net/[name]6 + + As in: + - /proc/net/tcp + - /proc/net/tcp6 + - /proc/net/udp + - /proc/net/udp6 + """ + + def ipv6(addr): + """ Convert /proc IPv6 hex address into standard IPv6 notation. """ + # turn ASCII hex address into binary + addr = codecs.decode(addr, "hex") + + # unpack into 4 32-bit integers in big endian / network byte order + addr = struct.unpack('!LLLL', addr) + + # re-pack as 4 32-bit integers in system native byte order + addr = struct.pack('@IIII', *addr) + + # now we can use standard network APIs to format the address + addr = socket.inet_ntop(socket.AF_INET6, addr) + return addr + + def ipv4(addr): + """ Convert /proc IPv4 hex address into standard IPv4 notation. """ + # Instead of codecs.decode(), we can just convert a 4 byte hex + # string to an integer directly using python radix conversion. + # Basically, int(addr, 16) EQUALS: + # aOrig = addr + # addr = codecs.decode(addr, "hex") + # addr = struct.unpack(">L", addr) + # assert(addr == (int(aOrig, 16),)) + addr = int(addr, 16) + + # system native byte order, 4-byte integer + addr = struct.pack("=L", addr) + addr = socket.inet_ntop(socket.AF_INET, addr) + return addr + + isUDP = name == "udp" + + # Iterate four files: /proc/net/{tcp,udp}{,6} + # ipv4 has no prefix, while ipv6 has 6 appended. + for ver in ["", "6"]: + with open(f"/proc/net/{name}{ver}", 'r') as proto: + proto = proto.read().splitlines() + proto = proto[1:] # drop header row + + for cxn in proto: + cxn = cxn.split() + + # /proc/net/udp{,6} uses different constants for LISTENING + if isUDP: + # These constants are based on enum offsets inside + # the Linux kernel itself. They aren't likely to ever + # change since they are hardcoded in utilities. + isListening = cxn[3] == "07" + else: + isListening = cxn[3] == "0A" + + # Right now this is a single-purpose tool so if process is + # not listening, we avoid further processing of this row. + if not isListening: + continue + + ip, port = cxn[1].split(':') + if ver: + ip = ipv6(ip) + else: + ip = ipv4(ip) + + port = int(port, 16) + inode = cxn[9] + + # We just use a list here because creating a new sub-dict + # for each entry was noticably slower than just indexing + # into lists. + inodes[int(inode)] = [ip, port, f"{name}{ver}"] + + processProc("tcp") + processProc("udp") + + +def appendToInodePidMap(fd, inodePidMap): + """ Take a full path to /proc/[pid]/fd/[fd] for reading. + + Populates both pid and full command line of pid owning an inode we + are interested in. + + Basically finds if any inodes on this pid is a listener we previously + recorded into our ``inodes`` dict. """ + _, _, pid, _, _ = fd.split('/') + try: + target = os.readlink(fd) + except FileNotFoundError: + # file vanished, can't do anything else + return + + if target.startswith("socket"): + ostype, inode = target.split(':') + # strip brackets from fd string (it looks like: [fd]) + inode = int(inode[1:-1]) + inodePidMap[inode].append(int(pid)) + + +def addProcessNamesToInodes(): + """ Loop over every fd in every process in /proc. + + The only way to map an fd back to a process is by looking + at *every* processes fd and extracting backing inodes. + + It's basically like a big awkward database join where you don't + have an index on the field you want. + + Also, due to Linux permissions (and Linux security concerns), + only the root user can read fd listing of processes not owned + by the current user. """ + + # glob glob glob it all + allFDs = glob.iglob("/proc/*/fd/*") + inodePidMap = collections.defaultdict(list) + + for fd in allFDs: + appendToInodePidMap(fd, inodePidMap) + + for inode in inodes: + if inode in inodePidMap: + for pid in inodePidMap[inode]: + try: + with open(f"/proc/{pid}/cmdline", 'r') as cmd: + # /proc command line arguments are delimited by + # null bytes, so undo that here... + cmdline = cmd.read().split('\0') + inodes[inode].append((pid, cmdline)) + except BaseException: + # files can vanish on us at any time (and that's okay!) + # But, since the file is gone, we want the entire fd + # entry gone too: + pass # del inodes[inode] + + +def checkListenersProc(): + terminalWidth = run(TERMINAL_WIDTH) + + procListeners() + addProcessNamesToInodes() + tried = inodes + + try: + cols = readOutput(terminalWidth)[0] + cols = int(cols) + except BaseException: + cols = 80 + + # Print our own custom output header... + proto = "Proto" + addr = "Listening" + pid = "PID" + process = "Process" + print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}") + + # Could sort by anything: ip, port, proto, pid, command name + # (or even the fd integer if that provided any insight whatsoever) + def compareByPidOrPort(what): + k, v = what + # v = [ip, port, proto, pid, cmd] + # - OR - + # v = [ip, port, proto] + + # If we're not running as root we can't pid and command mappings for + # the processes of other users, so sort the pids we did find at end + # of list and show UNKNOWN entries first + # (because the lines will be shorter most likely so the bigger visual + # weight should be lower in the display table) + try: + # Pid available! Sort by first pid, subsort by IP then port. + return (1, v[3], v[0], v[1]) + except BaseException: + # No pid available! Sort by port number then IP then... port again. + return (0, v[1], v[0], v[1]) + + # Sort results by pid... + for name, vals in sorted(tried.items(), key=compareByPidOrPort): + attachedPids = vals[3:] + if attachedPids: + desc = [f"{pid:5} {' '.join(cmd)}" for pid, cmd in vals[3:]] + else: + # If not running as root, we won't have pid or process, so use + # defaults + desc = ["UNKNOWN (must be root for global pid mappings)"] + + port = vals[1] + try: + # Convert port integer to service name if possible + port = socket.getservbyport(port) + except BaseException: + # If no match, just use port number directly. + pass + + addr = f"{vals[0]}:{port}" + proto = vals[2] + + # If IP address looks like it could be visible to the world, + # throw up a color. + # Note: due to port forwarding and NAT and other issues, + # this clearly isn't exhaustive. + if re.match(likelyLocalOnly, addr): + colorNotice = COLOR_OKAY + else: + colorNotice = COLOR_WARNING + + isFirstLine = True + for line in desc: + if isFirstLine: + output = f"{colorNotice}{proto:5} {addr:25} {line}" + isFirstLine = False + else: + output = f"{' ':31} {line}" + + # Be a polite terminal citizen by limiting our width to user's width + # (colors take up non-visible space, so add it to our col count) + print(output[:cols + (len(colorNotice) if isFirstLine else 0)]) + + if ONLY_LOWEST_PID: + break + + print(COLOR_END) + + +if __name__ == "__main__": + # cheap hack garbage way of setting one option + # if we need more options, obviously pull in argparse + if len(sys.argv) > 1: + ONLY_LOWEST_PID = True + else: + ONLY_LOWEST_PID = False + + checkListenersProc() diff --git a/ansible/roles/common/files/ssh-transfer-only.sh b/ansible/roles/common/files/ssh-transfer-only.sh new file mode 100755 index 0000000..c1f0624 --- /dev/null +++ b/ansible/roles/common/files/ssh-transfer-only.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Only allow ssh commands starting with 'scp' or 'rsync' +case $SSH_ORIGINAL_COMMAND in + scp*) + $SSH_ORIGINAL_COMMAND ;; + rsync*) + $SSH_ORIGINAL_COMMAND ;; + *) + echo "Not allowed with this key: $SSH_ORIGINAL_COMMAND" ;; +esac diff --git a/ansible/roles/common/files/vimrc.local b/ansible/roles/common/files/vimrc.local new file mode 100644 index 0000000..378d91e --- /dev/null +++ b/ansible/roles/common/files/vimrc.local @@ -0,0 +1,25 @@ +set encoding=utf-8 + +set ignorecase +set smartcase + +set title + +set backupdir=~/.vim-tmp,/tmp +set directory=~/.vim-tmp,/tmp + +set ruler + +filetype plugin indent on + +set ai +set expandtab +set tabstop=4 +set shiftwidth=4 + +autocmd BufEnter * :syntax sync fromstart + +set hlsearch +colorscheme peachpuff + +set wildignore+=*/tmp/*,*.so,*.swp,*.zip,*.o,*.dSYM,tags diff --git a/ansible/roles/common/handlers/main.yml b/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..ade4fea --- /dev/null +++ b/ansible/roles/common/handlers/main.yml @@ -0,0 +1,20 @@ +--- +- name: reload sshd + service: + name: sshd + state: reloaded + +- name: reload grub + command: update-grub + +- name: double disable systemd ntp client + command: timedatectl set-ntp false + +- name: clear motd cache + file: + path: "{{ item }}" + state: absent + loop: + - /var/cache/motd-news + - /run/motd.dynamic + - /run/motd.dynamic.new diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..23de53c --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,301 @@ +--- +# You can manually view how your OS-provided packages are supported with: +# ubuntu-support-status --show-all +- name: update packages + apt: + update_cache: yes + upgrade: safe + cache_valid_time: 3600 + + +- name: fix inputrc + copy: + src: inputrc + dest: /etc/inputrc + owner: root + group: root + mode: 0644 + +- name: fix vimrc + copy: + src: vimrc.local + dest: /etc/vim/ + owner: root + group: root + mode: 0644 + + +- include_role: + name: ramdisk + + +- name: remove ubuntu call home reporting cron + cron: + cron_file: popularity-contest + state: absent + + +- name: remove low port restriction + sysctl: + name: net.ipv4.ip_unprivileged_port_start + value: 0 + state: present + sysctl_set: yes + + +# 3 means enable for outgoing and incoming connections +# 2 means enable for incoming connections +# 1 means enable for outgoing connections +# 0 means disabled +# Linux 3.13 (2014-01-19) and newer +- name: enable server and client TCP_FASTOPEN + sysctl: + name: net.ipv4.tcp_fastopen + value: 3 + state: present + sysctl_set: yes + + +# These were taken from: +# https://wiki.mozilla.org/Security/Server_Side_TLS#Pre-defined_DHE_groups +- name: populate known-good dhparams + copy: + src: "{{ item }}" + dest: "/etc/ssl/{{ item }}" + loop: + - ffdhe2048.pem + - ffdhe3072.pem + - ffdhe4096.pem + + +- name: configure /etc/hostname + hostname: + name: "{{ inventory_hostname }}" + + #- name: Add IP address of all hosts to all hosts + # lineinfile: + # state: present + # dest: /etc/hosts + # regexp: '.*{{ item }}$' + # line: "{{ hostvars[item].ansible_default_ipv4.address }} {{item}}" + # when: hostvars[item].ansible_default_ipv4.address is defined + # with_items: "{{ groups['all'] }}" + + +- name: configure sshd to only listen on IPv4 + lineinfile: + dest: /etc/ssh/sshd_config + regexp: '^#?AddressFamily' + line: "AddressFamily inet" # no ipv6 + state: present + notify: reload sshd + + + # Capture example: + #- replace: + # path: /etc/hosts + # regexp: '(\s+)old\.host\.name(\s+.*)?$' + # replace: '\1new.host.name\2' + # backup: yes + + +- name: fix motd + replace: + path: /etc/default/motd-news + regexp: 'https://motd.ubuntu.com' + replace: 'https://matt.sh/motd' + notify: + - clear motd cache + + +# Verify against: +# systemctl list-timers +- name: disable more automated call home reporting + systemd: + name: "{{ item }}" + state: stopped + enabled: False + loop: + - apt-daily-upgrade.timer + - apt-daily.timer + - motd-news.timer + + +- name: remove ubuntu self-advertising + file: + path: "/etc/update-motd.d/{{ item }}" + state: absent + loop: + - 91-release-upgrade + - 80-livepatch + - 10-help-text + notify: + - clear motd cache + + +# Ubuntu's pam_motd.so shows you /etc/legal +# on login if you don't have ~/.cache/motd.legal-displayed +# There is no way to disable the creation of that file in ~/.cache on login, +# but we can wipe out the message for new users. +- name: remove login disclaimer + file: + path: /etc/legal + state: absent + + +- name: place net-listeners.py + copy: + src: net-listeners.py + dest: /usr/local/bin/ + owner: root + group: root + mode: 0755 + +- name: place scp/rsync-only ssh restriction capability + copy: + src: ssh-transfer-only.sh + dest: /usr/local/bin/ + owner: root + group: root + mode: 0755 + +# can't setsid 04755 scripts, so enable script with global passwordless sudo +- name: enable all user running of net-listeners.py + lineinfile: + path: /etc/sudoers.d/net-listeners + regexp: "listeners.py" + line: "ALL ALL = (root) NOPASSWD: /usr/local/bin/net-listeners.py" + create: yes + mode: 0440 + +- name: add uptime and uname to login motd + lineinfile: + dest: /etc/update-motd.d/00-header + line: "{{ item }}" + state: present + loop: + - printf "\n$(w -us)\n" + +- name: add listening watcher to global login config + lineinfile: + dest: /etc/bash.bashrc + line: "{{ item }}" + state: present + loop: + # Only show output when running a login, not when starting a sudo shell + - "[[ -z $SUDO_UID ]] && sudo /usr/local/bin/net-listeners.py" + +- name: ensure system grub template has serial access + lineinfile: + dest: /etc/default/grub + regexp: '^GRUB_CMDLINE_LINUX=' + line: 'GRUB_CMDLINE_LINUX="console=ttyS0 {{ grub.extras }}"' + state: present + notify: reload grub + + +# This is an ops opinion. For more advanced needs, modify here or just template +# the entire sshd_config directly. +- name: configure sshd to only listen on local IP + lineinfile: + dest: /etc/ssh/sshd_config + regexp: '^#?ListenAddress' + line: "ListenAddress {{ hostvars[inventory_hostname]['ansible_' + network.interface.private]['ipv4']['address'] }}" + state: present + notify: reload sshd + + +- name: install system tools + apt: + pkg: + # acl is required for ansible to "become_user" as someone non-root because + # of permissions on its temporary files. Ansible will setfacl on temp files + # so it doesn't have to 0666 everything just so a new user can modify things. + - acl + + # you aren't a linux server without sending nightly summary emails + - logwatch + + # apt helpers for repo installs not included by default for some reason + - software-properties-common + + # production CA bundles so we don't get unknown CA errors + - ca-certificates + + # Maintains high numbers in /proc/sys/kernel/random/entropy_avail + - rng-tools + + # should we use a more modern thing than collect? distributed osquery? + - collectd + + # make sure 'install_recommends: no' or this installs lots of other stuff + - vim-nox + + # rrdtool only installed so we can be lazy and generate graphs on-demand + # with: /usr/share/doc/collectd-core/examples/collectd2html.pl + # TODO: enable centralized reporting system + - rrdtool + + # netstat, mii-tool, etc + - net-tools + install_recommends: no + state: latest + +# use a modern ntp client+server. +# +# systemd actually has a built-in ntp client called 'systemd-timesyncd' +# You can view its status with: +# journalctl -u systemd-timesyncd +# timedatectl +# +# Installing chrony will disable systemd-timesyncd +# (represented in apt with "Replaces: time-daemon") +# but it doesn't _actually_ disable it according to timedatectl (bug?) +# so we also manually run 'timedatectl set-ntp false' just to confirm. +# A good writeup about systemd-timesyncd lives at: +# https://wiki.archlinux.org/index.php/systemd-timesyncd +# +# You can view your live chrony status with: +# chronyc tracking +# chronyc sources +# chronyc sourcestats +# +# ...and that's a lot more detail than the built-in garabage systemd-timesyncd +# client will tell you about how your system time is being managed. +# +# chrony is both an ntp client with a remote administration interface +# and an ntp server, but by default chrony does not enable remote admin +# or ntp serving without additional explicit configuration (chrony.conf). +# +# For more details about becoming an ntp server and remote time administartion, +# see sections 2.2 and 2.5 of: +# https://chrony.tuxfamily.org/faq.html#_how_do_i_make_an_ntp_server_from_an_ntp_client +- name: install ntp client + apt: + pkg: chrony + state: latest + notify: + - double disable systemd ntp client + +# If ansible facts aren't enough, we can get puppet and chef facts too: +#- name: install facter +# apt: +# pkg: facter +# state: latest +# +#- name: install ohai +# apt: +# pkg: facter +# state: latest + + +# cleanup +- name: cleanup packaging + apt: + autoclean: yes + autoremove: yes + +# If needed, build and provide: +# +# Build for nsjail: +# apt install protobu* bison flex pkg-config libprotobuf-dev diff --git a/ansible/roles/disableFirewall/files/modprobe.d/blacklist-iptables.conf b/ansible/roles/disableFirewall/files/modprobe.d/blacklist-iptables.conf new file mode 100644 index 0000000..4655374 --- /dev/null +++ b/ansible/roles/disableFirewall/files/modprobe.d/blacklist-iptables.conf @@ -0,0 +1,13 @@ +# Don't load iptables on startup (or ever)! + +# These look weird, but the 'blacklist' command still allows +# module insertion. +# +# This method defines a load-time alias so when you load the module, +# it runs a delegated command to load the module instead, but in +# the case of denying modules completely, just run nothing. +install ip6table_filter /bin/true +install iptable_filter /bin/true +install ip6_tables /bin/true +install ip_tables /bin/true +install x_tables /bin/true diff --git a/ansible/roles/disableFirewall/tasks/main.yml b/ansible/roles/disableFirewall/tasks/main.yml new file mode 100644 index 0000000..9454702 --- /dev/null +++ b/ansible/roles/disableFirewall/tasks/main.yml @@ -0,0 +1,22 @@ +--- +# Our mail systems only listen to SMTP(S) and IMAP(S) +# so we can disable all firewalls +# This stops ufw, then uninstalls ufw and iptables (and ip6tables) +- name: remove firewall + apt: + name: iptables + state: absent + register: firewallKaboom + +# removing iptables doesn't actually stop iptables processing, +# so let's force remove all packet processing from the kernel itself here +# TODO: this conditional could be better. would be nice if we had a fact +# of loaded kernel modules to query the presence/absence of +- name: unload firewall + command: modprobe -r ip6table_filter iptable_filter ip6_tables ip_tables x_tables + when: firewallKaboom.changed + +- name: disable iptables from reappearing in the future + copy: + src: modprobe.d/ + dest: /etc/modprobe.d/ diff --git a/ansible/roles/dovecot/files/dovecot/authdb.sqlite3.empty b/ansible/roles/dovecot/files/dovecot/authdb.sqlite3.empty new file mode 100644 index 0000000..f1e0330 Binary files /dev/null and b/ansible/roles/dovecot/files/dovecot/authdb.sqlite3.empty differ diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/10-acl.conf b/ansible/roles/dovecot/files/dovecot/conf.d/10-acl.conf new file mode 100644 index 0000000..f9fa335 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/10-acl.conf @@ -0,0 +1,15 @@ +mail_plugins = $mail_plugins acl + +protocol imap { + mail_plugins = $mail_plugins imap_acl +} + +plugin { + acl_defaults_from_inbox = yes +} + +# Should saving a mail to a nonexistent mailbox automatically create it? +lda_mailbox_autocreate = yes + +# Should automatically created mailboxes be also automatically subscribed? +lda_mailbox_autosubscribe = yes diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/10-auth.conf b/ansible/roles/dovecot/files/dovecot/conf.d/10-auth.conf new file mode 100644 index 0000000..4e23fa8 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/10-auth.conf @@ -0,0 +1,46 @@ +# cache all authentication results for one hour +#auth_cache_size = 10M +#auth_cache_ttl = 1 hour +#auth_cache_negative_ttl = 1 hour + +# Don't cache password details, otherwise password changes require a server HUP +# before the server will re-query the password data source. +auth_cache_size = 0 + +# only use plain username/password auth - OK since everything is over TLS +auth_mechanisms = plain + +# passdb specifies how users are authenticated - sql here, and +# our sql config specifies the sqlite filename with queries to use +passdb { + driver = sql + args = /etc/dovecot/dovecot-sql.conf.ext +} + +# userdb specifies the location of users' "home" directories - where their +# mail is stored. e.g. /var/mail/vhosts/exmaple.com/user +# %d = domain, %n = user +# We can't use "prefetch" because postfix can't read users from "prefetch" db, +# and we can't use 'static' because the doveadm tool needs to iterate users +# for purging zero refcount deleted mails, so we give a userdb of sql here +# and specify a "get all users" SQL query in the configuration file. +userdb { + driver = sql + args = /etc/dovecot/dovecot-sql.conf.ext +} + +# UNIX socket path to master authentication server to find users. +# This is used by imap (for shared users) and lda. +auth_socket_path = /var/run/dovecot/auth-userdb + +# Respect /etc/hosts.deny (populated by fail2ban) +# You can use /etc/hosts.allow to countermand fail2ban decisions. +login_access_sockets = tcpwrap + +service tcpwrap { + unix_listener login/tcpwrap { + group = $default_login_user + mode = 0600 + user = $default_login_user + } +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/10-mail.conf b/ansible/roles/dovecot/files/dovecot/conf.d/10-mail.conf new file mode 100644 index 0000000..6c03965 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/10-mail.conf @@ -0,0 +1,28 @@ +# default home directory location for all users +mail_home = /var/mail/vhosts/%d/%n + +# directory to store mail. The tilda makes it relative to the *dovecot* +# virtual home directory. +# +# I use mdbox - this is Dovecot's own high-performance mail store format. +# There are other slower, more "traditional" formats you can choose from. +# Read about them here: https://wiki2.dovecot.org/MailboxFormat +mail_location = mdbox:~/mdbox + +# nothing fancy - just a standard default namespace with '/' as the +# hierarchy separator +namespace inbox { + separator = / + inbox = yes +} + +# set this to the group that owns your vmail directory. +mail_privileged_group = vmail + +# these lines enable attachment deduplication. Attachments must be somewhat +# large (64k) to store them separately from the mail store. +mail_attachment_dir = /var/mail/attachments +mail_attachment_min_size = 64k + +# we'll uncomment this after we set up Solr in the following section: +# mail_plugins = $mail_plugins fts fts_solr diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/10-master.conf b/ansible/roles/dovecot/files/dovecot/conf.d/10-master.conf new file mode 100644 index 0000000..f99d0f4 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/10-master.conf @@ -0,0 +1,82 @@ +# to improve performance, disable fsync globally - we will enable it for +# some specific services later on +mail_fsync = never + +service imap-login { + # plain-text IMAP should only be accessible from localhost + inet_listener imap { + address = 127.0.0.1, ::1 + } + + # enable high-performance mode, described here: + # https://wiki.dovecot.org/LoginProcess + service_count = 0 + + # set to the number of CPU cores on your server + process_min_avail = 3 + vsz_limit = 1G +} + +# disable POP3 altogether +service pop3-login { + inet_listener pop3 { + port = 0 + } + + inet_listener pop3s { + port = 0 + } +} + +# enable semi-long-lived IMAP processes to improve performance +service imap { + service_count = 256 + # set to the number of CPU cores on your server + process_min_avail = 3 +} + +# expose an LMTP socket for postfix to deliver mail +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + group = postfix + mode = 0600 + user = postfix + } +} + +service auth { + # auth_socket_path points to this userdb socket by default. It's typically + # used by dovecot-lda, doveadm, possibly imap process, etc. Users that have + # full permissions to this socket are able to get a list of all usernames and + # get the results of everyone's userdb lookups. + # + # The default 0666 mode allows anyone to connect to the socket, but the + # userdb lookups will succeed only if the userdb returns an "uid" field that + # matches the caller process's UID. Also if caller's uid or gid matches the + # socket's uid or gid the lookup succeeds. Anything else causes a failure. + # + # To give the caller full permissions to lookup all users, set the mode to + # something else than 0666 and Dovecot lets the kernel enforce the + # permissions (e.g. 0777 allows everyone full permissions). + + # auth for postfix + unix_listener /var/spool/postfix/private/auth { + mode = 0666 + user = postfix + group = postfix + } + + # auth for doveadm tools + unix_listener auth-userdb { + mode = 0666 + user = vmail + group = vmail + } + + client_limit = 840 +} + +# no need to run this as root +service auth-worker { + user = vmail +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/15-lda.conf b/ansible/roles/dovecot/files/dovecot/conf.d/15-lda.conf new file mode 100644 index 0000000..32ca50d --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/15-lda.conf @@ -0,0 +1,7 @@ +# configuration for mail delivered by the `dovecot-lda` command. Shouldn't +# be needed since we are using LMTP, but kept for backwards compatibility. +protocol lda { + # use fsync for write-safety - this deals with delivering actual mail + mail_fsync = optimized + mail_plugins = $mail_plugins sieve +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/15-mailboxes.conf b/ansible/roles/dovecot/files/dovecot/conf.d/15-mailboxes.conf new file mode 100644 index 0000000..8674b0b --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/15-mailboxes.conf @@ -0,0 +1,24 @@ +# define any special IMAP folders here. You can force them to be created or +# created+subscribed automatically used the `auto` option. +namespace inbox { + mailbox Drafts { + auto = subscribe + special_use = \Drafts + } + mailbox Junk { + auto = create + special_use = \Junk + } + mailbox Trash { + auto = create + special_use = \Trash + } + mailbox Archive { + auto = subscribe + special_use = \Archive + } + mailbox Sent { + auto = subscribe + special_use = \Sent + } +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/20-imap.conf b/ansible/roles/dovecot/files/dovecot/conf.d/20-imap.conf new file mode 100644 index 0000000..7b32396 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/20-imap.conf @@ -0,0 +1,10 @@ +# Use a longer IDLE interval to reduce network chatter and save battery +# life. Max is 30 minutes. +imap_idle_notify_interval = 29 mins + +protocol imap { + # max IMAP connections per IP address + mail_max_userip_connections = 50 + # imap_sieve will be used for spam training by rspamd + mail_plugins = $mail_plugins imap_sieve +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/20-lmtp.conf b/ansible/roles/dovecot/files/dovecot/conf.d/20-lmtp.conf new file mode 100644 index 0000000..a51ee42 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/20-lmtp.conf @@ -0,0 +1,5 @@ +protocol lmtp { + # use fsync for write-safety - this deals with delivering actual mail + mail_fsync = optimized + mail_plugins = $mail_plugins sieve +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/90-imapsieve.conf b/ansible/roles/dovecot/files/dovecot/conf.d/90-imapsieve.conf new file mode 100644 index 0000000..26987b1 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/90-imapsieve.conf @@ -0,0 +1,18 @@ +plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + + # From elsewhere to Junk folder + imapsieve_mailbox1_name = Junk + imapsieve_mailbox1_causes = COPY + imapsieve_mailbox1_before = file:/etc/dovecot/sieve/report-spam.sieve + + # From Junk folder to elsewhere + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = Junk + imapsieve_mailbox2_causes = COPY + imapsieve_mailbox2_before = file:/etc/dovecot/sieve/report-ham.sieve + + sieve_pipe_bin_dir = /etc/dovecot/sieve + + sieve_global_extensions = +vnd.dovecot.pipe +} diff --git a/ansible/roles/dovecot/files/dovecot/conf.d/90-sieve.conf b/ansible/roles/dovecot/files/dovecot/conf.d/90-sieve.conf new file mode 100644 index 0000000..9a753bf --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/conf.d/90-sieve.conf @@ -0,0 +1,34 @@ +plugin { + # 'active' is a symlink to one sieve source script inside directory at 'file' + sieve = file:~/sieve;active=~/.dovecot.sieve + + # directory of global sieve scripts to run before and after processing ALL + # incoming mail + sieve_before = /etc/dovecot/sieve-before.d + sieve_after = /etc/dovecot/sieve-after.d + + # make sieve aware of user+tag@domain.tld aliases + recipient_delimiter = + + + + # no limits on script size or actions + sieve_quota_max_storage = 0 + sieve_max_script_size = 0 + sieve_max_actions = 0 + + sieve_extensions = +spamtest +spamtestplus + + sieve_spamtest_status_header = X-Spam-Score + sieve_spamtest_status_type = strlen + + # X-Spamd-Bar: +++++++++ + sieve_spamtest_max_value = 9 + + + # X-Spamd-Result: default: False [9.19 / 15.00]; + # (regex not fixed to capture the above) + #sieve_spamtest_status_type = score + #sieve_spamtest_status_header = \ + # X-Spamd-Result: [[:alnum:]]+, score=(-?[[:digit:]]+\.[[:digit:]]) + #sieve_spamtest_max_value = 5.0 +} diff --git a/ansible/roles/dovecot/files/dovecot/dovecot-sql.conf.ext b/ansible/roles/dovecot/files/dovecot/dovecot-sql.conf.ext new file mode 100644 index 0000000..f248243 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/dovecot-sql.conf.ext @@ -0,0 +1,150 @@ +# This file is commonly accessed via passdb {} or userdb {} section in +# conf.d/auth-sql.conf.ext + +# This file is opened as root, so it should be owned by root and mode 0600. +# +# http://wiki2.dovecot.org/AuthDatabase/SQL +# +# For the sql passdb module, you'll need a database with a table that +# contains fields for at least the username and password. If you want to +# use the user@domain syntax, you might want to have a separate domain +# field as well. +# +# If your users all have the same uig/gid, and have predictable home +# directories, you can use the static userdb module to generate the home +# dir based on the username and domain. In this case, you won't need fields +# for home, uid, or gid in the database. +# +# If you prefer to use the sql userdb module, you'll want to add fields +# for home, uid, and gid. Here is an example table: +# +# CREATE TABLE users ( +# username VARCHAR(128) NOT NULL, +# domain VARCHAR(128) NOT NULL, +# password VARCHAR(64) NOT NULL, +# home VARCHAR(255) NOT NULL, +# uid INTEGER NOT NULL, +# gid INTEGER NOT NULL, +# active CHAR(1) DEFAULT 'Y' NOT NULL +# ); + +# Database driver: mysql, pgsql, sqlite +driver = sqlite + +# Database connection string. This is driver-specific setting. +# +# HA / round-robin load-balancing is supported by giving multiple host +# settings, like: host=sql1.host.org host=sql2.host.org +# +# pgsql: +# For available options, see the PostgreSQL documention for the +# PQconnectdb function of libpq. +# Use maxconns=n (default 5) to change how many connections Dovecot can +# create to pgsql. +# +# mysql: +# Basic options emulate PostgreSQL option names: +# host, port, user, password, dbname +# +# But also adds some new settings: +# client_flags - See MySQL manual +# connect_timeout - Connect timeout in seconds (default: 5) +# read_timeout - Read timeout in seconds (default: 30) +# write_timeout - Write timeout in seconds (default: 30) +# ssl_ca, ssl_ca_path - Set either one or both to enable SSL +# ssl_cert, ssl_key - For sending client-side certificates to server +# ssl_cipher - Set minimum allowed cipher security (default: HIGH) +# ssl_verify_server_cert - Verify that the name in the server SSL certificate +# matches the host (default: no) +# option_file - Read options from the given file instead of +# the default my.cnf location +# option_group - Read options from the given group (default: client) +# +# You can connect to UNIX sockets by using host: host=/var/run/mysql.sock +# Note that currently you can't use spaces in parameters. +# +# sqlite: +# The path to the database file. +# +# Examples: +# connect = host=192.168.1.1 dbname=users +# connect = host=sql.example.com dbname=virtual user=virtual password=blarg +# connect = /etc/dovecot/authdb.sqlite +# +connect = /etc/dovecot/authdb.sqlite + +# Default password scheme. +# +# List of supported schemes is in +# http://wiki2.dovecot.org/Authentication/PasswordSchemes +# +#default_pass_scheme = SHA512-CRYPT + +# passdb query to retrieve the password. It can return fields: +# password - The user's password. This field must be returned. +# user - user@domain from the database. Needed with case-insensitive lookups. +# username and domain - An alternative way to represent the "user" field. +# +# The "user" field is often necessary with case-insensitive lookups to avoid +# e.g. "name" and "nAme" logins creating two different mail directories. If +# your user and domain names are in separate fields, you can return "username" +# and "domain" fields instead of "user". +# +# The query can also return other fields which have a special meaning, see +# http://wiki2.dovecot.org/PasswordDatabase/ExtraFields +# +# Commonly used available substitutions (see http://wiki2.dovecot.org/Variables +# for full list): +# %u = entire user@domain +# %n = user part of user@domain +# %d = domain part of user@domain +# +# Note that these can be used only as input to SQL query. If the query outputs +# any of these substitutions, they're not touched. Otherwise it would be +# difficult to have eg. usernames containing '%' characters. +# +# Example: +# password_query = SELECT userid AS user, pw AS password \ +# FROM users WHERE userid = '%u' AND active = 'Y' +# +password_query = \ + SELECT '%u' AS username, domain, password \ + FROM users WHERE userid = '%n' AND domain = '%d' + +# You can update (or modify this a bit to insert) user passwords in a shell with: +# sqlite3 authdb.sqlite "update users set password='$(doveadm pw -s SHA512-CRYPT -r 1856250)' where userid='USERNAME' and domain = 'DOMAIN';" + + +# userdb query to retrieve the user information. It can return fields: +# uid - System UID (overrides mail_uid setting) +# gid - System GID (overrides mail_gid setting) +# home - Home directory +# mail - Mail location (overrides mail_location setting) +# +# None of these are strictly required. If you use a single UID and GID, and +# home or mail directory fits to a template string, you could use userdb static +# instead. For a list of all fields that can be returned, see +# http://wiki2.dovecot.org/UserDatabase/ExtraFields +# +# Examples: +# user_query = SELECT home, uid, gid FROM users WHERE userid = '%u' +# user_query = SELECT dir AS home, user AS uid, group AS gid FROM users where userid = '%u' +# user_query = SELECT home, 501 AS uid, 501 AS gid FROM users WHERE userid = '%u' +# +user_query = \ + SELECT "/var/mail/vhosts/" || '%d' || '/' || '%n' AS home, 145 as uid, 145 as gid + +# If you wish to avoid two SQL lookups (passdb + userdb), you can use +# userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll +# also have to return userdb fields in password_query prefixed with "userdb_" +# string. For example: +password_query = \ + SELECT '%u' AS user, password, \ + "/var/mail/vhosts/" || '%d' || '/' || '%n' AS userdb_home, 145 AS userdb_uid, 145 AS userdb_gid \ + FROM users WHERE userid = '%n' AND domain = '%d' + +# Query to get a list of all usernames. +# This iteration is used for things like globally purging zero refcount emails +# for all users, but to get all users, we have to iterate the user storage, +# hence this iterator query is required. +iterate_query = SELECT userid AS user FROM users diff --git a/ansible/roles/dovecot/files/dovecot/dovecot.conf b/ansible/roles/dovecot/files/dovecot/dovecot.conf new file mode 100644 index 0000000..4304fa6 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/dovecot.conf @@ -0,0 +1,19 @@ +# IMAP for remote access, LMTP for local delivery +protocols = imap lmtp + +# set these to the uid of your `vmail` user +first_valid_uid = 145 +last_valid_uid = 145 + +#mail_debug = yes +##auth_verbose = yes +##auth_debug = yes +##auth_debug_passwords = yes +##auth_verbose_passwords = yes + + +mail_uid = vmail +mail_gid = vmail + +!include conf.d/*.conf +!include_try local.conf diff --git a/ansible/roles/dovecot/files/dovecot/sieve-before.d/10-rspamd.sieve b/ansible/roles/dovecot/files/dovecot/sieve-before.d/10-rspamd.sieve new file mode 100644 index 0000000..7931a71 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/sieve-before.d/10-rspamd.sieve @@ -0,0 +1,5 @@ +require ["fileinto"]; + +if header :is "X-Spam" "Yes" { + fileinto "Junk"; +} diff --git a/ansible/roles/dovecot/files/dovecot/sieve/report-ham.sieve b/ansible/roles/dovecot/files/dovecot/sieve/report-ham.sieve new file mode 100644 index 0000000..2ad40aa --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/sieve/report-ham.sieve @@ -0,0 +1,23 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +# This line is important because when we delete from Junk/Spam, +# messages get moved to Trash, which tirggers the "message moved out of +# spam" script (this script) which—usually!—trains the originally classified +# Spam as not-spam. +# BUT, this is just a delete! If we train our spam as not-spam on delete, that +# defeats our goals. +# In short, this always gets run on a message being moved out of Spam, but if +# the target mailbox is Trash, just don't run the trainer this time. +if string "${mailbox}" "Trash" { + stop; +} + +if environment :matches "imap.email" "*" { + set "email" "${1}"; +} + +pipe :copy "train-ham.sh" [ "${email}" ]; diff --git a/ansible/roles/dovecot/files/dovecot/sieve/report-spam.sieve b/ansible/roles/dovecot/files/dovecot/sieve/report-spam.sieve new file mode 100644 index 0000000..4ed95d7 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/sieve/report-spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.email" "*" { + set "email" "${1}"; +} + +pipe :copy "train-spam.sh" [ "${email}" ]; diff --git a/ansible/roles/dovecot/files/dovecot/sieve/train-ham.sh b/ansible/roles/dovecot/files/dovecot/sieve/train-ham.sh new file mode 100755 index 0000000..b0f30fd --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/sieve/train-ham.sh @@ -0,0 +1 @@ +exec /usr/bin/rspamc --connect localhost:11334 learn_ham diff --git a/ansible/roles/dovecot/files/dovecot/sieve/train-spam.sh b/ansible/roles/dovecot/files/dovecot/sieve/train-spam.sh new file mode 100755 index 0000000..bf1b920 --- /dev/null +++ b/ansible/roles/dovecot/files/dovecot/sieve/train-spam.sh @@ -0,0 +1 @@ +exec /usr/bin/rspamc --connect localhost:11334 learn_spam diff --git a/ansible/roles/dovecot/handlers/main.yml b/ansible/roles/dovecot/handlers/main.yml new file mode 100644 index 0000000..dda7930 --- /dev/null +++ b/ansible/roles/dovecot/handlers/main.yml @@ -0,0 +1,31 @@ +--- +- name: restart dovecot + service: + name: dovecot + state: restarted + +- name: reload dovecot + service: + name: dovecot + state: reloaded + +# We intentionally don't have a "creates:" guard on the resieve handlers +# because if they get called we need to re-run them on any changes of +# the underlying script itself regardless whether the result .svbin +# already exists or not. +- name: resieve spam + command: sievec report-spam.sieve + args: + chdir: /etc/dovecot/sieve + +- name: resieve ham + command: sievec report-ham.sieve + args: + chdir: /etc/dovecot/sieve + +- name: resieve spam mover + command: sievec 10-rspamd.sieve + args: + chdir: /etc/dovecot/sieve-before.d + creates: 10-rspamd.svbin + diff --git a/ansible/roles/dovecot/tasks/main.yml b/ansible/roles/dovecot/tasks/main.yml new file mode 100644 index 0000000..6e097c1 --- /dev/null +++ b/ansible/roles/dovecot/tasks/main.yml @@ -0,0 +1,110 @@ +--- +# dovecot install and configuration +- name: install dovecot + apt: + state: latest + pkg: + - dovecot-imapd + - dovecot-lmtpd + - dovecot-sieve + - dovecot-sqlite + +# Convert existing maildir to mdbox (local on-fs dirs) with: +# dsync -o mail_location=mdbox:herebox mirror maildir:Maildir +# Or, you can pull from a remote site: +# Below, -R means REVERSE backup so PULL messages FROM vorash INTO mdbox, +# otherwise, it's a PUSH backup and mdbox PUSHES to vorash which isn't what we want +# doveadm -o mail_location=mdbox:herebox backup -R ssh -J matt@novus.webdev.bz matt@vorash doveadm dsync-server +- name: create mail spool dirs + file: + path: /var/mail/local + owner: root + group: mail + mode: 0775 + state: directory + +- name: create dovecot virtual mailbox group + group: + name: vmail + gid: 145 + state: present + +- name: create dovecot virtual mailbox and virtual authentication account + user: + name: vmail + uid: 145 + group: vmail + shell: /sbin/nologin + create_home: yes + home: /var/mail/vhosts + state: present + +- name: give dovecot user permission to read private keys + user: + name: dovecot + groups: ssl-cert + append: yes + +# Create new passwords with: +# time doveadm pw -s SHA512-CRYPT -r 1856250 +- name: copy dovecot configs and userdb + copy: + src: dovecot/ + dest: /etc/dovecot/ + mode: preserve + notify: + - resieve spam + - resieve ham + - resieve spam mover + - restart dovecot + +# This permission is important because dovecot has multiple users: +# - dovecot +# - dovenull +# - vmail +# but login processes are run by the 'vmail' user, so 'vmail' must have read +# access to the DB +- name: fix user permissions on authdb + file: + path: /etc/dovecot/authdb.sqlite + owner: vmail + group: vmail + mode: 0600 + +- name: instantiate dovecot SSL template with host vars + template: + src: dovecot/conf.d/10-ssl.conf.j2 + dest: /etc/dovecot/conf.d/10-ssl.conf + notify: + - restart dovecot # NB this could be a reload instead + +# Dovecot mdbox format requires a purge to remove storage space +# allocated to messages that have been fully deleted by users. +# (it's an append-only refcounting system, so when a refcount becomes +# zero on final delete, it needs some cleanup to rewrite the old +# pack files without the deleted emails present anymore.) +- cron: + name: setup cron so dovecot can GC mailboxes + minute: 0 + hour: 3 + user: vmail + job: "doveadm purge -A" + cron_file: dovecot_maint_purge + + +# verify everything is running +- name: verify services are running in dependency order + service: + name: "{{ item }}" + enabled: yes + state: started + loop: + - dovecot + +- name: reload if certs newish + include_role: + name: certreload + vars: + certreload: + notifiers: + - reload dovecot diff --git a/ansible/roles/dovecot/templates/dovecot/conf.d/10-ssl.conf.j2 b/ansible/roles/dovecot/templates/dovecot/conf.d/10-ssl.conf.j2 new file mode 100644 index 0000000..c1654cd --- /dev/null +++ b/ansible/roles/dovecot/templates/dovecot/conf.d/10-ssl.conf.j2 @@ -0,0 +1,22 @@ +# require SSL for all non-localhost connections +ssl = required + +# Config detials at https://wiki.dovecot.org/SSL/DovecotConfiguration +ssl_cert = to= proto=ESMTP helo= +failregex = milter-reject: END-OF-MESSAGE from [a-z0-9.-]+\[\]: 5.7.1 Spam message rejected +ignoreregex = diff --git a/ansible/roles/fail2ban/files/fail2ban/jail.local b/ansible/roles/fail2ban/files/fail2ban/jail.local new file mode 100644 index 0000000..5b77b5b --- /dev/null +++ b/ansible/roles/fail2ban/files/fail2ban/jail.local @@ -0,0 +1,59 @@ +[DEFAULT] +# For 'banaction' you can use any action defined in /etc/fail2ban/action.d/ +# including things like iptables, iptables-ipset, nftables-*, ... +banaction = hostsdeny +banaction_allports = hostsdeny + +# Blocking decision making is fully logged in /var/log/fail2ban.log +# Current blocking can be viewed with: +# fail2ban-client status +# fail2ban-client status [service] + +# You can unban IPs with +# fail2ban-client unban ... +# +# Or unban just for one service/jail +# fail2ban-client set unban + +# Go away for a long time +bantime = 34d + + +# DEBUGGING +# You can debug fail2ban behavior by running it in the foreground with +# client debug and server debug logging: +# fail2ban-client -vvvvvvvvvv --loglevel DEBUG -f -x start +# In another terminal: +# tail -F /var/log/fail2ban.log +# +# It helps to delete the persistent save db before fail2ban is started +# in debug mode too: +# rm /var/lib/fail2ban/fail2ban.sqlite3 + + +# And we're always watching +# If you're testing/debugging your auth and failing your own logins +# either by mistake or intentionally, you'll want to either decrease +# the findtime, decrease the bantime, increase the maxretry time, +# or just disable fail2ban for [findtime] after your testing. +findtime = 6h + +# Quick and done +maxretry = 5 + +[sshd] +# Disable sshd since we don't have public ssh access to these servers +enabled = false + +[postfix] +enabled = true +mode = aggressive +findtime = 7d # watch out for bad long-term trickle tricksters + +[postfix-rspamd] +enabled = true +findtime = 7d +maxretry = 3 + +[dovecot] +enabled = true diff --git a/ansible/roles/fail2ban/handlers/main.yml b/ansible/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..d83f78d --- /dev/null +++ b/ansible/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart fail2ban + service: + name: fail2ban + state: restarted diff --git a/ansible/roles/fail2ban/tasks/main.yml b/ansible/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..6d8e237 --- /dev/null +++ b/ansible/roles/fail2ban/tasks/main.yml @@ -0,0 +1,28 @@ +--- +# dovecot is configured to respect the fail2ban deny decisions +# A failed login is recorded as: +# dovecot[]: imap-login: access(tcpwrap): Client refused (rip=) +- name: install fail2ban + apt: + name: fail2ban + state: latest + install_recommends: false + +- name: copy fail2ban config + copy: + src: fail2ban/ + dest: /etc/fail2ban/ + mode: preserve + notify: + - restart fail2ban + + +# verify everything is running +- name: verify services are running in dependency order + service: + name: "{{ item }}" + enabled: yes + state: started + loop: + - fail2ban + diff --git a/ansible/roles/gpg/tasks/main.yml b/ansible/roles/gpg/tasks/main.yml new file mode 100644 index 0000000..6bb9299 --- /dev/null +++ b/ansible/roles/gpg/tasks/main.yml @@ -0,0 +1,6 @@ +--- +# gpg is required to verify keys of external apt repos +- name: install gpg + apt: + pkg: gpg + state: latest diff --git a/ansible/roles/network/tasks/main.yml b/ansible/roles/network/tasks/main.yml new file mode 100644 index 0000000..5aaaeff --- /dev/null +++ b/ansible/roles/network/tasks/main.yml @@ -0,0 +1,40 @@ +--- +#- name: plop netplan +# copy: +# src: "{{ inventory_hostname }}.yaml" +# dest: "/etc/netplan/20-customNetworking.yaml" +# register: netplanCopy +# +#- name: generate netplan +# command: netplan generate +# register: netplanGenerated +# when: netplanCopy.changed +# +#- name: apply netplan +# command: netplan apply +# when: netplanGenerated.changed + +- name: configure public network link through systemd directly + template: + src: network/custom.link + dest: "/etc/systemd/network/custom-{{ network.ethernets.interface }}.link" + register: networkUpdated + +- name: configure public network address through systemd directly + template: + src: network/custom.network + dest: "/etc/systemd/network/custom-{{ network.ethernets.interface }}.network" + register: networkUpdated + +- name: reload network since configuration changed + command: systemctl restart systemd-networkd + when: networkUpdated.changed + +- name: re-query ansible facts since system changed + setup: + gather_subset: + - "!all" + - "!min" + - network + when: networkUpdated.changed + diff --git a/ansible/roles/network/templates/network/custom.link b/ansible/roles/network/templates/network/custom.link new file mode 100644 index 0000000..e3bc5a8 --- /dev/null +++ b/ansible/roles/network/templates/network/custom.link @@ -0,0 +1,6 @@ +[Match] +OriginalName={{ network.ethernets.interface }} + +[Link] +WakeOnLan=off +MACAddress={{ network.ethernets.macaddress }} diff --git a/ansible/roles/network/templates/network/custom.network b/ansible/roles/network/templates/network/custom.network new file mode 100644 index 0000000..5a8b74e --- /dev/null +++ b/ansible/roles/network/templates/network/custom.network @@ -0,0 +1,38 @@ +[Match] +Name={{ network.ethernets.interface }} + +#{% for net in network.ethernets.networks %} +#[Network] +#Address={{ net.subnet }} +#{% if net.gateway is defined %} +#Gateway={{ net.gateway }} +#{% endif %} +# +#{% endfor %} + + +{% for addr in network.ethernets.addresses %} +[Address] +Address={{ addr }} + +{% endfor %} + +{% for net in network.ethernets.networks %} +[Network] +Address={{ net.subnet }} +{% if net.gateway is defined %} +Gateway={{ net.gateway }} +{% endif %} + +{% endfor %} + + +{% for route in network.ethernets.routes %} +[Route] +Destination={{ route.to }} +Gateway={{ route.via }} +{% if route['on-link'] %} +GatewayOnlink=true +{% endif %} + +{% endfor %} diff --git a/ansible/roles/nginx/defaults/main.yml b/ansible/roles/nginx/defaults/main.yml new file mode 100644 index 0000000..34ac696 --- /dev/null +++ b/ansible/roles/nginx/defaults/main.yml @@ -0,0 +1,6 @@ +--- +nginx: + # Don't disable anything by default, but provide it here + # so you don't need to include empty 'disabled' in your own + # host configs. + disabled: [] diff --git a/ansible/roles/nginx/files/conf.d/http.custom.conf b/ansible/roles/nginx/files/conf.d/http.custom.conf new file mode 100644 index 0000000..5f70166 --- /dev/null +++ b/ansible/roles/nginx/files/conf.d/http.custom.conf @@ -0,0 +1,84 @@ + +## Proxy options +proxy_buffering on; +# proxy_cache_min_uses 3; +proxy_cache_path /var/nginx/proxy-cache/ levels=1:2 keys_zone=cache:10m inactive=10m max_size=1000M; +proxy_cache_valid any 10m; +proxy_ignore_client_abort off; +proxy_intercept_errors on; +proxy_next_upstream error timeout invalid_header; +proxy_redirect off; +proxy_set_header Host $host; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_connect_timeout 60; +proxy_send_timeout 60; +proxy_read_timeout 60; + +# We used to use this header when we ran dual http/https stacks to verify +# user login pages were being only requested over https, but now we forward +# every site to https, so we can assume our schemes are aligned to our interests +# (as long as all our backend code stopped checking for X-Forwarded-Proto too). +#proxy_set_header X-Forwarded-Proto $scheme; + +## Size Limits +# May need to override these (server or location blocks) if doing large uploads. +# Setting to zero disables any size checking. +client_body_buffer_size 16k; +client_max_body_size 15m; + +# If clients send headers larger than 1k, +# they get upgraded to large_client_header_buffers. +client_header_buffer_size 1k; +large_client_header_buffers 32 64k; + +## Timeouts +client_body_timeout 5s; +client_header_timeout 5s; +keepalive_timeout 5s 5s; +#keepalive_timeout 0; +send_timeout 5s; + +## General Options +ignore_invalid_headers on; +recursive_error_pages on; +#sendfile on; # enabled by top level config +server_name_in_redirect off; +server_tokens off; + +# For per-client rate limiting, see config options at: +# https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html + +## Compression +#gzip on; # enabled by top level config +gzip_static on; +gzip_buffers 16 32k; +gzip_comp_level 6; +gzip_http_version 1.0; +gzip_min_length 500; +gzip_types text/plain application/x-javascript text/xml text/css image/x-icon application/xml application/xml+rss text/javascript application/javascript application/json image/svg+xml font/truetype font/opentype application/vnd.ms-fontobject; +gzip_vary on; +gzip_proxied any; # required for cloudfront to receive a gzip'd response + +## Filesystem Operation Cache (caches fds, sizes, times, errors, etc) +open_file_cache max=6000 inactive=5m; +open_file_cache_valid 2m; +open_file_cache_min_uses 1; +open_file_cache_errors on; + +# For reading a response from disk +output_buffers 32 32k; + +## Optimize Large File Transfers (can be overriden in hosts and locations) +aio threads; # use default thread pool, create thread pools: threads=NAME; +aio_write on; # use threaded writes for temporary files and proxied data + +# For files larger than 8 MB, use O_DIRECT instead of sendfile() +directio 8m; +directio_alignment 512; # if using XFS, set as 4096 + +## Access Log Caches +open_log_file_cache max=64 inactive=20s min_uses=1 valid=1m; + +log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; diff --git a/ansible/roles/nginx/files/tls/ssl_ciphers_intermediate b/ansible/roles/nginx/files/tls/ssl_ciphers_intermediate new file mode 100644 index 0000000..bc79954 --- /dev/null +++ b/ansible/roles/nginx/files/tls/ssl_ciphers_intermediate @@ -0,0 +1,10 @@ +# From https://mozilla.github.io/server-side-tls/ssl-config-generator/ +# as of 2018-07-12 + +# No TLSv1.3 support yet! + +ssl_protocols TLSv1 TLSv1.1 TLSv1.2; +ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; + +# Diffie-Hellman parameter for DHE ciphersuites, recommended 2048 bits +ssl_dhparam /etc/ssl/ffdhe2048.pem; diff --git a/ansible/roles/nginx/files/tls/ssl_ciphers_modern b/ansible/roles/nginx/files/tls/ssl_ciphers_modern new file mode 100644 index 0000000..ab93ffc --- /dev/null +++ b/ansible/roles/nginx/files/tls/ssl_ciphers_modern @@ -0,0 +1,7 @@ +# From https://mozilla.github.io/server-side-tls/ssl-config-generator/ +# as of 2018-07-12 + +# No TLSv1.3 support yet! + +ssl_protocols TLSv1.2; +ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256'; diff --git a/ansible/roles/nginx/files/tls/ssl_ciphers_tls13 b/ansible/roles/nginx/files/tls/ssl_ciphers_tls13 new file mode 100644 index 0000000..db04c36 --- /dev/null +++ b/ansible/roles/nginx/files/tls/ssl_ciphers_tls13 @@ -0,0 +1,7 @@ +# From https://github.com/cloudflare/sslconfig/blob/796bc5ac7224f1e540394d792323ccafa86aaeea/conf + +# nginx >= 1.11.0 (2016-05-24) created the 'ssl_ecdh_curve' parameter + +ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; +ssl_ecdh_curve X25519:P-256:P-384:P-224:P-521; +ssl_ciphers '[ECDHE-ECDSA-AES128-GCM-SHA256|ECDHE-ECDSA-CHACHA20-POLY1305|ECDHE-RSA-AES128-GCM-SHA256|ECDHE-RSA-CHACHA20-POLY1305]:ECDHE+AES128:RSA+AES128:ECDHE+AES256:RSA+AES256:ECDHE+3DES:RSA+3DES'; diff --git a/ansible/roles/nginx/files/tls/ssl_params b/ansible/roles/nginx/files/tls/ssl_params new file mode 100644 index 0000000..37798fc --- /dev/null +++ b/ansible/roles/nginx/files/tls/ssl_params @@ -0,0 +1,55 @@ +# Test OCSP with: +# openssl s_client -connect $site:443 -tls1 -tlsextdebug -status +# +# also test with: +# openssl s_client -connect $site:443 -CAfile /etc/ssl/certs/ca-certificates.crt -showcerts -status -tlsextdebug -cipher RSA = 1.11.0 (2016-05-24) allows loading redundant certs and keys so you + # can serve modern EC clients and less modern RSA clients at the same time. + ssl_certificate /etc/ssl/{{ item.domain }}-cert-combined.prime256v1.pem; + ssl_certificate_key /etc/ssl/private/{{ item.domain }}-key.prime256v1.pem; + + root /srv/web/{{ item.domain }}; + +{% if nginx.google is defined %} + location /{{ nginx.google.siteKey }}.html { + root {{ nginx.google.siteKeyServeDir }}}; + } +{% endif %} + +{% if item.customConfig is defined %} +{{ item.customConfig }} +{% endif %} + +{% for location in item.uri %} + location {{ location.path }} { +{% if location.appServer is defined %} + proxy_pass {{ location.appServer }}/$request_uri; + proxy_set_header Host $host; +{% else %} + root /srv/web/{{ item.domain }}; +{% endif %} + } +{% endfor %} +} + +server { + listen {{ item.domain }} fastopen=4096 reuseport; + server_name www.{{ item.domain }} {{ item.domain }}; + + access_log /var/log/nginx/{{ item.domain }}.access.log main buffer=32k; + error_log /var/log/nginx/{{ item.domain }}.error.log error; + + location /.well-known/acme-challenge/ { + alias /srv/web/challenges/; + try_files $uri =404; + } + + location / { + return 301 https://{{ item.domain }}$request_uri; + } +} diff --git a/ansible/roles/pip3/tasks/main.yml b/ansible/roles/pip3/tasks/main.yml new file mode 100644 index 0000000..26de65b --- /dev/null +++ b/ansible/roles/pip3/tasks/main.yml @@ -0,0 +1,6 @@ +--- +# pip3 because borgmatic is distributed through pip3, not apt/dpkg +- name: install pip3 + apt: + pkg: python3-pip + state: latest diff --git a/ansible/roles/postfix/files/postfix/master.cf b/ansible/roles/postfix/files/postfix/master.cf new file mode 100644 index 0000000..d428c18 --- /dev/null +++ b/ansible/roles/postfix/files/postfix/master.cf @@ -0,0 +1,113 @@ +# +# Postfix master process configuration file. For details on the format +# of the file, see the master(5) manual page (command: "man 5 master" or +# on-line: http://www.postfix.org/master.5.html). +# +# Do not forget to execute "postfix reload" after editing this file. +# +# ========================================================================== +# service type private unpriv chroot wakeup maxproc command + args +# (yes) (yes) (no) (never) (100) +# ========================================================================== +smtp inet n - y - - smtpd + -o smtpd_sasl_auth_enable=no +# for verbose connection debugging, append -v to the above args +#smtp inet n - y - 1 postscreen +#smtpd pass - - y - - smtpd +#dnsblog unix - - y - 0 dnsblog +#tlsproxy unix - - y - 0 tlsproxy +submission inet n - n - - smtpd + -o smtpd_tls_security_level=encrypt + -o tls_preempt_cipherlist=yes +#submission inet n - y - - smtpd +# -o syslog_name=postfix/submission +# -o smtpd_tls_security_level=encrypt +# -o smtpd_sasl_auth_enable=yes +# -o smtpd_tls_auth_only=yes +# -o smtpd_reject_unlisted_recipient=no +# -o smtpd_client_restrictions=$mua_client_restrictions +# -o smtpd_helo_restrictions=$mua_helo_restrictions +# -o smtpd_sender_restrictions=$mua_sender_restrictions +# -o smtpd_recipient_restrictions= +# -o smtpd_relay_restrictions=permit_sasl_authenticated,reject +# -o milter_macro_daemon_name=ORIGINATING +#smtps inet n - y - - smtpd +# -o syslog_name=postfix/smtps +# -o smtpd_tls_wrappermode=yes +# -o smtpd_sasl_auth_enable=yes +# -o smtpd_reject_unlisted_recipient=no +# -o smtpd_client_restrictions=$mua_client_restrictions +# -o smtpd_helo_restrictions=$mua_helo_restrictions +# -o smtpd_sender_restrictions=$mua_sender_restrictions +# -o smtpd_recipient_restrictions= +# -o smtpd_relay_restrictions=permit_sasl_authenticated,reject +# -o milter_macro_daemon_name=ORIGINATING +#628 inet n - y - - qmqpd +pickup unix n - y 60 1 pickup +cleanup unix n - y - 0 cleanup +qmgr unix n - n 300 1 qmgr +#qmgr unix n - n 300 1 oqmgr +tlsmgr unix - - y 1000? 1 tlsmgr +rewrite unix - - y - - trivial-rewrite +bounce unix - - y - 0 bounce +defer unix - - y - 0 bounce +trace unix - - y - 0 bounce +verify unix - - y - 1 verify +flush unix n - y 1000? 0 flush +proxymap unix - - n - - proxymap +proxywrite unix - - n - 1 proxymap +smtp unix - - y - - smtp +relay unix - - y - - smtp + -o syslog_name=postfix/$service_name +# -o smtp_helo_timeout=5 -o smtp_connect_timeout=5 +showq unix n - y - - showq +error unix - - y - - error +retry unix - - y - - error +discard unix - - y - - discard +local unix - n n - - local +virtual unix - n n - - virtual +lmtp unix - - y - - lmtp +anvil unix - - y - 1 anvil +scache unix - - y - 1 scache +# +# ==================================================================== +# Interfaces to non-Postfix software. Be sure to examine the manual +# pages of the non-Postfix software to find out what options it wants. +# +# Many of the following services use the Postfix pipe(8) delivery +# agent. See the pipe(8) man page for information about ${recipient} +# and other message envelope options. +# ==================================================================== +# +# maildrop. See the Postfix MAILDROP_README file for details. +# Also specify in main.cf: maildrop_destination_recipient_limit=1 +# +maildrop unix - n n - - pipe + flags=DRhu user=vmail argv=/usr/bin/maildrop -d ${recipient} +# +# ==================================================================== +# +# Recent Cyrus versions can use the existing "lmtp" master.cf entry. +# +# Specify in cyrus.conf: +# lmtp cmd="lmtpd -a" listen="localhost:lmtp" proto=tcp4 +# +# Specify in main.cf one or more of the following: +# mailbox_transport = lmtp:inet:localhost +# virtual_transport = lmtp:inet:localhost +# +# ==================================================================== +# +# Cyrus 2.1.5 (Amos Gouaux) +# Also specify in main.cf: cyrus_destination_recipient_limit=1 +# +#cyrus unix - n n - - pipe +# user=cyrus argv=/cyrus/bin/deliver -e -r ${sender} -m ${extension} ${user} +# +# ==================================================================== +# Old example of delivery via Cyrus. +# +#old-cyrus unix - n n - - pipe +# flags=R user=cyrus argv=/cyrus/bin/deliver -e -m ${extension} ${user} +# +# ==================================================================== diff --git a/ansible/roles/postfix/files/postfix/remap.sh b/ansible/roles/postfix/files/postfix/remap.sh new file mode 100755 index 0000000..86e8d73 --- /dev/null +++ b/ansible/roles/postfix/files/postfix/remap.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +here=$(dirname $0) + +MAPS="virtual" + +for map in $MAPS; do + postmap $here/$map +done diff --git a/ansible/roles/postfix/handlers/main.yml b/ansible/roles/postfix/handlers/main.yml new file mode 100644 index 0000000..aa41be4 --- /dev/null +++ b/ansible/roles/postfix/handlers/main.yml @@ -0,0 +1,13 @@ +--- +- name: restart postfix + service: + name: postfix + state: restarted + +- name: reload postfix + service: + name: postfix + state: reloaded + +- name: rehash postfix aliases + command: /etc/postfix/remap.sh diff --git a/ansible/roles/postfix/tasks/main.yml b/ansible/roles/postfix/tasks/main.yml new file mode 100644 index 0000000..57b0601 --- /dev/null +++ b/ansible/roles/postfix/tasks/main.yml @@ -0,0 +1,50 @@ +--- +# postfix install and configuration +# note: this postfix config requires open ports: 25 and 587 +- name: install postfix + apt: + state: latest + pkg: + - postfix + - postfix-doc + - postfix-pcre + +- name: give postfix user permission to read private keys + user: + name: postfix + groups: ssl-cert + append: yes + +- name: copy postfix config + copy: + src: postfix/ + dest: /etc/postfix/ + mode: preserve + notify: + - restart postfix # NB this could be a reload instead + - rehash postfix aliases + +- name: instantiate postfix main.cf template + template: + src: postfix/main.cf.j2 + dest: /etc/postfix/main.cf + notify: + - reload postfix + + +# verify everything is running +- name: verify services are running in dependency order + service: + name: "{{ item }}" + enabled: yes + state: started + loop: + - postfix + +- name: reload if certs newish + include_role: + name: certreload + vars: + certreload: + notifiers: + - reload postfix diff --git a/ansible/roles/postfix/templates/postfix/main.cf.j2 b/ansible/roles/postfix/templates/postfix/main.cf.j2 new file mode 100644 index 0000000..b3e8cc2 --- /dev/null +++ b/ansible/roles/postfix/templates/postfix/main.cf.j2 @@ -0,0 +1,194 @@ +# Modified from https://www.c0ffee.net/blog/mail-server-guide + +smtpd_banner = $myhostname ESMTP dx Independence + +# "2" is current for postfix 3.2 configs +compatibility_level = 2 + +# disable "new mail" notifications for local unix users +biff = no + +# directory to store mail for local unix users +mail_spool_directory = /var/mail/local + +# Name of this mail server, used in the SMTP HELO for outgoing mail. Make +# sure this resolves to the same IP as your reverse DNS hostname. +myhostname = {{ network.hostname.public }} + +# Domains for which postfix will deliver local mail. Does not apply to +# virtual domains, which are configured below. Make sure to specify the FQDN +# of your sever, as well as localhost. +# Note: NEVER specify any virtual domains here!!! Those come later. +mydestination = localhost + +# Domain appended to mail sent locally from this machine - such as mail sent +# via the `sendmail` command. +myorigin = $myhostname + +# prevent spammers from searching for valid users +disable_vrfy_command = yes + +# require properly formatted email addresses - prevents a lot of spam +strict_rfc821_envelopes = yes + +# don't give any helpful info when a mailbox doesn't exist +show_user_unknown_table_name = no + +# limit maximum e-mail size to 256 MB. mailbox size must be at least as big as +# the message size for the mail to be accepted, but has no meaning after +# that since we are using Dovecot for delivery. +message_size_limit = 268435456 +mailbox_size_limit = 0 + +# require addresses of the form "user@domain.tld" +allow_percent_hack = no +swap_bangpath = no + +# allow plus-aliasing: "user+tag@domain.tld" delivers to "user" mailbox +recipient_delimiter = + + +# path to the SSL certificate for the mail server +smtpd_tls_cert_file = /etc/ssl/{{ network.hostname.public }}-cert-combined.rsa2048.pem +smtpd_tls_key_file = /etc/ssl/private/{{ network.hostname.public }}-key.rsa2048.pem + +# You can also specify an EC cert to try first if the clients support it. +smtpd_tls_eccert_file = /etc/ssl/{{ network.hostname.public }}-cert-combined.prime256v1.pem +smtpd_tls_eckey_file = /etc/ssl/private/{{ network.hostname.public }}-key.prime256v1.pem + +# Path to your trusted certificates file. Usually provided by a +# ca-certificates package or similar. +smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt + +# These two lines define how postfix will connect to other mail servers. +# "may" allows opportunistic TLS and "enabled" allows hostname lookups +# http://www.postfix.org/TLS_README.html +smtp_tls_security_level = may +smtp_dns_support_level = enabled + +# IP address used by postfix to send outgoing mail. You only need this if +# your machine has multiple IP addresses - set it to your MX address to +# satisfy your SPF record. +smtp_bind_address = {{ hostvars[inventory_hostname]['ansible_' + network.interface.public]['ipv4']['address'] }} +smtp_bind_address6 = +inet_interfaces = 127.0.0.1,$smtp_bind_address +inet_protocols = ipv4 + +# Here we define the options for "mandatory" TLS. In our setup, TLS is only +# "mandatory" for authenticating users. I got these settings from Mozilla's +# SSL reccomentations page. +# +# NOTE: do not attempt to make TLS mandatory for all incoming/outgoing +# connections. Do not attempt to change the default cipherlist for non- +# mandatory connections either. There are still a lot of mail servers out +# there that do not use TLS, and many that do only support old ciphers. +# Forcing TLS for everyone *will* cause you to lose mail. +smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1, TLSv1.2 +smtpd_tls_mandatory_ciphers = high +tls_high_cipherlist = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256 + +# allow other mail servers to connect using TLS, but don't require it +smtpd_tls_security_level = may + +# tickets and compression have known vulnerabilities +tls_ssl_options = no_ticket, no_compression + +# yes, using 2048 with "dh1024" is the right thing to do +smtpd_tls_dh1024_param_file = /etc/ssl/ffdhe2048.pem + +# cache incoming and outgoing TLS sessions +smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_tlscache +smtp_tls_session_cache_database = btree:${data_directory}/smtp_tlscache + +# enable SMTPD auth. Dovecot will place an `auth` socket in postfix's +# runtime directory that we will use for authentication. +# TODO: can also replace this with a network inet connection if dovecot opens it +smtpd_sasl_auth_enable = yes +smtpd_sasl_path = private/auth +smtpd_sasl_type = dovecot + +# only allow authentication over TLS +smtpd_tls_auth_only = yes + +# don't allow plaintext auth methods on unencrypted connections +smtpd_sasl_security_options = noanonymous, noplaintext +# but plaintext auth is fine when using TLS +smtpd_sasl_tls_security_options = noanonymous + +# add a message header when email was recieved over TLS +smtpd_tls_received_header = yes + +# require that connecting mail servers identify themselves - this greatly +# reduces spam +smtpd_helo_required = yes + +# The following block specifies some security restrictions for incoming +# mail. The gist of it is, authenticated users and connections from +# localhost can do anything they want. Random people connecting over the +# internet are treated with more suspicion: they must have a reverse DNS +# entry and present a valid, FQDN HELO hostname. In addition, they can only +# send mail to valid mailboxes on the server, and the sender's domain must +# actually exist. +smtpd_client_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + #reject_unknown_reverse_client_hostname, + # you might want to consider: + # reject_unknown_client_hostname, + # here. This will reject all incoming connections without a reverse DNS + # entry that resolves back to the client's IP address. This is a very + # restrictive check and may reject legitimate mail. + reject_unauth_pipelining +smtpd_helo_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + reject_invalid_helo_hostname, + reject_non_fqdn_helo_hostname, + # you might want to consider: + # reject_unknown_helo_hostname, + # here. This will reject all incoming mail without a HELO hostname that + # properly resolves in DNS. This is a somewhat restrictive check and may + # reject legitimate mail. + reject_unauth_pipelining +smtpd_sender_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + reject_non_fqdn_sender, +# reject_unknown_sender_domain, + reject_unauth_pipelining +smtpd_relay_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + # !!! THIS SETTING PREVENTS YOU FROM BEING AN OPEN RELAY !!! + reject_unauth_destination + # !!! DO NOT REMOVE IT UNDER ANY CIRCUMSTANCES !!! +#smtpd_recipient_restrictions = +# permit_mynetworks, +# permit_sasl_authenticated, +# reject_non_fqdn_recipient, +# reject_unknown_recipient_domain, +# reject_unauth_pipelining, +smtpd_data_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + reject_multi_recipient_bounce, + reject_unauth_pipelining + +smtpd_recipient_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + reject_unauth_destination + +# deliver mail for virtual users to Dovecot's LMTP socket +# TODO: convert this to network with dovecot opening a local inet port +virtual_transport = lmtp:unix:private/dovecot-lmtp +virtual_mailbox_domains = /etc/postfix/domains + +virtual_alias_maps = hash:/etc/postfix/virtual +#virtual_alias_domains = /etc/postfix/domains + +# We'll uncomment these when we set up rspamd later: +milter_protocol = 6 +milter_default_action = accept +#smtpd_milters = unix:/var/run/rspamd/milter.sock +smtpd_milters = inet:localhost:11332 +milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen} diff --git a/ansible/roles/ramdisk/tasks/main.yml b/ansible/roles/ramdisk/tasks/main.yml new file mode 100644 index 0000000..2c05747 --- /dev/null +++ b/ansible/roles/ramdisk/tasks/main.yml @@ -0,0 +1,13 @@ +- name: create ramdisk directory + file: + dest: /srv/ramdisk + mode: 0755 # TODO: use acl module and bind this to system data user + state: directory + +- name: mount ramdisk to /srv/ramdisk + mount: + path: /srv/ramdisk + src: tmpfs + fstype: tmpfs + opts: "lazytime,size={{ ansible_memtotal_mb }}M" + state: mounted diff --git a/ansible/roles/rspamd/files/redis/redis.conf b/ansible/roles/rspamd/files/redis/redis.conf new file mode 100644 index 0000000..ee237d9 --- /dev/null +++ b/ansible/roles/rspamd/files/redis/redis.conf @@ -0,0 +1,21 @@ +# listen only on localhost +# Even though we only tell rspamd to contact Redis over 127.0.0.1, it still +# connects via ::1 for some modules as well, so things inside rspamd break +# if redis isn't listening on both 127.0.0.1 and ::1 +bind 127.0.0.1 ::1 + +# limit the max amount of memory used - appropriate value will depend on +# your email volume +maxmemory 512mb +maxmemory-policy volatile-lru + +daemonize yes + +logfile /var/log/redis/redis-server.log +dir /var/lib/redis + +appendonly yes +appendfilename redisisgarbage.aof +dbfilename redisisgarbage.rdb + +pidfile /var/run/redis/redis-server.pid diff --git a/ansible/roles/rspamd/files/rspamd/local.d/classifier-bayes.conf b/ansible/roles/rspamd/files/rspamd/local.d/classifier-bayes.conf new file mode 100644 index 0000000..db2f240 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/classifier-bayes.conf @@ -0,0 +1,4 @@ +autolearn = true; +backend = "redis"; +new_schema = true; +expire = 8640000; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/greylist.conf b/ansible/roles/rspamd/files/rspamd/local.d/greylist.conf new file mode 100644 index 0000000..a6ee831 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/greylist.conf @@ -0,0 +1 @@ +enabled = false; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/milter_headers.conf b/ansible/roles/rspamd/files/rspamd/local.d/milter_headers.conf new file mode 100644 index 0000000..4c924d7 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/milter_headers.conf @@ -0,0 +1,3 @@ +# Refer to https://rspamd.com/doc/modules/milter_headers.html for information on configuration + +use = ["x-spamd-bar", "authentication-results", "x-spamd-result", "x-rspamd-server", "x-rspamd-queue-id"]; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/mx_check.conf b/ansible/roles/rspamd/files/rspamd/local.d/mx_check.conf new file mode 100644 index 0000000..de9fac1 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/mx_check.conf @@ -0,0 +1,2 @@ +# checks if sender's domain has at least one connectable MX record +enabled = true; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/neural.conf b/ansible/roles/rspamd/files/rspamd/local.d/neural.conf new file mode 100644 index 0000000..7fc7bd2 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/neural.conf @@ -0,0 +1,33 @@ +servers = "localhost"; +enabled = true; # Important after 1.7 + +# use_settings = true; + +rules { + "LONG" { + train { + max_trains = 5000; + max_usages = 200; + max_iterations = 25; + learning_rate = 0.01, + spam_score = 8; + ham_score = -2; + } + symbol_spam = "NEURAL_SPAM_LONG"; + symbol_ham = "NEURAL_HAM_LONG"; + ann_expire = 100d; + } + "SHORT" { + train { + max_trains = 100; + max_usages = 2; + max_iterations = 25; + learning_rate = 0.01, + spam_score = 8; + ham_score = -2; + } + symbol_spam = "NEURAL_SPAM_SHORT"; + symbol_ham = "NEURAL_HAM_SHORT"; + ann_expire = 1d; + } +} diff --git a/ansible/roles/rspamd/files/rspamd/local.d/neural_group.conf b/ansible/roles/rspamd/files/rspamd/local.d/neural_group.conf new file mode 100644 index 0000000..fff5058 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/neural_group.conf @@ -0,0 +1,29 @@ +symbols = { + "NEURAL_SPAM" { + weight = 3.0; # sample weight + description = "Neural network spam"; + } + "NEURAL_HAM" { + weight = -3.0; # sample weight + description = "Neural network ham"; + } +} + +symbols = { + "NEURAL_SPAM_LONG" { + weight = 3.0; # sample weight + description = "Neural network spam (long)"; + } + "NEURAL_HAM_LONG" { + weight = -3.0; # sample weight + description = "Neural network ham (long)"; + } + "NEURAL_SPAM_SHORT" { + weight = 2.0; # sample weight + description = "Neural network spam (short)"; + } + "NEURAL_HAM_SHORT" { + weight = -1.0; # sample weight + description = "Neural network ham (short)"; + } +} diff --git a/ansible/roles/rspamd/files/rspamd/local.d/phishing.conf b/ansible/roles/rspamd/files/rspamd/local.d/phishing.conf new file mode 100644 index 0000000..dcb1caf --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/phishing.conf @@ -0,0 +1,3 @@ +# check messages against some anti-phishing databases +openphish_enabled = true; +phishtank_enabled = true; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/redis.conf b/ansible/roles/rspamd/files/rspamd/local.d/redis.conf new file mode 100644 index 0000000..4ae822d --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/redis.conf @@ -0,0 +1,2 @@ +# just specifying a server enables redis for all modules that can use it +servers = "127.0.0.1:6379"; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/replies.conf b/ansible/roles/rspamd/files/rspamd/local.d/replies.conf new file mode 100644 index 0000000..382764d --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/replies.conf @@ -0,0 +1,2 @@ +# whitelist messages from threads that have been replied to +action = "no action"; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/surbl.conf b/ansible/roles/rspamd/files/rspamd/local.d/surbl.conf new file mode 100644 index 0000000..9cbd70f --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/surbl.conf @@ -0,0 +1,2 @@ +# follow redirects when checking URLs in emails for spaminess +redirector_hosts_map = "/etc/rspamd/redirectors.inc"; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/url_reputation.conf b/ansible/roles/rspamd/files/rspamd/local.d/url_reputation.conf new file mode 100644 index 0000000..f5ca48a --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/url_reputation.conf @@ -0,0 +1,2 @@ +# check URLs within messages for spaminess +enabled = true; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/url_tags.conf b/ansible/roles/rspamd/files/rspamd/local.d/url_tags.conf new file mode 100644 index 0000000..61832fb --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/url_tags.conf @@ -0,0 +1,2 @@ +# cache some URL tags in redis +enabled = true; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/worker-controller.inc b/ansible/roles/rspamd/files/rspamd/local.d/worker-controller.inc new file mode 100644 index 0000000..1e1a713 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/worker-controller.inc @@ -0,0 +1,39 @@ +# generate a password hash using the `rspamadm pw` command and define 'password' +# this one is the hash for 'hunter2' +# password = "$2$b9s94udsn7zzgk1hc9wuheqqcpydo64x$a1kksr9r9f1g1358shqdz789wmoqbnapndwqi6uscazhz3muz4gy"; + +# this one is the hash for: +# Roberts had grown so rich, he wanted to retire. He took me to his cabin and he told me his secret. 'I am not the Dread Pirate Roberts' he said. 'My name is Ryan; I inherited the ship from the previous Dread Pirate Roberts, just as you will inherit it from me. The man I inherited it from is not the real Dread Pirate Roberts either. His name was Cummerbund. The real Roberts has been retired 15 years and living like a king in Patagonia.' +# password = "$2$bhjy5j4njn8r5mx3yo6ksmdt9hbm8fan$myysr1gnbcf9ggpf4dzjdky3by9nbb8w9wbm7wciu97sbb7zhomy"; + +# this one is the hash for: +# The point is, ladies and gentleman, that greed, for lack of a better word, is good. Greed is right, greed works. Greed clarifies, cuts through, and captures the essence of the evolutionary spirit. Greed, in all of its forms; greed for life, for money, for love, knowledge has marked the upward surge of mankind. And greed, you mark my words, will not only save Teldar Paper, but that other malfunctioning corporation called the USA. Thank you very much. +# password = "$2$eqre5picpektnop85uashzyxpxeeo6cr$gugecdr35jhg8uhzxkwh4jnp19rtxc6ukaadaad5665sm1rapmfy"; + +# this one is the hash for: +# whence the day goes on +# password = "$2$am4gzwgxbuksntkn7784g6mpoir4mp1o$83i86hfju6jfbp6g9w9sh44qacqmne85q9weah6xcj1d3c5ei1rb"; + + +# You can list multiple bind sockets on networks and file systems. +#bind_socket = "/var/run/rspamd/rspamd.sock mode=0666 owner=nobody"; + +# The worker controller handles multiple functions: +# - trains spam +# - trains not-spam +# - hosts the built-in statistics web interface +# - web interface also allows config modification and data injection + +# The config parameter 'secure_ip' defines which sources DO NOT need a +# password to connect to this worker controller. +# By default, 'secure_ip' is defined as: +# secure_ip = "127.0.0.1"; +# secure_ip = "::1"; +# +# If you need distributed rspamd access or want to view your spam stats console +# from another machine without SSH tunneling port 11334, you can add multiple +# non-localhost IP address config lines here, but also remember to +# define 'password' above as well. + +bind_socket = "127.0.0.1:11334"; +#bind_socket = "192.168.122.8:11334"; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/worker-normal.inc b/ansible/roles/rspamd/files/rspamd/local.d/worker-normal.inc new file mode 100644 index 0000000..ffa77f5 --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/worker-normal.inc @@ -0,0 +1,3 @@ +# we're not running rspamd in a distributed setup, so this can be disabled +# the proxy worker will handle all the spam filtering +enabled = false; diff --git a/ansible/roles/rspamd/files/rspamd/local.d/worker-proxy.inc b/ansible/roles/rspamd/files/rspamd/local.d/worker-proxy.inc new file mode 100644 index 0000000..056d2bc --- /dev/null +++ b/ansible/roles/rspamd/files/rspamd/local.d/worker-proxy.inc @@ -0,0 +1,16 @@ +# this worker will be used as postfix milter +milter = yes; + +# note to self - tighten up these permissions +#bind_socket = "/var/run/rspamd/milter.sock mode=0666 owner=nobody"; + +# DEFAULT: listens on localhost:11332 + + +# the following specifies self-scan mode, for when rspamd is on the same +# machine as postfix +timeout = 120s; +upstream "local" { + default = yes; + self_scan = yes; +} diff --git a/ansible/roles/rspamd/handlers/main.yml b/ansible/roles/rspamd/handlers/main.yml new file mode 100644 index 0000000..de7cfc9 --- /dev/null +++ b/ansible/roles/rspamd/handlers/main.yml @@ -0,0 +1,11 @@ +--- +- name: restart rspamd + service: + name: rspamd + state: restarted + +- name: restart redis + service: + name: redis + state: restarted + diff --git a/ansible/roles/rspamd/meta/main.yml b/ansible/roles/rspamd/meta/main.yml new file mode 100644 index 0000000..8ab008e --- /dev/null +++ b/ansible/roles/rspamd/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: gpg diff --git a/ansible/roles/rspamd/tasks/main.yml b/ansible/roles/rspamd/tasks/main.yml new file mode 100644 index 0000000..a15c330 --- /dev/null +++ b/ansible/roles/rspamd/tasks/main.yml @@ -0,0 +1,54 @@ +--- +# Set Up rspam repo +- name: install rspam key + apt_key: + url: https://rspamd.com/apt-stable/gpg.key + state: present + +- name: create rspam repo + apt_repository: + repo: deb http://rspamd.com/apt-stable/ bionic main + state: present + +# rspamd install and configuration +- name: install shitdis + apt: + pkg: redis + state: latest + +- name: set guanodis directory permissions + file: + path: /var/lib/redis + owner: redis + group: redis + mode: 0700 + state: directory + +- name: copy poodis config + copy: + src: redis/ + dest: /etc/redis/ + notify: + - restart redis + +- name: install rspamd + apt: + pkg: rspamd + state: latest + +- name: copy rspamd config + copy: + src: rspamd/local.d/ + dest: /etc/rspamd/local.d/ + notify: + - restart rspamd + +# verify everything is running +- name: verify services are running in dependency order + service: + name: "{{ item }}" + enabled: yes + state: started + loop: + - redis + - rspamd diff --git a/ansible/roles/sieve/tasks/main.yml b/ansible/roles/sieve/tasks/main.yml new file mode 100644 index 0000000..1be8729 --- /dev/null +++ b/ansible/roles/sieve/tasks/main.yml @@ -0,0 +1,20 @@ +--- +- name: copy user sieves to user vmail homedirs + copy: + src: "sieve/{{ item.username }}.sieve" + dest: "/var/mail/vhosts/{{item.domain}}/{{item.user}}/sieve/" + mode: 0600 + owner: vmail + group: vmail + loop: "{{ sieve.users }}" + + # dovecot only reads .dovecot.sieve symlink in each users's virtual homedir +- name: create active sieve symlinks for dovecot users + file: + state: link + src: "/var/mail/vhosts/{{item.domain}}/{{item.user}}/sieve/{{ item.username }}.sieve" + dest: "/var/mail/vhosts/{{item.domain}}/{{item.user}}/.dovecot.sieve" + owner: vmail + group: vmail + force: yes + loop: "{{ sieve.users }}" diff --git a/ansible/runner.sh b/ansible/runner.sh new file mode 100755 index 0000000..c06b35a --- /dev/null +++ b/ansible/runner.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -x +set -e + +here=$(dirname $0) + +GROUP_PLAYBOOK=$1 +INVENTORY="$here/inventory/inventory" + +# More debug options from ansible docs about auto-provisioning... +# (mostly disables built-in checks and overrides some defaults) +# (broken across lines so all the options are easier to see): +# PYTHONUNBUFFERED=1 +# ANSIBLE_FORCE_COLOR=true +# ANSIBLE_HOST_KEY_CHECKING=false +# ANSIBLE_SSH_ARGS='-o UserKnownHostsFile=/dev/null -o ControlMaster=auto -o ControlPersist=60s' +# ansible-playbook +# --private-key=/home/someone/.vagrant.d/insecure_private_key +# --user=vagrant +# --connection=ssh +# --limit='machine1' +# --inventory-file=/inventory/vagrant_ansible_inventory +# playbook.yml + +# "debug" below formats output as properly indented/pretty printed. +# You can also replace "debug" with "yaml" for a different view. +# For details of all stdout callbacks, see: +# https://docs.ansible.com/ansible/2.5/plugins/callback.html +# and/or +# ansible-doc -t callback -l + +# The following assumes you are testing per-host playbooks and the +# host(s) you are testing are a prefix of the playbook name. +# e.g. if your host name is "webby" and you test playbook "web", +# that's a valid prefix match ("web" is a prefix of "webby") +# or, you can use direct names: deploy to mailmash using mailmash.yml +ANSIBLE_STDOUT_CALLBACK=debug PYTHONUNBUFFERED=1 ansible-playbook -v --inventory $INVENTORY \ + -l $GROUP_PLAYBOOK \ + "$here/$GROUP_PLAYBOOK.yml" \ + --ask-pass --ask-become-pass -- cgit v1.2.3