Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
12c73a9
New version to fix cert bug
nonholamilza Sep 1, 2025
cea8cc5
New logo idea for replatform
nonholamilza Sep 1, 2025
c2946b6
Update README.md
nonholamilza Sep 1, 2025
e496bb4
Rename Replatform_logo.jpg to replatform_logo.jpg
nonholamilza Sep 1, 2025
2e28bfe
Remove OCSP stapling configuration
nonholamilza May 20, 2026
ca68388
Support Debian 12 (Bookworm) alongside Debian 11 (Bullseye)
nonholamilza Aug 21, 2026
30aea7d
Update TODO.md
nonholamilza Aug 21, 2026
f186383
Update TODO.md
nonholamilza Aug 21, 2026
32fea3f
Migrate to Debian 12 is done
nonholamilza Aug 21, 2026
52de05b
gitignore: ignora .DS_Store
nonholamilza Aug 22, 2026
471a0fe
dovecot: make auth_verbose and mail_debug configurable, default 'no'
nonholamilza Aug 22, 2026
5a3d640
vars: document dovecot debug toggles with safe defaults
nonholamilza Aug 22, 2026
bbcf577
Manage ufw and fail2ban via Ansible
nonholamilza Aug 22, 2026
1bd24fc
ssl_cert: allow extra certbot args for staging runs
nonholamilza Aug 22, 2026
6912ae4
fail2ban: read mail logs from journal on bookworm, verify daemon is up
nonholamilza Aug 22, 2026
10409ca
Add ASCII banner at playbook start via callback plugin
nonholamilza Aug 22, 2026
7b319b8
ssh: make root login and password auth configurable
nonholamilza Aug 24, 2026
d2ed6a8
img: new logo update
nonholamilza Aug 25, 2026
52b4d52
todo: mark ssl cert expiry investigation as done
nonholamilza Aug 26, 2026
f6e817a
chore: untrack __pycache__, add to .gitignore
nonholamilza Aug 26, 2026
98df493
feat: fail2ban and AbuseIPDB fully under Ansible management
nonholamilza Aug 26, 2026
8db0d3f
Merge branch 'master' into master
nonholamilza Aug 27, 2026
41c7b47
Update ASCII logo and README
nonholamilza Aug 27, 2026
737789b
Update README.md
nonholamilza Aug 27, 2026
3bd0886
Merge branch 'master' into hardening
nonholamilza Aug 27, 2026
81776c7
fail2ban: replace recidive with per-jail bantime.increment, fix mail …
nonholamilza Aug 29, 2026
a6fb8d5
dovecot: setting dovecot to create mailbox on disk
nonholamilza Aug 29, 2026
dc17618
ufw: restart fail2ban after enabling to restore its INPUT jump rule
nonholamilza Aug 30, 2026
720162c
Merge pull request #1 from nonholamilza/hardening
nonholamilza Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
*.retry
.DS_Store
private_vars.yml
private_vars/
__pycache__/
__pycache__/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ansible.cfg
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions callback_plugins/banner.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions configure_dovecot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
template:
src: templates/etc_dovecot_passwd.j2
dest: /etc/dovecot/passwd
notify: reload dovecot

- name: Ensure sieve directories exist
file:
Expand Down
38 changes: 38 additions & 0 deletions configure_fail2ban.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion configure_postfix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 19 additions & 10 deletions configure_spamassassin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions configure_ufw.yml
Original file line number Diff line number Diff line change
@@ -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"
39 changes: 39 additions & 0 deletions generate_hostname_records.yml
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions generate_txt_records.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}."
Binary file added img/Replatform_logo-color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/Replatform_logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions img/banner.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
___ __ __ ___
/ _ \___ ___ / /__ _/ /_/ _/__ ______ _
/ , _/ -_)/ _ \/ / _ `/ __/ _/ _ \/ __/ ' \
/_/|_|\__// .__/_/\_,_/\__/_/ \___/_/ /_/_/_/
/_/

Run your own websites and email

Binary file removed img/replatform.jpeg
Binary file not shown.
12 changes: 10 additions & 2 deletions mail_server_setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions private_vars.template
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<PASTE-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"
10 changes: 10 additions & 0 deletions security_setup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
- name: Configure fail2ban
include_tasks: configure_fail2ban.yml
tags:
- fail2ban

- name: Configure ufw
include_tasks: configure_ufw.yml
tags:
- ufw
9 changes: 7 additions & 2 deletions site.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
- hosts: myplatform
remote_user: root
force_handlers: true

vars_files:
- vars.yml
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions ssh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions ssl_cert.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Loading