diff --git a/.gitignore b/.gitignore index a8b42eb..8e2b0bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ *.retry +.DS_Store +private_vars.yml +private_vars/ +__pycache__/ +__pycache__/ diff --git a/README.md b/README.md index 831e04d..f1a4260 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # replatform -![replatform logo](img/replatform.jpeg) +![replatform logo](img/Replatform_logo.png) Run your own websites and email accounts using a platform you control. @@ -34,7 +34,7 @@ needed to migrate to newer versions of Debian. - You have your domain name(s) registered under the domain name provider(s) of your choice -- You have root access to a Debian 11 linux server (see bottom of page +- You have root access to a Debian 12 linux server (see bottom of page for a good deal) ## Steps diff --git a/TODO.md b/TODO.md index 3470d07..74be0ee 100644 --- a/TODO.md +++ b/TODO.md @@ -6,15 +6,16 @@ - [ ] Allow only necessary ports via ufw - [ ] investigate ssl cert expiry - [ ] Add mail-tester.com to docs -- [ ] Migrate to Debian 12 (Bookworm) -- [ ] Add fail2ban to stop brute force attacks ### In Progress - ### Done +- [x] Allow only necessary ports via ufw +- [x] Add fail2ban to stop brute force attacks +- [x] Migrate to Debian 12 (Bookworm) +- [x] investigate ssl cert expiry - [x] Fix mail server reload after cert update - Add to certbot post hook - systemctl reload postfix diff --git a/ansible.cfg b/ansible.cfg index a473453..5969502 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,8 @@ [defaults] inventory = hosts.ini retry_files_enabled = False +callback_plugins = ./callback_plugins +callbacks_enabled = banner [ssh_connection] -pipelining=True +pipelining=True \ No newline at end of file diff --git a/callback_plugins/banner.py b/callback_plugins/banner.py new file mode 100644 index 0000000..bd8bc86 --- /dev/null +++ b/callback_plugins/banner.py @@ -0,0 +1,21 @@ +from __future__ import annotations +import os +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'banner' + CALLBACK_NEEDS_ENABLED = True + + def v2_playbook_on_start(self, playbook): + path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'img', 'banner.txt' + ) + try: + with open(path) as f: + self._display.display("\n" + f.read()) + except OSError: + pass diff --git a/configure_dovecot.yml b/configure_dovecot.yml index 6e081bb..960fd41 100644 --- a/configure_dovecot.yml +++ b/configure_dovecot.yml @@ -22,6 +22,7 @@ template: src: templates/etc_dovecot_passwd.j2 dest: /etc/dovecot/passwd + notify: reload dovecot - name: Ensure sieve directories exist file: diff --git a/configure_fail2ban.yml b/configure_fail2ban.yml new file mode 100644 index 0000000..ecf59ff --- /dev/null +++ b/configure_fail2ban.yml @@ -0,0 +1,38 @@ +--- +- name: Ensure fail2ban is installed + apt: pkg=fail2ban state=present + +- name: Ensure python3-systemd is installed (required by fail2ban's systemd backend) + apt: pkg=python3-systemd state=present + +- name: Configure fail2ban jails + template: + src: templates/jail.local.j2 + dest: /etc/fail2ban/jail.local + mode: '0600' + notify: restart fail2ban + +- name: Configure fail2ban global settings + template: + src: templates/fail2ban.local.j2 + dest: /etc/fail2ban/fail2ban.local + mode: '0600' + notify: restart fail2ban + +- name: Configure AbuseIPDB reporting key + template: + src: templates/abuseipdb.local.j2 + dest: /etc/fail2ban/action.d/abuseipdb.local + owner: root + mode: '0600' + notify: restart fail2ban + +- name: Ensure fail2ban is running and starts at boot + service: name=fail2ban state=started enabled=yes + +- name: Apply pending fail2ban restart before verifying + meta: flush_handlers + +- name: Verify fail2ban is actually running + command: fail2ban-client ping + changed_when: false \ No newline at end of file diff --git a/configure_postfix.yml b/configure_postfix.yml index 3c8b011..ab13ca4 100644 --- a/configure_postfix.yml +++ b/configure_postfix.yml @@ -59,7 +59,7 @@ line: "myorigin = /etc/mailname" - regexp: "^mydestination" - line: "mydestination = $myhostname, myplatform.dataengineering.co.ke, localhost.dataengineering.co.ke, localhost" + line: "mydestination = $myhostname, localhost.$mydomain, localhost" - regexp: "^mynetworks" # Relay mail from host only line: "mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" diff --git a/configure_spamassassin.yml b/configure_spamassassin.yml index 1b54f3e..0c22a06 100644 --- a/configure_spamassassin.yml +++ b/configure_spamassassin.yml @@ -11,17 +11,26 @@ state: present notify: restart spamass-milter -- name: Configure spamd via /etc/default/spamassassin +- name: Configure spamd via /etc/default/{{ spamassassin_service_name }} lineinfile: - dest: "/etc/default/spamassassin" + dest: "/etc/default/{{ spamassassin_service_name }}" state: present - regexp: "{{ item.regexp }}" - line: "{{ item.line }}" - with_items: - - regexp: "^CRON=" # Update spam rules nightly - line: "CRON=1" + regexp: "^OPTIONS=" # run spamd with non-root debian-spamd user + line: 'OPTIONS="--create-prefs --max-children 5 --helper-home-dir=/var/lib/spamassassin -u debian-spamd -g debian-spamd -x"' + notify: restart spamassassin daemon - - regexp: "^OPTIONS=" # run spamd with non-root debian-spamd user - line: 'OPTIONS="--create-prefs --max-children 5 --helper-home-dir=/var/lib/spamassassin -u debian-spamd -g debian-spamd -x"' - +- name: Enable nightly spam rule updates via CRON (Bullseye only, superseded by spamassassin-maintenance.timer on Bookworm) + lineinfile: + dest: "/etc/default/{{ spamassassin_service_name }}" + state: present + regexp: "^CRON=" + line: "CRON=1" + when: ansible_distribution_major_version | int < 12 notify: restart spamassassin daemon + +- name: Enable and start the systemd timer that updates spam rules nightly (Bookworm only) + service: + name: spamassassin-maintenance.timer + state: started + enabled: yes + when: ansible_distribution_major_version | int >= 12 diff --git a/configure_ufw.yml b/configure_ufw.yml new file mode 100644 index 0000000..bfa859d --- /dev/null +++ b/configure_ufw.yml @@ -0,0 +1,41 @@ +--- +- name: Ensure ufw is installed + apt: pkg=ufw state=present + +# ufw is reset on every run and rebuilt from ufw_allowed_ports so that +# the firewall matches what's declared in vars.yml rather than +# accumulating manually-added rules over time. The reset briefly +# disables ufw; if anything fails in between, the rescue block below +# re-enables it with a default-deny policy so the server is never left +# without a firewall. +- block: + - name: Reset ufw to a clean slate + command: ufw --force reset + + - name: Set default policy to deny all incoming traffic + command: ufw default deny incoming + + - name: Set default policy to allow all outgoing traffic + command: ufw default allow outgoing + + - name: Allow required inbound ports + command: "ufw allow {{ item.port }}/{{ item.proto }}" + loop: "{{ ufw_allowed_ports }}" + loop_control: + label: "{{ item.port }}/{{ item.proto }}" + + - name: Allow ManageSieve (port 4190) for remote filter editing + command: ufw allow 4190/tcp + when: enable_managesieve + + - name: Enable ufw + command: ufw --force enable + notify: restart fail2ban + + rescue: + - name: Re-enable ufw after a failed run + command: ufw --force enable + + - name: Fail with an explicit message + fail: + msg: "ufw configuration failed — firewall re-enabled, verify the rules on the server" diff --git a/generate_hostname_records.yml b/generate_hostname_records.yml new file mode 100644 index 0000000..0c94ac2 --- /dev/null +++ b/generate_hostname_records.yml @@ -0,0 +1,39 @@ +--- +- name: Extract dkim record value for the server hostname + shell: + cmd: "tr -d '\n' < /etc/dkimkeys/{{ server_hostname }}/default.txt | sed -E 's/.+IN\\s+TXT\\s+//' | tr -d '()\"[:blank:]' | sed -E s/\\;--.+//" + register: extract_hostname_dkim_result + changed_when: false + +- name: Create file to hold dkim txt record for the server hostname + template: + src: templates/txt_record_template.j2 + dest: "~/dns_txt_records/{{ server_hostname }}_dkim.txt" + vars: + key: "default._domainkey.{{ server_hostname }}" + value: "{{ extract_hostname_dkim_result.stdout }}" + +- name: Create file to hold spf txt record for the server hostname + template: + src: templates/txt_record_template.j2 + dest: "~/dns_txt_records/{{ server_hostname }}_spf.txt" + vars: + key: "{{ server_hostname }}" + value: "v=spf1 a -all" + +- name: Create file to hold A record for the server hostname + template: + src: templates/txt_record_template.j2 + dest: "~/dns_txt_records/{{ server_hostname }}_a.txt" + vars: + key: "{{ server_hostname }}" + value: "{{ ipv4_address }}" + +- name: Create file to hold AAAA record for the server hostname + template: + src: templates/txt_record_template.j2 + dest: "~/dns_txt_records/{{ server_hostname }}_aaaa.txt" + vars: + key: "{{ server_hostname }}" + value: "{{ ipv6_address }}" + when: (ipv6_address is defined) and (ipv6_address | length > 0) diff --git a/generate_txt_records.yml b/generate_txt_records.yml index 01430df..8ef36ab 100644 --- a/generate_txt_records.yml +++ b/generate_txt_records.yml @@ -28,3 +28,11 @@ vars: key: "_dmarc.{{ domain }}" value: "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@{{ domain }}; ruf=mailto:dmarc-reports@{{ domain }}; fo=1" + +- name: Create file to hold mx record + template: + src: templates/txt_record_template.j2 + dest: "~/dns_txt_records/{{ domain }}_mx.txt" + vars: + key: "{{ domain }}" + value: "10 {{ server_hostname }}." diff --git a/img/Replatform_logo-color.png b/img/Replatform_logo-color.png new file mode 100644 index 0000000..e95ae61 Binary files /dev/null and b/img/Replatform_logo-color.png differ diff --git a/img/Replatform_logo.png b/img/Replatform_logo.png new file mode 100644 index 0000000..a23e9c6 Binary files /dev/null and b/img/Replatform_logo.png differ diff --git a/img/banner.txt b/img/banner.txt new file mode 100644 index 0000000..8f30327 --- /dev/null +++ b/img/banner.txt @@ -0,0 +1,8 @@ + ___ __ __ ___ + / _ \___ ___ / /__ _/ /_/ _/__ ______ _ + / , _/ -_)/ _ \/ / _ `/ __/ _/ _ \/ __/ ' \ +/_/|_|\__// .__/_/\_,_/\__/_/ \___/_/ /_/_/_/ + /_/ + + Run your own websites and email + diff --git a/img/replatform.jpeg b/img/replatform.jpeg deleted file mode 100644 index eb4e438..0000000 Binary files a/img/replatform.jpeg and /dev/null differ diff --git a/mail_server_setup.yml b/mail_server_setup.yml index 2152dcd..52424ac 100644 --- a/mail_server_setup.yml +++ b/mail_server_setup.yml @@ -11,13 +11,18 @@ - opendkim - opendkim-tools - spamass-milter + - spamassassin + +- name: Determine the SpamAssassin daemon systemd unit name for this OS + set_fact: + spamassassin_service_name: "{{ 'spamd' if ansible_distribution_major_version | int >= 12 else 'spamassassin' }}" - name: Ensure that mail system servers are running and start at boot service: "name={{ item }} state=started enabled=yes" loop: - postfix - dovecot - - spamassassin + - "{{ spamassassin_service_name }}" - spamass-milter - name: Combine all mail domains @@ -90,8 +95,11 @@ path: ~/dns_txt_records state: directory -- name: Generate TXT records for DKIM, DMARC, SPF for hosted domains +- name: Generate TXT records for DKIM, DMARC, SPF, MX for hosted domains include_tasks: generate_txt_records.yml loop: "{{ mail_domains.keys() | list }}" loop_control: loop_var: domain + +- name: Generate SPF, DKIM, A/AAAA records for the server hostname + include_tasks: generate_hostname_records.yml diff --git a/private_vars.template b/private_vars.template index 3843779..43c54d4 100644 --- a/private_vars.template +++ b/private_vars.template @@ -64,3 +64,15 @@ mail_domains: # be directed to this address as well, so problems to do with email # deliverability will be sent here. admin_mail_password: "some super secret password" + +# API key for AbuseIPDB (https://www.abuseipdb.com/), used to report +# fail2ban bans so other sysadmins can see repeat offenders. +# Get a free key by creating an account there, then generating one under +# Account > API. +abuseipdb_api_key: "" + +# A second recipient for fail2ban ban notification emails, added +# alongside root@localhost so you can actually see bans without having to +# read root's local mailbox on the server. Applied to every jail except +# sshd (ssh brute-force attempts are constant and not worth alerting on). +fail2ban_notify_email: "you@example.com" diff --git a/security_setup.yml b/security_setup.yml new file mode 100644 index 0000000..4a520ae --- /dev/null +++ b/security_setup.yml @@ -0,0 +1,10 @@ +--- +- name: Configure fail2ban + include_tasks: configure_fail2ban.yml + tags: + - fail2ban + +- name: Configure ufw + include_tasks: configure_ufw.yml + tags: + - ufw diff --git a/site.yml b/site.yml index 51d6210..f4f146f 100644 --- a/site.yml +++ b/site.yml @@ -1,6 +1,7 @@ --- - hosts: myplatform remote_user: root + force_handlers: true vars_files: - vars.yml @@ -50,12 +51,13 @@ user: name: admin create_home: yes - password: "{{ admin_mail_password | password_hash('blowfish', ('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' | shuffle(seed=server_hostname) | join)[:21] + ('Oeu' | shuffle(seed=server_hostname) | join)[1], rounds=11) }}" + password: "{{ admin_mail_password | password_hash('blowfish', rounds=11) }}" groups: adm append: true - include_tasks: web_server_setup.yml - include_tasks: mail_server_setup.yml + - include_tasks: security_setup.yml - name: Ensure rsync is installed apt: pkg=rsync state=present @@ -96,10 +98,13 @@ service: name=spamass-milter state=restarted - name: restart spamassassin daemon - service: name=spamassassin state=restarted + service: "name={{ spamassassin_service_name }} state=restarted" - name: restart opendkim service: name=opendkim state=restarted - name: pre-compile default sieve command: sievec /var/lib/dovecot/sieve/default.sieve + + - name: restart fail2ban + service: name=fail2ban state=restarted diff --git a/ssh.yml b/ssh.yml index f142088..981d617 100644 --- a/ssh.yml +++ b/ssh.yml @@ -12,11 +12,11 @@ mode: 0644 backup: yes with_items: - - regexp: "^PasswordAuthentication" # Only key based auth - line: "PasswordAuthentication no" + - regexp: "^PasswordAuthentication" # Only key based auth by default + line: "PasswordAuthentication {{ ssh_password_authentication }}" - regexp: "^PermitRootLogin" # We're using root for login - line: "PermitRootLogin yes" + line: "PermitRootLogin {{ ssh_permit_root_login }}" - regexp: "^UseDNS" # No need for reverse-dns checks on clients line: "UseDNS no" diff --git a/ssl_cert.yml b/ssl_cert.yml index 8f5871f..9aeb8bd 100644 --- a/ssl_cert.yml +++ b/ssl_cert.yml @@ -31,6 +31,7 @@ vars: certbot_command: >- certbot certonly --standalone --non-interactive --agree-tos + {{ certbot_extra_args | default('') }} --email {{ cert_admin_email }} --cert-name {{ server_hostname }} {% for domain in web_domains %} -d {{ domain }} -d www.{{ domain }} diff --git a/templates/abuseipdb.local.j2 b/templates/abuseipdb.local.j2 new file mode 100644 index 0000000..9c8c660 --- /dev/null +++ b/templates/abuseipdb.local.j2 @@ -0,0 +1,8 @@ +# /etc/fail2ban/action.d/abuseipdb.local +# +# Overrides the [Init] apikey used by fail2ban's built-in abuseipdb.conf +# action. Kept out of jail.local so the key isn't duplicated across every +# jail that reports to AbuseIPDB. + +[Init] +abuseipdb_apikey = {{ abuseipdb_api_key }} diff --git a/templates/dovecot.conf.j2 b/templates/dovecot.conf.j2 index 4f3aea9..7c7d294 100644 --- a/templates/dovecot.conf.j2 +++ b/templates/dovecot.conf.j2 @@ -44,7 +44,10 @@ pop3_uidl_format = %08Xu%08Xv # Authentication configuration: # --- -auth_verbose = yes # log reason for failures +# Enable only for diagnostics. Generates "unknown user" lines on intermediate +# passdb/userdb chain lookups, which fail2ban filters count +# as failed attempts, resulting in legitimate clients being banned. +auth_verbose = {{ dovecot_auth_verbose | default('no') }} auth_mechanisms = plain login # password sent in plain text over ssl # If the domain is hostname/localhost, use only the name component @@ -73,7 +76,8 @@ userdb { args = uid=vmail gid=vmail home=/home/vmail/%d/%n } -mail_debug = yes # todo: remember to turn off +# Enable for diagnostics only (see note on auth_verbose). +mail_debug = {{ dovecot_mail_debug | default('no') }} mail_privileged_group = mail @@ -82,18 +86,22 @@ namespace inbox { location = mailbox Drafts { special_use = \Drafts + auto = subscribe } mailbox Junk { special_use = \Junk + auto = subscribe } mailbox Sent { special_use = \Sent + auto = subscribe } mailbox "Sent Messages" { special_use = \Sent } mailbox Trash { special_use = \Trash + auto = subscribe } prefix = } diff --git a/templates/etc_dovecot_passwd.j2 b/templates/etc_dovecot_passwd.j2 index 145fe8f..f1ff592 100644 --- a/templates/etc_dovecot_passwd.j2 +++ b/templates/etc_dovecot_passwd.j2 @@ -1,5 +1,5 @@ {% for domain, info in mail_domains.items() %} {% for username, passwd in info.items() %} -{{ username }}@{{ domain }}:{BLF-CRYPT}{{ passwd | password_hash('blowfish', ('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' | shuffle(seed=server_hostname) | join)[:21] + ('Oeu' | shuffle(seed=server_hostname) | join)[1], rounds=11) }}:::::: +{{ username }}@{{ domain }}:{BLF-CRYPT}{{ passwd | password_hash('blowfish', rounds=11) }}:::::: {% endfor %} {% endfor %} \ No newline at end of file diff --git a/templates/fail2ban.local.j2 b/templates/fail2ban.local.j2 new file mode 100644 index 0000000..48e88ec --- /dev/null +++ b/templates/fail2ban.local.j2 @@ -0,0 +1,14 @@ +# /etc/fail2ban/fail2ban.local + +[Definition] + +# fail2ban_dbpurgeage (vars.yml) is kept >= the longest bantime configured +# in jail.local - currently [DEFAULT]'s bantime.maxtime (the cap each +# jail's own escalating bantime can reach), or [recidive]'s bantime if +# that optional jail is enabled and set longer. Otherwise a ban's database +# row would be purged before the ban itself expires, and it wouldn't +# survive a fail2ban restart. +dbpurgeage = {{ fail2ban_dbpurgeage }} + +# Silences a warning fail2ban-client -t otherwise prints on every check. +allowipv6 = auto diff --git a/templates/jail.local.j2 b/templates/jail.local.j2 new file mode 100644 index 0000000..3fb4ad1 --- /dev/null +++ b/templates/jail.local.j2 @@ -0,0 +1,133 @@ +# /etc/fail2ban/jail.local +# +# This file intentionally contains ONLY the settings that differ from +# Debian's /etc/fail2ban/jail.conf. Any section/option not listed below +# keeps its jail.conf default - do not paste jail.conf in here. +# +# AbuseIPDB reporting: the API key is never set in this file. It lives in +# /etc/fail2ban/action.d/abuseipdb.local (see abuseipdb.local.j2), which +# fail2ban's built-in abuseipdb action reads via its own [Init] section. +# The previous hand-edited jail.local had the key hardcoded four times +# here, had to be revoked and regenerated because of it. + +[DEFAULT] +bantime = 12h +findtime = 360m +maxretry = 5 +backend = auto +usedns = warn +destemail = root@localhost, {{ fail2ban_notify_email }} +sender = root@{{ server_hostname }} +mta = sendmail +banaction = iptables-multiport +banaction_allports = iptables-allports + +# Escalation lives here instead of in [recidive]: each jail grows its own +# bantime the more that jail's own limit is hit, capped at bantime.maxtime. +# overalljails=false keeps it scoped per jail on purpose - someone hammering +# IMAP earns longer and longer IMAP bans without ever losing submission +# (587) or the web. [recidive] below still exists for cross-jail escalation +# but is opt-in (see fail2ban_recidive_enabled in vars.yml). +bantime.increment = true +bantime.factor = 2 +bantime.maxtime = 4w +bantime.overalljails = false + +# No ignoreip here. Admin IPs are consumer fibre/mobile, so they're +# dynamic - a static whitelist would age backwards and end up protecting +# a stranger instead of us. If this box ever autobans us, the hosting +# provider's VNC console bypasses sshd entirely, and +# `fail2ban-client unban ` clears the ban. `ignoreself` (on by +# default) already covers the box's own local addresses. +action = %(action_)s + %(action_abuseipdb)s[abuseipdb_category="11,15"] + %(mta)s[name=%(__name__)s, sender="%(sender)s", dest="%(destemail)s"] + +[sshd] +enabled = true +maxretry = {{ fail2ban_sshd_maxretry }} +findtime = {{ fail2ban_sshd_findtime }} +bantime = {{ fail2ban_sshd_bantime }} + +# SSH brute-force noise is constant - keep the single default recipient +# instead of the second address configured above, and no mail action +# below, or that inbox would drown in alerts for something too common +# to be actionable. +destemail = root@localhost + +action = %(action_)s + %(action_abuseipdb)s[abuseipdb_category="18,22"] + +[postfix] +enabled = true +backend = systemd +mode = more +# port list carries a duplicated-looking "ssmtp" entry inherited from the +# production jail.local - unverified, not a deliberate choice, left as-is +# per the freeze. +port = smtp,465,submission, ssmtp +maxretry = 3 +bantime = 1h + +# A jail-level ignoreip REPLACES [DEFAULT]'s, it does not add to it - this +# is the only ignoreip in the whole file. It covers MXToolbox's +# monitoring IPs (see fail2ban_postfix_ignoreip in vars.yml for why). +# With mode=more this jail also matches legitimate SMTP rejects, not just +# auth failures: MXToolbox runs recurring open relay tests, Postfix +# correctly answers with "554 Relay access denied", and this jail counts +# that as abuse. Banning MXToolbox would be harmless, but reporting it to +# AbuseIPDB would file false-positive reports against a legitimate +# service and damage our reporter reputation. +{% if fail2ban_postfix_ignoreip %} +ignoreip = {{ fail2ban_postfix_ignoreip | join(' ') }} +{% endif %} + +action = %(action_)s + %(action_abuseipdb)s[abuseipdb_category="18"] + %(mta)s[name=%(__name__)s, sender="%(sender)s", dest="%(destemail)s"] + +[dovecot] +enabled = true +backend = systemd +port = pop3,pop3s,imap,imaps,sieve +maxretry = 10 +findtime = 600 +bantime = 3600 + +action = %(action_)s + %(mta)s[name=%(__name__)s, sender="%(sender)s", dest="%(destemail)s"] + +[postfix-sasl] +enabled = true +backend = systemd +# Only Postfix submission ports: this jail reads postfix[mode=auth] logs, +# the imap/pop entries in the old list were inherited noise. maxretry +# raised from 3 to 8 - a stale phone burns 3 attempts in seconds, a real +# SASL brute force does thousands. +port = smtp,465,submission +maxretry = 8 +bantime = 21600 +action = %(action_)s + %(mta)s[name=%(__name__)s, sender="%(sender)s", dest="%(destemail)s"] + +[nginx-http-auth] +enabled = true +# port, logpath and action all come from jail.conf / [DEFAULT] - do not +# override them here. That also means it inherits [DEFAULT]'s escalating +# bantime.increment and its mail notification. +{% if fail2ban_recidive_enabled %} + +# Optional cross-jail escalation, off by default: [DEFAULT]'s +# bantime.increment (above) already escalates each jail's own bantime on +# repeat offenses, scoped to that jail. [recidive] instead watches ALL +# jails together and re-bans an IP for even longer once it has racked up +# repeat bans anywhere - useful if the same attacker rotates across +# services, at the cost of a false positive in one jail lengthening bans +# in every other jail too. Enable via fail2ban_recidive_enabled in +# vars.yml. Its bantime must stay the longest of any jail here (see +# fail2ban_recidive_bantime in vars.yml) and fail2ban_dbpurgeage in +# fail2ban.local.j2 must stay >= it. +[recidive] +enabled = true +bantime = {{ fail2ban_recidive_bantime }} +{% endif %} diff --git a/templates/nginx_site.j2 b/templates/nginx_site.j2 index 41f2b71..7efb23b 100644 --- a/templates/nginx_site.j2 +++ b/templates/nginx_site.j2 @@ -42,14 +42,4 @@ server { # HSTS (ngx_http_headers_module is required) (63072000 seconds) add_header Strict-Transport-Security "max-age=63072000" always; - - # OCSP stapling - ssl_stapling on; - ssl_stapling_verify on; - - # verify chain of trust of OCSP response using Root CA and Intermediate certs - ssl_trusted_certificate /etc/letsencrypt/live/{{ server_hostname }}/chain.pem; - - # Using Google's DNS server as the resolver - resolver 8.8.8.8; -} \ No newline at end of file +} diff --git a/templates/start_services.j2 b/templates/start_services.j2 index 26c6d97..d0a76d0 100644 --- a/templates/start_services.j2 +++ b/templates/start_services.j2 @@ -5,5 +5,6 @@ echo "starting service nginx" systemctl start nginx echo "restarting mail services to reload certificates" -systemctl reload postfix -systemctl reload dovecot +systemctl restart postfix +systemctl restart dovecot +systemctl restart {{ 'spamd' if ansible_distribution_major_version | int >= 12 else 'spamassassin' }} diff --git a/vars.yml b/vars.yml index 4d8da6d..7837331 100644 --- a/vars.yml +++ b/vars.yml @@ -15,3 +15,132 @@ unattended_upgrade_mail_report_trigger: "on-change" # Timezone in format provided by tzdata timezone: "Etc/UTC" + +# Dovecot debug output. Enable only for diagnostics: verbose auth logging +# produces 'unknown user' lines on intermediate passdb/userdb lookups that +# fail2ban's dovecot filter counts as failed attempts. +dovecot_auth_verbose: 'no' +dovecot_mail_debug: 'no' + +# Inbound ports the firewall (ufw) must allow. ufw is reset and rebuilt +# from this list on every playbook run, so it is the single source of +# truth for the firewall - rules added by hand outside of Ansible will +# not survive the next run. +ufw_allowed_ports: + - { port: 22, proto: tcp } # SSH + - { port: 80, proto: tcp } # nginx (HTTP) + - { port: 443, proto: tcp } # nginx (HTTPS) + - { port: 25, proto: tcp } # SMTP + - { port: 587, proto: tcp } # submission + - { port: 465, proto: tcp } # submissions (implicit TLS) + - { port: 143, proto: tcp } # IMAP + - { port: 993, proto: tcp } # IMAPS + +# Open ManageSieve (port 4190) so users can edit their own mail filters +# from a mail client. Server-side Sieve filtering (see default.sieve) works +# regardless - this only controls remote rule editing. +enable_managesieve: false + +# --- SSH access ------------------------------------------------------- +# +# WARNING: read this before your first run if you log in with a +# password. The defaults below disable password login entirely. If you +# have not installed an SSH key yet, running the playbook as-is will +# lock you out of the server the next time sshd restarts. +# +# The safe order is: install your key first, verify it works, then run +# the playbook. +# +# 1. On your own machine (not the server), create a key if you don't +# have one: +# ssh-keygen -t ed25519 +# +# 2. Copy the public key to the server, using the password you still +# have. The key ends up in /root/.ssh/authorized_keys on the +# server - that file is the list of keys allowed to log in. +# +# From your own machine: +# ssh-copy-id root@your.IP.server.address +# +# It will ask for your password one last time. Note that only the +# public half of the key (~/.ssh/id_ed25519.pub) is sent; the +# private half (~/.ssh/id_ed25519, no .pub) stays on your machine +# and must never be copied anywhere. +# +# If ssh-copy-id isn't available do the same thing "by hand": +# cat ~/.ssh/id_ed25519.pub | ssh root@your.IP.server.address \ +# "mkdir -p ~/.ssh && chmod 700 ~/.ssh && \ +# cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" +# +# 3. Open a SECOND terminal and confirm the key works, leaving the +# first one connected as a fallback: +# ssh root@your.IP.server.address +# If it lets you in without asking for a password, you're set. +# +# 4. Now run the playbook with the defaults below. +# +# If you would rather keep password login - for example while you're +# still setting things up - set: +# +# ssh_password_authentication: 'yes' +# ssh_permit_root_login: 'yes' +# +# and change them back once your key is in place. Note that fail2ban +# slows brute-force attempts down but does not make passwords as safe +# as keys: a weak password is still a weak password. +# +# Quote the values. Unquoted yes/no are parsed by YAML as booleans and +# would be written into sshd_config as 'True'/'False', which sshd +# rejects. +# +# After a run, check what sshd actually ended up with: +# sshd -T | grep -iE 'permitrootlogin|passwordauthentication' +# +ssh_password_authentication: 'no' +ssh_permit_root_login: 'prohibit-password' + +# --- fail2ban ----------------------------------------------------------- +# +# sshd jail. bantime was previously -1 (permanent): that had produced +# over 40,000 bans that never expired, sitting forever in fail2ban's +# database. 1w is a normal first-offense ban; repeat offenders on sshd +# escalate via [DEFAULT]'s bantime.increment (see jail.local.j2) instead +# of getting a longer ban here. +fail2ban_sshd_maxretry: 2 +fail2ban_sshd_findtime: 10m +fail2ban_sshd_bantime: 1w + +# [recidive] is optional cross-jail escalation, off by default now that +# [DEFAULT]'s bantime.increment (jail.local.j2) already escalates each +# jail's own bantime on repeat offenses within that same jail. Enable +# this only if you also want an IP that gets banned repeatedly across +# DIFFERENT jails to be re-banned for longer than any single jail would. +# If enabled, its bantime must stay the longest of any jail here - it's +# the value fail2ban_dbpurgeage below is sized against. findtime and +# maxretry are left at jail.conf's own recidive defaults (1d / 5). +fail2ban_recidive_enabled: false +fail2ban_recidive_bantime: 4w + +# Must be >= the longest bantime configured in any jail - currently +# [DEFAULT]'s bantime.maxtime (jail.local.j2), or fail2ban_recidive_bantime +# above if [recidive] is enabled and set longer. If it's shorter, a ban's +# row gets purged from the database before the ban itself expires, and it +# won't survive a fail2ban restart. Debian's default is 1d. +fail2ban_dbpurgeage: 8w + +# MXToolbox's monitoring IPs (not a hostname: fail2ban re-resolves +# ignoreip DNS names on every check, and a resolution failure would +# silently drop the exemption). They run recurring open-relay tests +# against port 25; the [postfix] jail (mode=more) counts Postfix's +# correct "554 Relay access denied" reply as abuse. See the comment on +# [postfix]'s ignoreip in jail.local.j2 for why this is excluded rather +# than left to be banned. Source of these IPs: +# https://knowledgebase.mxtoolbox.com/home/which-ip-addresses-do-your-monitoring-servers-use- +# Defined as a YAML list, not a string, so adding an entry is a one-line +# diff. +fail2ban_postfix_ignoreip: + - 44.194.168.193 + - 52.55.244.91 + - 18.205.72.90 + - 18.209.86.113 +