Initial docs: alpha.jayfield.org server documentation
Records the host's service inventory, setup runbook, mail account list, sync plan, package diff vs. clean install, and hardening TODOs, including today's SSH hardening (key-only, no root login) and the fail2ban sshd findtime fix for a low-and-slow brute-force evasion pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdTyvEJkfNLDVAWt8WQ6X9
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
# alpha.jayfield.org — Fresh-Install Setup Guide
|
||||
|
||||
This is a from-scratch runbook for reproducing this host: Ubuntu 22.04 LTS
|
||||
running authoritative DNS, a full mail stack, Apache-fronted web services,
|
||||
and a Docker layer for containerized apps (Portainer, Nextcloud). It's
|
||||
written in build order — each section depends on the ones before it.
|
||||
|
||||
Reference: `README.md` documents the *current, live* state of this exact
|
||||
server. This file documents *how to get there* from nothing. When the two
|
||||
disagree, `README.md` is the source of truth for what's actually running.
|
||||
|
||||
---
|
||||
|
||||
## 0. Before you start
|
||||
|
||||
- **VPS with a real, stable public IPv4** (this host: `89.58.8.149`, static
|
||||
netplan config, not DHCP) and ideally IPv6. You'll need reverse DNS (PTR)
|
||||
for the mail IP pointed at your mail hostname *before* sending real mail —
|
||||
ask your host to set it. **Don't assume a self-service option exists**:
|
||||
this host's provider control panel has no PTR/rDNS field at all, so it
|
||||
had to go through a support ticket instead (see §3's `myhostname` caveat
|
||||
for the interim workaround this host is running while that's pending).
|
||||
- **Partition the disk fully at provision time.** This host originally had a
|
||||
9.7GB root partition on a 172GB disk — ~161GB sat unallocated for years
|
||||
before we caught it. If your provisioner's cloud image under-partitions,
|
||||
fix it immediately:
|
||||
```
|
||||
sudo growpart /dev/sda 3 # extend the partition into free space
|
||||
sudo resize2fs /dev/sda3 # grow the filesystem to fill it (online, no reboot)
|
||||
```
|
||||
Verify with `df -h /` and `sudo parted /dev/sda unit GB print free`.
|
||||
- **Register the domain and delegate DNS to this host** before step 2 —
|
||||
you need `ns1`/`ns2` NS records pointed here at the registrar for the
|
||||
zone to actually resolve publicly.
|
||||
- A non-root sudo user for all administration (this host: `jens`). Don't
|
||||
do the following steps as root directly.
|
||||
|
||||
---
|
||||
|
||||
## 1. Base system
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt full-upgrade -y
|
||||
sudo timedatectl set-timezone Europe/Berlin # match your locale
|
||||
sudo apt install -y unattended-upgrades
|
||||
sudo dpkg-reconfigure -plow unattended-upgrades
|
||||
```
|
||||
|
||||
### SSH
|
||||
|
||||
Move SSH off the default port to cut down on background credential-stuffing
|
||||
noise (this host uses `10022`):
|
||||
|
||||
```
|
||||
# /etc/ssh/sshd_config
|
||||
Port 10022
|
||||
```
|
||||
|
||||
Prefer `PermitRootLogin no` and key-only auth (`PasswordAuthentication no`)
|
||||
from the start, then add `fail2ban`'s `sshd` jail (already covered in §3)
|
||||
as defense-in-depth rather than the only line of defense:
|
||||
|
||||
```
|
||||
# /etc/ssh/sshd_config
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
```
|
||||
|
||||
(Fixed on this host 2026-07-19 — it had shipped with both `yes`. If you
|
||||
have chrooted SFTP-only accounts via a `Match Group` block, e.g. for
|
||||
dropbox-style uploads, that block can keep its own
|
||||
`PasswordAuthentication yes` independently; it doesn't reopen this risk as
|
||||
long as the matched accounts have no shell/forwarding/TTY.)
|
||||
|
||||
```bash
|
||||
sudo systemctl restart sshd
|
||||
```
|
||||
Test the new port from a **second terminal before closing your current
|
||||
session** — if `sshd_config` has a typo, you don't want to be locked out.
|
||||
|
||||
---
|
||||
|
||||
## 2. DNS (BIND9)
|
||||
|
||||
```bash
|
||||
sudo apt install -y bind9 bind9-dnsutils bind9-utils
|
||||
```
|
||||
|
||||
### Zone file
|
||||
|
||||
`/etc/bind/named.conf.default-zones`:
|
||||
```
|
||||
zone "jayfield.org" {
|
||||
type master;
|
||||
file "/etc/bind/db.jayfield.org";
|
||||
};
|
||||
```
|
||||
|
||||
`/etc/bind/db.jayfield.org` — minimal skeleton (see `README.md` for every
|
||||
record currently live; SPF/DKIM/DMARC TXT records get added once the mail
|
||||
stack in §3 generates a DKIM key):
|
||||
```
|
||||
$TTL 300
|
||||
$ORIGIN jayfield.org.
|
||||
@ IN SOA ns1.jayfield.org. hostmaster.jayfield.org. (
|
||||
2024010100 ; Serial — bump on every edit
|
||||
3600 ; Refresh
|
||||
1800 ; Retry
|
||||
1209600 ; Expire
|
||||
600 ) ; Minimum
|
||||
|
||||
@ IN A <your public IP>
|
||||
@ IN NS ns1.jayfield.org.
|
||||
@ IN NS ns2.jayfield.org.
|
||||
ns1.jayfield.org. IN A <your public IP>
|
||||
ns2.jayfield.org. IN A <your public IP>
|
||||
@ IN MX 10 mail
|
||||
mail IN A <your public IP>
|
||||
```
|
||||
|
||||
### `named.conf.options` — the two security fixes + resolver architecture
|
||||
|
||||
```
|
||||
options {
|
||||
directory "/var/cache/bind";
|
||||
version "unknown"; // don't leak the exact BIND version
|
||||
|
||||
rate-limit { // mitigate DNS reflection/amplification abuse
|
||||
responses-per-second 10;
|
||||
window 5;
|
||||
};
|
||||
|
||||
dnssec-validation auto;
|
||||
listen-on-v6 { any; };
|
||||
notify yes;
|
||||
allow-transfer { <your secondary NS IP, if any>; };
|
||||
allow-query { any; }; // required for a public authoritative zone
|
||||
allow-recursion { trusted; }; // recursion only for local/trusted clients
|
||||
|
||||
// Deliberately NO forwarders block — see note below.
|
||||
|
||||
querylog yes;
|
||||
minimal-responses no;
|
||||
};
|
||||
|
||||
acl "trusted" {
|
||||
localhost;
|
||||
localnets;
|
||||
};
|
||||
```
|
||||
|
||||
**Don't add a `forwarders {}` block.** The obvious move is to forward
|
||||
non-authoritative queries to your hosting provider's DNS servers — this
|
||||
host originally did exactly that, and it caused a real production
|
||||
incident: the provider's resolver is shared across many tenants, so its
|
||||
combined query volume tripped Spamhaus's DNSBL rate limit
|
||||
(`127.255.255.254`, a "your resolver is over quota" sentinel, not a real
|
||||
blacklist entry). Postfix's `postscreen` misread that as "this sender is
|
||||
blacklisted" and silently dropped legitimate inbound mail — confirmed live
|
||||
with GMX's own mail servers getting rejected twice. With no `forwarders`
|
||||
block, BIND does full recursive resolution itself (root hints → TLD →
|
||||
authoritative) under its own dedicated IP, avoiding the shared-quota
|
||||
problem entirely. See `README.md`'s "DNS resolver architecture" section
|
||||
for the full incident writeup.
|
||||
|
||||
```bash
|
||||
sudo named-checkconf
|
||||
sudo systemctl restart bind9
|
||||
```
|
||||
|
||||
### Point the system resolver at BIND
|
||||
|
||||
`/etc/systemd/resolved.conf`:
|
||||
```
|
||||
[Resolve]
|
||||
DNS=127.0.0.1
|
||||
```
|
||||
```bash
|
||||
sudo systemctl restart systemd-resolved
|
||||
```
|
||||
This makes every service on the box (Postfix's DNSBL/PTR lookups included)
|
||||
resolve through BIND's own recursion instead of a shared upstream. Leave
|
||||
the `127.0.0.53` stub listener and `/etc/resolv.conf` symlink untouched —
|
||||
Docker's DNS auto-detection specifically looks for that stub.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
dig @127.0.0.1 google.com # general recursive resolution works
|
||||
dig @127.0.0.1 jayfield.org +short # your own zone resolves
|
||||
```
|
||||
|
||||
### TSIG for dynamic DNS (if you need a dyndns subdomain)
|
||||
|
||||
```bash
|
||||
sudo tsig-keygen -a hmac-sha256 dyndns.jayfield.org. | sudo tee /etc/bind/dyndns.jayfield.org.key
|
||||
sudo chmod 640 /etc/bind/dyndns.jayfield.org.key
|
||||
sudo chown root:bind /etc/bind/dyndns.jayfield.org.key # NOT 644 — this key is the only auth for dynamic updates
|
||||
```
|
||||
Add a matching `key` block + `update-policy` (or a delegated zone) in
|
||||
`named.conf.local` — see the actual working config on this host for the
|
||||
exact `nsupdate` invocation pattern used by `/var/www/dyndns`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mail stack (Postfix + Dovecot + rspamd + fail2ban)
|
||||
|
||||
```bash
|
||||
sudo apt install -y postfix postfix-mysql mariadb-server \
|
||||
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-managesieved \
|
||||
dovecot-mysql dovecot-sieve \
|
||||
redis-server redis-tools \
|
||||
rspamd fail2ban
|
||||
```
|
||||
During the `postfix` package install prompt, pick "Internet Site",
|
||||
`mail.<yourdomain>` as the system mail name.
|
||||
|
||||
### MySQL: virtual mailbox schema
|
||||
|
||||
```bash
|
||||
sudo mysql -e "CREATE DATABASE vmail CHARACTER SET utf8;"
|
||||
sudo mysql vmail <<'EOF'
|
||||
CREATE TABLE domains (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
domain varchar(255) NOT NULL,
|
||||
PRIMARY KEY (id), UNIQUE KEY domain (domain)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
CREATE TABLE accounts (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
username varchar(64) NOT NULL,
|
||||
domain varchar(255) NOT NULL,
|
||||
password varchar(255) NOT NULL,
|
||||
quota int(10) unsigned DEFAULT 0,
|
||||
enabled tinyint(1) DEFAULT 0,
|
||||
sendonly tinyint(1) DEFAULT 0,
|
||||
PRIMARY KEY (id), UNIQUE KEY username (username,domain), KEY domain (domain),
|
||||
CONSTRAINT accounts_ibfk_1 FOREIGN KEY (domain) REFERENCES domains (domain)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
CREATE TABLE aliases (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
source_username varchar(64) NOT NULL, source_domain varchar(255) NOT NULL,
|
||||
destination_username varchar(64) NOT NULL, destination_domain varchar(255) NOT NULL,
|
||||
enabled tinyint(1) DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY source_username (source_username,source_domain,destination_username,destination_domain),
|
||||
KEY source_domain (source_domain),
|
||||
CONSTRAINT aliases_ibfk_1 FOREIGN KEY (source_domain) REFERENCES domains (domain)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
CREATE TABLE tlspolicies (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
domain varchar(255) NOT NULL,
|
||||
policy enum('none','may','encrypt','dane','dane-only','fingerprint','verify','secure') NOT NULL,
|
||||
params varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (id), UNIQUE KEY domain (domain)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
EOF
|
||||
sudo mysql -e "CREATE USER 'vmail'@'localhost' IDENTIFIED BY '<strong password>'; GRANT SELECT ON vmail.* TO 'vmail'@'localhost';"
|
||||
sudo mysql -e "INSERT INTO vmail.domains (domain) VALUES ('jayfield.org');"
|
||||
```
|
||||
Add real accounts via `INSERT INTO accounts ... ` with a `SHA512-CRYPT`
|
||||
password hash (`doveadm pw -s SHA512-CRYPT`). See `MAIL-ACCOUNTS.md` for
|
||||
the full day-to-day recipe (accounts, aliases, send-only addresses,
|
||||
disabling an account, verification) — this is just the one-time schema
|
||||
setup.
|
||||
|
||||
**Host `'localhost'`, even though Postfix/Dovecot connect via TCP to
|
||||
`127.0.0.1`** (see the `hosts =` / `connect =` lines below) — this looks
|
||||
wrong but isn't. With `skip_name_resolve` at its default (`OFF`), MariaDB
|
||||
reverse-resolves the connecting client's IP before matching it against
|
||||
`mysql.user`'s `Host` column, rather than matching the raw IP. Since
|
||||
`/etc/hosts` maps `127.0.0.1 → localhost` (standard on every Linux box),
|
||||
a TCP connection from `127.0.0.1` resolves to the hostname `localhost` at
|
||||
match time and hits the `vmail@localhost` grant — confirmed live 2026-07-19
|
||||
by connecting with a deliberately wrong password and reading the error:
|
||||
`Access denied for user 'vmail'@'localhost'`, despite `mysql -h 127.0.0.1`.
|
||||
A grant for `'vmail'@'127.0.0.1'` would also work (matching the raw IP
|
||||
directly), but `'localhost'` is what's actually live on this host, so
|
||||
that's what's documented here now — don't "fix" it to `127.0.0.1` thinking
|
||||
the current one is broken.
|
||||
|
||||
### Postfix SQL lookup maps
|
||||
|
||||
`/etc/postfix/sql/*.cf` — create each, mode `640 root:root`, all pointing
|
||||
at `hosts = 127.0.0.1`, `dbname = vmail`, `user = vmail`:
|
||||
|
||||
| File | Query |
|
||||
|---|---|
|
||||
| `domains.cf` | `SELECT domain FROM domains WHERE domain='%s'` |
|
||||
| `accounts.cf` | `select 1 as found from accounts where username='%u' and domain='%d' and enabled=true LIMIT 1;` |
|
||||
| `aliases.cf` | `select concat(destination_username,'@',destination_domain) as destinations from aliases where source_username='%u' and source_domain='%d' and enabled=true;` |
|
||||
| `recipient-access.cf` | `select if(sendonly=true,'REJECT','OK') AS access from accounts where username='%u' and domain='%d' and enabled=true LIMIT 1;` |
|
||||
| `sender-login-maps.cf` | `select concat(username,'@',domain) as 'owns' from accounts where username='%u' AND domain='%d' and enabled=true union select concat(destination_username,'@',destination_domain) AS 'owns' from aliases where source_username='%u' and source_domain='%d' and enabled=true;` |
|
||||
| `tls-policy.cf` | `SELECT policy, params FROM tlspolicies WHERE domain='%s'` |
|
||||
|
||||
### `main.cf` — the parts that matter
|
||||
|
||||
```
|
||||
myhostname = alpha.jayfield.org
|
||||
mydomain = jayfield.org
|
||||
mydestination = $myhostname localhost.$mydomain localhost
|
||||
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
|
||||
inet_interfaces = 127.0.0.1, ::1, <your public IP>
|
||||
virtual_mailbox_domains = mysql:/etc/postfix/sql/domains.cf
|
||||
virtual_mailbox_maps = mysql:/etc/postfix/sql/accounts.cf
|
||||
virtual_alias_maps = mysql:/etc/postfix/sql/aliases.cf
|
||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||
|
||||
# postscreen: pre-greet + DNSBL filtering before real smtpd handles anything
|
||||
postscreen_dnsbl_sites = ix.dnsbl.manitu.net*2 zen.spamhaus.org*2
|
||||
postscreen_dnsbl_threshold = 2
|
||||
postscreen_dnsbl_action = drop
|
||||
postscreen_greet_action = drop
|
||||
postscreen_access_list = permit_mynetworks cidr:/etc/postfix/postscreen_access
|
||||
|
||||
# no open relay: reject anything not from mynetworks or SASL-authenticated
|
||||
smtpd_relay_restrictions = reject_non_fqdn_recipient reject_unknown_recipient_domain permit_mynetworks reject_unauth_destination
|
||||
smtpd_recipient_restrictions = check_recipient_access mysql:/etc/postfix/sql/recipient-access.cf
|
||||
smtpd_client_restrictions = permit_mynetworks check_client_access hash:/etc/postfix/without_ptr reject_unknown_client_hostname
|
||||
smtpd_helo_required = yes
|
||||
smtpd_helo_restrictions = permit_mynetworks reject_invalid_helo_hostname reject_non_fqdn_helo_hostname reject_unknown_helo_hostname
|
||||
smtpd_data_restrictions = reject_unauth_pipelining
|
||||
|
||||
# submission (587) uses separate mua_* restrictions — see master.cf below
|
||||
mua_relay_restrictions = reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_mynetworks,permit_sasl_authenticated,reject
|
||||
mua_client_restrictions = permit_mynetworks,permit_sasl_authenticated,reject
|
||||
mua_sender_restrictions = permit_mynetworks,reject_non_fqdn_sender,reject_sender_login_mismatch,permit_sasl_authenticated,reject
|
||||
|
||||
# TLS
|
||||
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.jayfield.org/fullchain.pem
|
||||
smtpd_tls_key_file = /etc/letsencrypt/live/mail.jayfield.org/privkey.pem
|
||||
smtpd_tls_security_level = may
|
||||
smtp_tls_security_level = dane # opportunistic DANE validation for outbound
|
||||
smtp_tls_policy_maps = mysql:/etc/postfix/sql/tls-policy.cf
|
||||
smtp_dns_support_level = dnssec
|
||||
|
||||
# rspamd milter integration
|
||||
milter_default_action = accept
|
||||
milter_protocol = 6
|
||||
smtpd_milters = inet:localhost:11332
|
||||
non_smtpd_milters = inet:localhost:11332
|
||||
|
||||
message_size_limit = 52428800
|
||||
recipient_delimiter = +
|
||||
```
|
||||
|
||||
**`myhostname` must match the PTR record for the mail IP.** This host's
|
||||
PTR for `89.58.8.149` resolves to `alpha.jayfield.org`, not
|
||||
`mail.jayfield.org` — a PTR/EHLO mismatch that some strict receivers
|
||||
penalize. The "correct" fix is repointing the PTR at your hosting
|
||||
provider (a PTR record isn't set in this zone's own `db.jayfield.org`;
|
||||
it lives in the provider's reverse-DNS zone for the IP block, usually
|
||||
an rDNS field in their control panel or a support ticket) to
|
||||
`mail.jayfield.org`, matching the MX record. Until that's done,
|
||||
`myhostname` is set to `alpha.jayfield.org` here as a same-session
|
||||
workaround so PTR and EHLO at least agree — verify with:
|
||||
```bash
|
||||
printf 'EHLO test.local\r\nQUIT\r\n' | nc -w 3 127.0.0.1 25 # expect "220 alpha.jayfield.org ..."
|
||||
```
|
||||
Switch it back to `mail.jayfield.org` once the PTR is fixed upstream —
|
||||
that's the more conventional setup (dedicated mail hostname for both
|
||||
PTR and EHLO, matching the MX record) and is what the TLS cert
|
||||
(`mail.jayfield.org`) already assumes.
|
||||
|
||||
### `master.cf` — separate port 25 from port 587
|
||||
|
||||
```
|
||||
smtp inet n - y - 1 postscreen -o smtpd_sasl_auth_enable=no
|
||||
smtpd pass - - y - - smtpd
|
||||
dnsblog unix - - y - 0 dnsblog
|
||||
tlsproxy unix - - y - 0 tlsproxy
|
||||
|
||||
submission inet n - y - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_sasl_type=dovecot
|
||||
-o smtpd_sasl_path=private/auth
|
||||
-o smtpd_sasl_security_options=noanonymous
|
||||
-o smtpd_client_restrictions=$mua_client_restrictions
|
||||
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
||||
-o smtpd_relay_restrictions=$mua_relay_restrictions
|
||||
-o smtpd_sender_login_maps=mysql:/etc/postfix/sql/sender-login-maps.cf
|
||||
-o smtpd_helo_required=no
|
||||
-o smtpd_helo_restrictions=
|
||||
```
|
||||
This is the whole point: port 25 never accepts SASL auth and never relays
|
||||
to third parties (`reject_unauth_destination`); port 587 requires both TLS
|
||||
and authentication before it will relay anywhere.
|
||||
|
||||
### Dovecot
|
||||
|
||||
```
|
||||
# /etc/dovecot/conf.d/*.conf — key settings
|
||||
protocols = imap lmtp sieve
|
||||
mail_location = maildir:~/mail:LAYOUT=fs
|
||||
mail_uid = vmail
|
||||
mail_gid = vmail
|
||||
mail_home = /var/vmail/mailboxes/%d/%n
|
||||
mail_privileged_group = vmail
|
||||
|
||||
ssl = required
|
||||
ssl_cert = </etc/letsencrypt/live/mail.jayfield.org/fullchain.pem
|
||||
ssl_key = </etc/letsencrypt/live/mail.jayfield.org/privkey.pem
|
||||
disable_plaintext_auth = yes # default — do not override to "no"
|
||||
|
||||
passdb { driver = sql; args = /etc/dovecot/dovecot-sql.conf }
|
||||
userdb { driver = sql; args = /etc/dovecot/dovecot-sql.conf }
|
||||
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth { group = postfix; mode = 0660; user = postfix }
|
||||
}
|
||||
service lmtp {
|
||||
unix_listener /var/spool/postfix/private/dovecot-lmtp { group = postfix; mode = 0660; user = postfix }
|
||||
user = vmail
|
||||
}
|
||||
service managesieve-login {
|
||||
inet_listener sieve { port = 4190 }
|
||||
}
|
||||
|
||||
plugin {
|
||||
sieve = file:/var/vmail/sieve/%d/%n/scripts;active=/var/vmail/sieve/%d/%n/active-script.sieve
|
||||
sieve_before = /var/vmail/sieve/global/spam-global.sieve
|
||||
imapsieve_mailbox1_name = Spam
|
||||
imapsieve_mailbox1_causes = COPY
|
||||
imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve
|
||||
imapsieve_mailbox2_name = *
|
||||
imapsieve_mailbox2_from = Spam
|
||||
imapsieve_mailbox2_causes = COPY
|
||||
imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve
|
||||
}
|
||||
```
|
||||
`/etc/dovecot/dovecot-sql.conf`:
|
||||
```
|
||||
driver = mysql
|
||||
connect = host=127.0.0.1 dbname=vmail user=vmail password=<same as above>
|
||||
default_pass_scheme = SHA512-CRYPT
|
||||
password_query = SELECT username, domain, password FROM accounts WHERE username='%n' AND domain='%d' AND enabled=true;
|
||||
user_query = SELECT concat('*:storage=', quota, 'M') AS quota_rule FROM accounts WHERE username='%n' AND domain='%d' AND sendonly=false;
|
||||
```
|
||||
|
||||
### rspamd
|
||||
|
||||
```
|
||||
# /etc/rspamd/local.d/redis.conf
|
||||
servers = "127.0.0.1";
|
||||
|
||||
# /etc/rspamd/local.d/worker-proxy.inc (the milter Postfix talks to)
|
||||
bind_socket = "localhost:11332";
|
||||
|
||||
# /etc/rspamd/local.d/dkim_signing.conf
|
||||
path = "/var/lib/rspamd/dkim/$selector.key";
|
||||
selector = "2022";
|
||||
allow_username_mismatch = true;
|
||||
```
|
||||
Generate the DKIM key and publish it in DNS:
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/rspamd/dkim
|
||||
sudo rspamadm dkim_keygen -s 2022 -d jayfield.org -k /var/lib/rspamd/dkim/2022.key
|
||||
sudo chown _rspamd:_rspamd /var/lib/rspamd/dkim/2022.key
|
||||
```
|
||||
The command prints a DNS TXT record for `2022._domainkey.jayfield.org` —
|
||||
add it to `db.jayfield.org`, bump the serial, `rndc reload`.
|
||||
|
||||
Also add SPF and DMARC TXT records:
|
||||
```
|
||||
@ 3600 IN TXT "v=spf1 a:mail.jayfield.org ?all"
|
||||
_dmarc 3600 IN TXT "v=DMARC1; p=reject;"
|
||||
```
|
||||
|
||||
**Always quote TXT rdata in the zone file — an unquoted `;` starts a
|
||||
BIND comment, not a literal semicolon.** This host learned that the
|
||||
hard way 2026-07-19: `_dmarc ... TXT v=DMARC1; p=reject;` (unquoted)
|
||||
was silently being served as just `"v=DMARC1"` — BIND treated
|
||||
`; p=reject;` as a trailing comment and dropped it — and both DKIM
|
||||
records were serving only `"v=DKIM1"` the same way, with `k=rsa; p=`
|
||||
and the actual public key silently missing (**DKIM signature
|
||||
verification was failing for all outbound mail** as a result). A
|
||||
separate, unquoted, semicolon-free SPF line had the opposite problem —
|
||||
BIND split it on whitespace into multiple concatenated character-strings
|
||||
and merged in an unrelated `google-site-verification=` token, which
|
||||
broke SPF parsing (RFC 7208 concatenates a record's strings with no
|
||||
added spaces). `named-checkzone` does **not** catch either mistake — it
|
||||
happily loads a TXT record missing its policy tag or one with extra
|
||||
strings, since both are syntactically valid TXT rdata. Verify TXT
|
||||
records with `dig TXT <name> @<ns>` after every edit, not just
|
||||
`named-checkzone`.
|
||||
|
||||
A single quoted character-string is also capped at 255 bytes — DKIM
|
||||
public keys routinely exceed that. Split across multiple adjacent
|
||||
quoted strings inside the same record instead (the DNS/DKIM spec
|
||||
concatenates them back together):
|
||||
```
|
||||
2022._domainkey 3600 IN TXT ("v=DKIM1; k=rsa; p=<first ~236 chars>" "<rest of the base64 key>")
|
||||
```
|
||||
|
||||
### fail2ban
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now fail2ban
|
||||
```
|
||||
Enable jails for `sshd`, `postfix`, `postfix-sasl`, `postfix-rbl`,
|
||||
`dovecot`, `sieve` (and the Apache ones once §4 is done) in
|
||||
`/etc/fail2ban/jail.local`, all watching `/var/log/mail.log` /
|
||||
`/var/log/auth.log` / `/var/log/apache2/*.log` as appropriate.
|
||||
|
||||
**Widen `sshd`'s `findtime` beyond the 30-minute default.** Default
|
||||
`findtime=30m`/`maxretry=3` only catches bursts — a bot that spaces login
|
||||
attempts ~33 minutes apart never accumulates 3 strikes inside any 30-minute
|
||||
window and is never banned at all (seen live on this host against
|
||||
`47.76.192.176`; check `fail2ban-client status sshd` and grep
|
||||
`/var/log/fail2ban.log` for `Found`/`Ban` counts per IP if a repeat
|
||||
offender never shows up banned). Fix in its own drop-in rather than
|
||||
editing the packaged jail file:
|
||||
```
|
||||
# /etc/fail2ban/jail.d/sshd-findtime.local
|
||||
[sshd]
|
||||
findtime = 1d
|
||||
maxretry = 4
|
||||
bantime = 1d
|
||||
```
|
||||
`fail2ban-client reload sshd` applies it. Worth spot-checking the other
|
||||
jails' `Found`/`Ban` ratios the same way — a *distributed* variant (many
|
||||
different source IPs, each trying once) evades per-IP banning by design
|
||||
and needs subnet-level blocking instead, which `findtime` tuning can't fix.
|
||||
|
||||
### Bring it up
|
||||
|
||||
```bash
|
||||
sudo systemctl restart mariadb redis-server rspamd dovecot postfix
|
||||
sudo postfix check && sudo postconf -n # sanity check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Web (Apache + Let's Encrypt)
|
||||
|
||||
```bash
|
||||
sudo apt install -y apache2 certbot python3-certbot-apache libapache2-mod-php7.4
|
||||
sudo a2enmod ssl proxy proxy_http rewrite headers
|
||||
```
|
||||
|
||||
For each subdomain (static site, dyndns, mail webmail-proxy, any
|
||||
Docker-backed app): create `/etc/apache2/sites-available/<name>.conf` with
|
||||
a `:80` vhost, `a2ensite`, then let certbot handle TLS + the redirect:
|
||||
|
||||
```bash
|
||||
sudo certbot --apache -d <subdomain> --non-interactive --agree-tos \
|
||||
-m webmaster@jayfield.org --redirect
|
||||
```
|
||||
This creates the `-le-ssl.conf` SSL vhost, injects the redirect into the
|
||||
`:80` block, and registers the cert with `certbot.timer` for auto-renewal.
|
||||
|
||||
**Add HSTS to every real domain's SSL vhost** (skip the self-signed
|
||||
`default-ssl.conf` catch-all — it has no real trusted hostname for a
|
||||
browser to pin):
|
||||
```
|
||||
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||
```
|
||||
|
||||
**Pattern for any Docker-backed subdomain** (see §5): publish the
|
||||
container to `127.0.0.1:<port>` only, never a public interface. The Apache
|
||||
vhost does `ProxyPass / http://127.0.0.1:<port>/` and
|
||||
`ProxyPassReverse` to match, plus (if the app needs to know it's behind
|
||||
TLS) `RequestHeader set X-Forwarded-Proto "https"`.
|
||||
|
||||
**Give every vhost its own `:80` block**, even if it just redirects — a
|
||||
domain with no `:80` vhost of its own silently falls through to whichever
|
||||
vhost Apache treats as default, which may redirect to the *wrong* domain.
|
||||
This host learned that the hard way with `jayfield.org`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker layer
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker jens
|
||||
```
|
||||
|
||||
### Portainer
|
||||
|
||||
```bash
|
||||
sudo docker run -d --name portainer --restart=always \
|
||||
-p 8000:8000 -p 9443:9443 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v portainer_data:/data \
|
||||
portainer/portainer-ce:latest
|
||||
```
|
||||
`https://<host>:9443` for the UI. `/var/run/docker.sock` access means
|
||||
anyone with Portainer access effectively has root on the host — treat its
|
||||
credentials accordingly.
|
||||
|
||||
### Any future container-backed site (e.g. the `web` nginx container)
|
||||
|
||||
```bash
|
||||
sudo docker run -d --name <name> --restart=always \
|
||||
-p 127.0.0.1:<port>:80 \
|
||||
<image>
|
||||
```
|
||||
Then wire it into Apache per §4's pattern.
|
||||
|
||||
### Nextcloud (data-hosting-focused, minimal — core Files app only)
|
||||
|
||||
```bash
|
||||
sudo docker network create nextcloud_net
|
||||
|
||||
sudo mkdir -p /srv/nextcloud/data
|
||||
sudo chown 33:33 /srv/nextcloud/data # uid 33 = www-data, both on host and in the image
|
||||
sudo chmod 750 /srv/nextcloud/data
|
||||
|
||||
sudo docker run -d --name nextcloud-db --network nextcloud_net --restart=always \
|
||||
-e MYSQL_ROOT_PASSWORD="$(openssl rand -hex 24)" \
|
||||
-e MYSQL_DATABASE=nextcloud -e MYSQL_USER=nextcloud \
|
||||
-e MYSQL_PASSWORD="<generated>" \
|
||||
-v nextcloud_db:/var/lib/mysql \
|
||||
mariadb:lts
|
||||
|
||||
sudo docker run -d --name nextcloud-redis --network nextcloud_net --restart=always \
|
||||
-v nextcloud_redis:/data redis:alpine
|
||||
|
||||
sudo docker run -d --name nextcloud --network nextcloud_net --restart=always \
|
||||
-p 127.0.0.1:8082:80 \
|
||||
-v nextcloud_html:/var/www/html \
|
||||
-v /srv/nextcloud/data:/var/www/html/data \
|
||||
-e MYSQL_HOST=nextcloud-db -e MYSQL_DATABASE=nextcloud \
|
||||
-e MYSQL_USER=nextcloud -e MYSQL_PASSWORD="<same as above>" \
|
||||
-e NEXTCLOUD_ADMIN_USER=<user> -e NEXTCLOUD_ADMIN_PASSWORD="$(openssl rand -base64 18)" \
|
||||
-e NEXTCLOUD_TRUSTED_DOMAINS=cloud.jayfield.org \
|
||||
-e TRUSTED_PROXIES=127.0.0.1 -e OVERWRITEPROTOCOL=https \
|
||||
-e REDIS_HOST=nextcloud-redis \
|
||||
nextcloud:apache
|
||||
|
||||
sudo docker exec -u www-data nextcloud php occ background:cron
|
||||
(sudo crontab -l 2>/dev/null; echo '*/5 * * * * docker exec -u www-data nextcloud php -f /var/www/html/cron.php > /dev/null 2>&1') | sudo crontab -
|
||||
```
|
||||
**Use dedicated `nextcloud-db`/`nextcloud-redis` containers, not the
|
||||
host's own MariaDB/Redis.** The host's MySQL already serves the mail
|
||||
stack's `vmail` database — keep app data separate from mail data. The
|
||||
host's `redis-server` is bound to loopback with no auth for rspamd's
|
||||
benefit; reusing it for Nextcloud would mean loosening a shared service's
|
||||
exposure just to save one small container.
|
||||
|
||||
Set the DNS `A` record and Apache vhost per §2/§4 before running
|
||||
`certbot --apache -d cloud.jayfield.org`.
|
||||
|
||||
### Per-user password vaults (KeePass, synced via Nextcloud WebDAV)
|
||||
|
||||
Once Nextcloud is up, each admin/family member gets a matching Linux
|
||||
user and Nextcloud account, and their KeePass `.kdbx` is placed directly
|
||||
in their Nextcloud storage so it syncs over Nextcloud's own WebDAV —
|
||||
no separate Apache-WebDAV or SFTP-upload pipeline needed.
|
||||
|
||||
```bash
|
||||
# 1. Linux user (if they don't already have one)
|
||||
sudo useradd -m -s /bin/bash <user>
|
||||
|
||||
# 2. Nextcloud account — occ has no interactive TTY over a plain `docker exec`
|
||||
# from an automated shell, so generate the password non-interactively:
|
||||
PW=$(openssl rand -base64 18)
|
||||
sudo docker exec -e OC_PASS="$PW" -u www-data nextcloud php occ user:add --password-from-env <user>
|
||||
echo "$PW" # hand this to the user, then have them change it on first login
|
||||
|
||||
# 3. Drop the vault file into their Files root and pick it up
|
||||
sudo mkdir -p /srv/nextcloud/data/<user>/files
|
||||
sudo cp <source>.kdbx /srv/nextcloud/data/<user>/files/sicher_<user>.kdbx
|
||||
sudo chown -R www-data:www-data /srv/nextcloud/data/<user>
|
||||
sudo chmod 750 /srv/nextcloud/data/<user>
|
||||
sudo chmod 640 /srv/nextcloud/data/<user>/files/sicher_<user>.kdbx
|
||||
sudo docker exec -u www-data nextcloud php occ files:scan <user>
|
||||
```
|
||||
Point KeePass (or any WebDAV-aware sync tool) at
|
||||
`https://cloud.jayfield.org/remote.php/dav/files/<user>/sicher_<user>.kdbx`,
|
||||
authenticating with the Nextcloud account from step 2. Sanity-check a new
|
||||
setup with a raw WebDAV round trip before trusting it:
|
||||
```bash
|
||||
curl -u '<user>:<password>' -X PROPFIND \
|
||||
https://cloud.jayfield.org/remote.php/dav/files/<user>/sicher_<user>.kdbx \
|
||||
-H 'Depth: 0' # expect HTTP 207
|
||||
```
|
||||
Also keep a `600`-permission copy in the user's own home directory
|
||||
(`/home/<user>/sicher_<user>.kdbx`) as a local backup outside Nextcloud.
|
||||
|
||||
If an old SFTP-upload path is being retired in favor of this, lock the
|
||||
corresponding chrooted account rather than deleting it —
|
||||
`sudo passwd -l <sftp-user>` refuses login while leaving the account,
|
||||
chroot, and any files recoverable (`passwd -u` reverses it).
|
||||
|
||||
---
|
||||
|
||||
## 6. Post-install hardening checklist
|
||||
|
||||
Run through this after everything above is live:
|
||||
|
||||
- [ ] `dig CH TXT version.bind @<host>` returns `"unknown"`, not a real version
|
||||
- [ ] `named.conf.options` has a `rate-limit {}` block
|
||||
- [ ] TSIG key files (if any) are `640 root:bind`, never world-readable
|
||||
- [ ] Every real HTTPS vhost sends `Strict-Transport-Security`
|
||||
- [ ] Every vhost has its own `:80` redirect — none silently inherit the default
|
||||
- [ ] `fail2ban-client status` shows jails for `sshd`, `postfix*`, `dovecot`, `apache-auth`
|
||||
- [ ] `postconf -n | grep relay_restrictions` shows `reject_unauth_destination` — confirm you are not an open relay (test from an external host)
|
||||
- [ ] SPF, DKIM, and DMARC (`p=reject` or at least `p=quarantine`) TXT records are live and verified against a real send (check `Authentication-Results` on a delivered test message)
|
||||
- [ ] No `forwarders {}` in BIND, and `systemd-resolved`'s `DNS=` points at `127.0.0.1` — verify with `dig @127.0.0.1 <any domain>` and a spot-check DNSBL query for a known-good IP, confirming it doesn't return `127.255.255.254`
|
||||
- [ ] Disk partition actually uses the full disk (`df -h /` vs. `lsblk`/`parted ... print free` should agree)
|
||||
- [ ] `PermitRootLogin no` / `PasswordAuthentication no` — verify live with
|
||||
`sshd -T`, not just the config file (see §1)
|
||||
- [ ] `fail2ban-client status sshd` findtime is wide enough to catch
|
||||
low-and-slow attempts, not just bursts (see §3) — spot-check
|
||||
`/var/log/fail2ban.log` for any repeat-offender IP with several
|
||||
`Found` entries but no `Ban`
|
||||
- [ ] Any chrooted SFTP accounts (`/var/sftp/*`) no longer in active use are
|
||||
password-locked (`sudo passwd -S <user>` shows `L`), not left active
|
||||
|
||||
## 7. End-to-end verification
|
||||
|
||||
Send a real test email from an external provider (Gmail, GMX, etc.) to a
|
||||
real mailbox and confirm in `/var/log/mail.log`:
|
||||
```
|
||||
postscreen[...]: PASS NEW [<sender IP>]
|
||||
...
|
||||
dovecot: lmtp(user@yourdomain)<...>: sieve: msgid=<...>: stored mail into mailbox 'INBOX'
|
||||
```
|
||||
Then reply from that mailbox and confirm the outbound leg:
|
||||
```
|
||||
postfix/submission/smtpd[...]: sasl_method=PLAIN, sasl_username=user@yourdomain
|
||||
postfix/smtp[...]: to=<...>, relay=<remote MX>, status=sent
|
||||
```
|
||||
This is the same verification loop that caught the DNSBL false-positive
|
||||
bug documented in `README.md` — a real send-and-check beats trusting the
|
||||
config on paper.
|
||||
Reference in New Issue
Block a user