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:
2026-07-19 23:09:27 +02:00
co-authored by Claude Sonnet 5
commit 92d871f2da
6 changed files with 1583 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
# alpha.jayfield.org — Diff vs. a Clean Ubuntu 22.04 Install
Generated 2026-07-19. Methodology and caveats up front; findings below,
organized in three tiers of decreasing mechanical certainty and increasing
manual judgment.
**Update, same day**: three of the findings below (RTMP/`nginx`, the
`wireguard` tunnel, and `samba`/`cifs-utils`) were removed shortly after
this diff was run — see the annotations inline and `README.md`'s known
quirks for the current state. Left in place here as a record of what was
found and why it was removed; this file is a point-in-time investigation,
not a live inventory (`README.md` is the live inventory).
**Baseline used**: the official `jammy-server-cloudimg-amd64` manifest
(`cloud-images.ubuntu.com/jammy/current/`) for package comparisons, and
freshly-downloaded `.deb`s from the exact same Ubuntu archive this host
uses (matching installed versions exactly — e.g. `bind9 9.18.39-0ubuntu0.22.04.4`,
`postfix 3.6.4-1ubuntu1.4`) for config-file comparisons. One honest caveat:
several signs suggest this host was actually provisioned from the
interactive Server ISO installer, not the cloud image (`installation-report`,
`tasksel`, `popularity-contest`, and `language-pack-en` are all manually
installed — none of these appear on a cloud-init-provisioned instance).
The cloud image manifest is still the best available reference point, but
treat tier 1's line count as approximate, not exact.
---
## Tier 1 — Package diff
968 packages installed vs. 600 in the baseline manifest. Most of that gap
is transitive dependencies (installing `apache2` alone pulls in a dozen
supporting libraries nobody explicitly chose). Filtering to packages that
are both (a) not in the baseline and (b) marked `apt-mark showmanual`
(i.e. an actual administrative decision, not a dependency) gives **122
packages** — the real, meaningful list. Grouped by what they're for:
| Purpose | Packages |
|---|---|
| DNS | `bind9` |
| Mail | `postfix`, `postfix-mysql`, `dovecot-core`, `dovecot-imapd`, `dovecot-lmtpd`, `dovecot-managesieved`, `dovecot-mysql`, `dovecot-sieve`, `rspamd`, `mailutils`, `libmailutils6` |
| Database | `mariadb-server` (+ `mariadb-server-10.3`/`galera-3` — stale leftovers, see note below) |
| Web | `apache2`, `php8.0` + extensions, `libapache2-mod-php7.4`, `libapache2-mod-php8.0`, ~~`nginx`~~ (see Tier 2 — was **not** a web server here; **removed** same day, was unused RTMP) |
| TLS | `certbot`, `python3-certbot-apache` |
| Docker | `docker-ce`, `docker-ce-cli`, `docker-buildx-plugin`, `docker-compose-plugin`, `containerd.io` |
| Security | `fail2ban` |
| File sharing | ~~`samba`, `cifs-utils`~~ (→ the `vlda-01` CIFS mount; **removed** same day along with the dead tunnel below) |
| **VPN — not previously documented** | ~~`wireguard`~~ (**removed** same day — dormant, root-caused the CIFS failure, config backed up to `/root/removed-configs-backup/`) |
| Utilities | `at`, `net-tools`, `unzip`, `lz4`, `ltrace`, `bsdmainutils`, `resolvconf`, `qemu-guest-agent` |
| ISO-installer artifacts | `installation-report`, `tasksel`, `popularity-contest`, `language-pack-en`, `language-selector-common` |
**Stale/leftover packages worth a cleanup pass**: `mariadb-server-10.3`,
`mariadb-client-10.3`, `galera-3`, `libssl1.1`, `libicu66`, `php8.0` (vs.
the active `php7.4` module), `libperl5.30` — all remnants of the
18.04→20.04→22.04 upgrade history already noted in `README.md` (same
lineage as the `python3.8`/certbot issue documented there). Not actively
harmful, just disk/attack-surface bloat from packages no longer in active
use. Candidate for a future `apt autoremove` pass — worth checking what's
actually still linked against before purging.
**24 packages "missing" vs. baseline are not removals** — they're
expected absences for a BIOS-booted QEMU VM with no physical hardware:
`grub-efi-*`, `shim-signed`, `mokutil`, `efibootmgr` (this host boots via
legacy BIOS/`bios_grub`, confirmed in the disk-resize work — never needed
EFI tooling), plus `libtss2-*` (TPM), `fuse3`/`libntfs-3g89` (physical
filesystem drivers), `systemd-hwe-hwdb` (hardware enablement) — none of
this is relevant to a cloud VM regardless of who administers it.
---
## Tier 2 — Config file diff (live file vs. pristine package default)
Real `diff -u` output against genuinely pristine files pulled from the
matching package version in Ubuntu's archive — not reconstructed from
memory. Only the files with a real static default to diff against are
listed; Postfix's `main.cf`/`master.cf` and Dovecot's `dovecot.conf` are
**not** static conffiles (Postfix generates them via debconf at install
time; Dovecot's shipped file is a first-install template only), so "diff"
for those is a semantic comparison, covered in `SETUP.md` instead.
### `/etc/bind/named.conf.options`
Exactly what's documented in `TODO.md`/`README.md``version "unknown"`,
the `rate-limit` block, and the `forwarders` block removed in favor of
full recursion (with an inline comment explaining why). Plus pre-existing
(2022) additions: `allow-transfer`, `allow-query { any; }`,
`allow-recursion { trusted; }`, `querylog yes`, the `trusted` ACL.
### `/etc/ssh/sshd_config`
- `Port 10022` (from `#Port 22`)
- `PasswordAuthentication yes` and `PermitRootLogin yes` **explicitly set**
the shipped default leaves both commented out (relying on OpenSSH's own
safer compiled-in defaults). Flagged already in `SETUP.md` §1 as a
hardening gap worth reconsidering.
- `ChallengeResponseAuthentication no` — uses the pre-8.7 directive name;
current templates use `KbdInteractiveAuthentication`. Cosmetic, but a
dating signal (this file predates a later OpenSSH directive rename).
- **New finding**: an entire `Match Group sftp` block — chrooted,
no-shell SFTP-only access (`ChrootDirectory /var/sftp/%u`, tunneling and
forwarding all disabled). Three accounts use it: `ftpuser`, `ftp_jens`,
`ftp_alex` (`/var/sftp/{ftpuser,ftp_jens,ftp_alex}/`). This is a
legitimate, separate access mechanism from `jens`'s full shell account —
not documented anywhere before this diff.
### `/etc/systemd/resolved.conf`
Exactly the `DNS=127.0.0.1` change documented in `README.md`'s resolver
section, plus pre-existing `DNSSEC=allow-downgrade`, `LLMNR=no`, etc.
### `/etc/apache2/apache2.conf`
- `<Directory /var/www>`: `AllowOverride all` (default: `None`) — permits
`.htaccess` files to override server config anywhere under `/var/www`.
Broader than the stock posture; worth checking whether any `.htaccess`
files actually exist and rely on this before tightening it.
- A custom `fail2ban` `LogFormat` — ties into the `apache-auth` jail.
- **New finding, worth a closer look**: a global block appended at the
end of the file —
```
<FilesMatch "\.*">
SetHandler application/x-httpd-php
</FilesMatch>
```
The regex `\.* ` (zero or more literal dots, unanchored) matches nearly
any filename, including ones with zero dots. Applied globally (outside
any `<Directory>` scope), this routes essentially all requests through
the PHP handler, not just `.php` files. Whether this has real
consequences depends on what mod_php does with non-PHP content (likely
passes it through as literal text), but it's a broad, unusual, and
previously-undocumented directive — worth understanding the original
intent before deciding whether to scope it down to `\.php$`.
### `/etc/fail2ban/jail.conf`
This host edits `jail.conf` directly rather than using `jail.local` for
overrides — the intended pattern is to leave the vendor file untouched
and override in `jail.local` so future package upgrades don't silently
clobber local changes. Real customizations found: `bantime` 10m→**4h**,
`findtime` 10m→**30m**, `maxretry` 5→**3**, `port = 10022` on the
`sshd`/`dropbear`/`selinux-ssh` jails (matching the moved SSH port),
`sender = root@jayfield.org`, and several jails explicitly `enabled = true`
(`sshd`, `postfix`, `postfix-rbl`, `dovecot`, `sieve`, `pam-generic`, and
others) that ship disabled by default. The remaining diff noise (action-
syntax `%(banaction)s[...]` reformatting, comment typo fixes) is from
fail2ban's own upstream template evolving between minor releases, not a
local change.
### `/etc/nginx/nginx.conf` — **removed 2026-07-19, same day as this diff**
Confirmed Tier 1's finding cleanly: the entire `http {}` block from the
stock config was **removed** — no web serving capability at all — and
`include /etc/nginx/rtmp.conf;` was the only addition. This nginx instance
existed solely to run `libnginx-mod-rtmp` as a live-streaming ingest server
(`rtmp.conf`: `listen 1935; application live { live on; record off; }`,
no publish authentication configured). Entirely separate from the
Docker `web` (nginx) container and from Apache — no overlap, no conflict,
just a second, unrelated nginx process that had gone uncatalogued. No
active connections or recent use found before removal; purged
(`nginx nginx-core nginx-common libnginx-mod-rtmp`) along with `/etc/nginx`.
### `/etc/apache2/ports.conf`
No diff — matches stock exactly (Apache's own `Listen 80`/mod_ssl's
`Listen 443` addition).
---
## Tier 3 — Changes with no package-level "default" to diff against
Not mechanically diffable — either data (not config), or infrastructure
this host's package manager doesn't track at all.
- **Disk partitioning**: root partition was 9.7GB of a 172GB disk for
years (see `README.md`) — grown to 157GB this session via
`growpart`/`resize2fs`. No "default" partition layout to diff against;
this is provisioning-time state.
- **DNS zone content** (`/etc/bind/db.jayfield.org`): all records —
there's no baseline zone file to diff against, it's 100% custom data.
- **MySQL `vmail` database**: schema + data for mail accounts/aliases/domains
(see `SETUP.md` §3 for the schema). Data, not config.
- **TLS certificates**: 7 Let's Encrypt certs, all custom to this domain.
- **Docker layer**: `portainer`, `web` (nginx), and the Nextcloud stack
(`nextcloud`, `nextcloud-db`, `nextcloud-redis`) — none of this exists
in a base install; it's entirely additive infrastructure documented in
`README.md`.
- **WireGuard — removed 2026-07-19, same day as this diff**
(`/etc/wireguard/alpha_at_moon.conf`): a client tunnel named
"alpha at moon" — `Address = 10.8.0.8/24`, peer endpoint
`vpn.dyndns.jayfield.org:51820`, `AllowedIPs = 10.8.0.0/24,
192.168.22.0/24`. Was inactive (`wg-quick@wg0` not running) at the time
of this diff. Almost certainly *why* the `mnt-vlda-01.mount` failure
documented in `README.md` happened — `vlda-01` (the CIFS target) was
presumably only reachable via this same `192.168.22.0/24` range.
Decision made: rather than reviving the tunnel, removed it along with
the dead CIFS mount and unused Samba packages. Config backed up to
`/root/removed-configs-backup/alpha_at_moon.conf.bak` on the server
(`600 root:root`) in case the tunnel is wanted again later.
- **SFTP chroot jail** (`/var/sftp/`): three accounts (`ftpuser`,
`ftp_jens`, `ftp_alex`) in the `sftp` group, each chrooted to their own
directory with no shell — a controlled file-drop mechanism separate
from `jens`'s admin SSH access. Purpose/owners of these accounts not
established from config alone.
- **Users, groups, sudoers**: not diffed — no baseline "clean install"
user set to compare against beyond the default `ubuntu`/first-boot user,
which this host doesn't appear to still have.
- **Cron jobs**: `certbot renew` (weekly), Nextcloud's `cron.php` (every
5 min) — both documented in `README.md`, both additive, no baseline.
---
## Summary: what this diff actually surfaced
Beyond confirming the changes already tracked in `README.md`/`TODO.md`,
this exercise found **four previously-undocumented things**:
1. ~~A live RTMP streaming server (`nginx` + `libnginx-mod-rtmp` on
`:1935`)~~ — **removed** same day; unused, no auth on the publish
endpoint, no reason found to keep it.
2. ~~A dormant WireGuard tunnel to a home network~~ — **removed** same
day, along with the CIFS mount it was almost certainly gating and the
Samba packages that supported it. Config backed up first.
3. A chrooted SFTP-only access path for three accounts, separate from the
main admin login. Left as-is — appears to be legitimate, ongoing
infrastructure, not a stray leftover.
4. Two Apache config items worth a second look: `AllowOverride all` on
`/var/www`, and a broad `<FilesMatch "\.*">` PHP handler that's wider
than it probably needs to be. Still open — not yet addressed.
+122
View File
@@ -0,0 +1,122 @@
# alpha.jayfield.org — Adding Email Accounts (How-To)
Accounts, aliases, and domains for the mail stack (Postfix + Dovecot) all
live in one place: the `vmail` MySQL database on this host. There's no
admin UI — everything below is a `mysql` insert plus one hash-generation
command. See `SETUP.md` §3 for how this schema was built in the first
place; this doc is just the day-to-day "add a mailbox" recipe.
**No service restart is ever needed for account changes.** Postfix and
Dovecot query MySQL live on every delivery/login (`auth_cache_size` is
unset/disabled on this host — verified in `/etc/dovecot/conf.d/10-auth.conf`)
so a new row is usable immediately.
## 1. Prerequisite: the domain must already exist
```bash
sudo mysql vmail -e "SELECT * FROM domains;"
```
Currently just `jayfield.org`. If you ever need a second domain, insert it
here first (`INSERT INTO vmail.domains (domain) VALUES ('newdomain.tld');`)
— but note that's the *easy* part; a real second domain also needs its own
DNS zone/MX/SPF/DMARC records and DKIM selector (see `SETUP.md` §2/§3), a
TLS cert covering it, and its own `mynetworks`/`inet_interfaces`
consideration. Out of scope for just adding a mailbox — this doc assumes
`jayfield.org`.
## 2. Generate the password hash
Never put a plaintext password in the `accounts` table — hash it first:
```bash
sudo doveadm pw -s SHA512-CRYPT
```
(You'll be prompted twice, interactively — don't pass the password as a
CLI argument, it'd land in shell history.) Copy the full
`{SHA512-CRYPT}$6$...` output; that's the exact string that goes in the
`password` column.
You may see `Error: net_connect_unix(/run/dovecot/stats-writer) failed:
Permission denied` printed before the hash — that's `doveadm` trying to
report stats, unrelated to the hash it prints right after; harmless, not a
sign the hash is wrong.
## 3. Insert the account
```bash
sudo mysql vmail -e "INSERT INTO accounts (username, domain, password, quota, enabled, sendonly) VALUES ('newuser', 'jayfield.org', '{SHA512-CRYPT}\$6\$...', 2048, 1, 0);"
```
- `quota` is in **MB** (existing accounts use `2048` = 2GB — match that
unless there's a reason not to).
- `enabled = 1` — required for the account to authenticate or receive mail
at all. Setting it to `0` disables the account without deleting it (see
§6).
- `sendonly = 0` — normal mailbox: can send *and* receive. Set to `1` only
for a send-only address that should never accept incoming mail (see §5).
That's it — no mailbox directory to create by hand. Dovecot auto-creates
`/var/vmail/mailboxes/jayfield.org/<user>/mail/` (owned `vmail:vmail`,
`700`) the first time the account logs in or receives mail.
## 4. Client configuration (no webmail — this stack is IMAP/SMTP only)
| Setting | Value |
|---|---|
| IMAP (recommended) | `mail.jayfield.org` : `993`, implicit TLS |
| IMAP (STARTTLS) | `mail.jayfield.org` : `143` |
| SMTP submission | `mail.jayfield.org` : `587`, STARTTLS + SASL (PLAIN) auth required |
| Username | `<user>@jayfield.org` (full address, not just the local part) |
| Password | the plaintext password you hashed in §2 |
| Port 25 | **not for clients** — server-to-server only, no SASL, no relay (`reject_unauth_destination`) |
`disable_plaintext_auth = yes` is set — clients must actually negotiate TLS
first; a client configured for plaintext IMAP/SMTP on the wrong port will
just fail to auth, not silently send credentials in the clear.
## 5. Send-only accounts (`sendonly = 1`)
Postfix's `recipient-access.cf` query rejects any inbound mail to a
`sendonly=1` address at `RCPT TO` time (`450`/`550` before it's even
accepted), while the account can still authenticate via SASL on `587` and
send outbound. Use this for something like a monitoring/alerting sender
address that should never actually receive replies.
## 6. Aliases (forward one address to another mailbox)
Aliases don't need their own mailbox or password — they're a pure
redirect, resolved at `RCPT TO` time via `aliases.cf`:
```bash
sudo mysql vmail -e "INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) VALUES ('sales', 'jayfield.org', 'jens', 'jayfield.org', 1);"
```
This host already has `postmaster`/`hostmaster`/`webmaster`/`wlan`
`jens` as standing aliases (RFC-required roles + a house one) — check
`SELECT * FROM aliases;` before adding a new one to avoid duplicates.
## 7. Disabling / removing an account
Prefer disabling over deleting — it's reversible and doesn't touch the
Maildir:
```bash
sudo mysql vmail -e "UPDATE accounts SET enabled = 0 WHERE username = 'olduser' AND domain = 'jayfield.org';"
```
This blocks both authentication and inbound delivery immediately (both
`accounts.cf` and `recipient-access.cf` filter on `enabled = true`). The
actual mail data stays on disk at
`/var/vmail/mailboxes/jayfield.org/olduser/` until you decide to remove it
by hand — nothing auto-deletes it.
## 8. Verify a new account end-to-end
Don't trust the INSERT alone — confirm both directions actually work,
same discipline as `SETUP.md` §7:
```bash
# IMAP auth works
doveadm auth test newuser@jayfield.org
# Watch a real inbound + outbound test the same way §7 of SETUP.md describes:
# send TO newuser@jayfield.org from an external provider, confirm in
# /var/log/mail.log:
# dovecot: lmtp(newuser@jayfield.org)<...>: sieve: msgid=<...>: stored mail into mailbox 'INBOX'
# then send FROM the new account via a real mail client on 587, confirm:
# postfix/submission/smtpd[...]: sasl_method=PLAIN, sasl_username=newuser@jayfield.org
```
+322
View File
@@ -0,0 +1,322 @@
# alpha.jayfield.org — Web Services Overview
Host: `alpha.jayfield.org` (`89.58.8.149`), Ubuntu 22.04 LTS.
SSH: `ssh -p 10022 jens@alpha.jayfield.org`
All administration in this repo's notes is performed remotely against
this host over SSH (port `10022`) — there is no local checkout of any
service config; changes are made directly on the server and recorded
here afterward.
This host runs two layers of web services: native services managed by
system Apache, and containerized services managed by Docker (visible in
Portainer). Apache reverse-proxies into the Docker layer where needed.
## Native services (systemd / apt)
| Service | Role |
|---|---|
| `apache2` (2.4.52) | Terminates HTTP/HTTPS for all vhosts, reverse-proxies to backends (Docker containers, rspamd, Portainer) |
| `bind9` | Authoritative DNS for the `jayfield.org` zone (`ns1`/`ns2.jayfield.org` are both this host); also the box's own recursive resolver — see below |
| `docker` | Runs the containerized web services (see below) |
| `certbot` (apt, 1.21.0) | Let's Encrypt cert issuance/renewal, via `certbot.timer` (systemd) |
| Postfix (`master`) | SMTP, ports 25/587, on the public IP + localhost |
| Dovecot | IMAP/IMAPS (143/993) + ManageSieve (4190) |
| rspamd | Spam filtering for the mail stack, localhost-only (1133211334), proxied at `mail.jayfield.org/rspamd` |
| redis-server | Localhost-only (6379); backs rspamd's Bayes classifier, history, and reputation caching |
Python: `python3.10.12` (fixed from a stalled `python3.8` leftover of an
incomplete 20.04→22.04 upgrade — this was breaking apt's certbot via a
`pytz`/`zoneinfo` incompatibility).
## System users
| User | uid | Home | Purpose |
|---|---|---|---|
| `jens` | 1001 | `/home/jens` | Primary sudo admin account — all administration in this repo is done as this user over SSH |
| `alex` | 1005 | `/home/alex` | Secondary, non-sudo personal account (added 2026-07-19) |
Both also exist as separate Nextcloud accounts (see "Password vault sync"
below) and as chrooted, SFTP-only accounts `ftp_jens`/`ftp_alex` under
`/var/sftp/` — the latter currently password-locked, not deleted (see
below).
## DNS resolver architecture
Fixed 2026-07-19: the box's own outbound DNS resolution (used by Postfix's
`postscreen` for DNSBL lookups, and by every other service) previously
forwarded through the hosting provider's shared resolvers
(`46.38.225.230`/`46.38.252.230`). Confirmed via a live test: GMX's own
mail servers (`212.227.15.15`/`.19`, verified via reverse DNS as
`mout.gmx.net`) were being **dropped by `postscreen` on a false positive**
— Spamhaus's DNSBL was returning `127.255.255.254`, its documented
rate-limit sentinel ("this querying resolver has exceeded our free-tier
volume"), not a real listing. The shared resolver was almost certainly
pooling query volume from other tenants of the same hosting provider,
pushing it over Spamhaus's threshold.
Fix:
- Removed the `forwarders {}` block from `named.conf.options``bind9`
now does full iterative/recursive resolution itself (root hints →
TLD → authoritative) for `trusted` clients (`localhost` + `localnets`)
instead of relaying to the shared upstream.
- Repointed `/etc/systemd/resolved.conf`'s `DNS=` from the shared
provider IPs to `127.0.0.1` (i.e. `bind9`), then `systemctl restart
systemd-resolved`. The `127.0.0.53` stub listener and `/etc/resolv.conf`
symlink were left untouched, so Docker's DNS-detection logic (which
special-cases the stub) is unaffected.
- (An `unbound` install was tried first as a separate local resolver, but
`bind9` already listens on `127.0.0.1:53`/`[::1]:53``listen-on-v6 {
any; }` plus BIND's default of listening on all IPv4 interfaces — so
they couldn't coexist on the same port. Reusing `bind9` for both roles
was simpler than fighting that conflict.)
Verified: both GMX IPs now correctly resolve as **not listed**;
`postscreen` now `PASS`es them. A real end-to-end test email
("Claude email test #01") delivered successfully to both `jens@` and
`otto2022@jayfield.org` after the fix.
**Known caveat**: direct recursive resolution occasionally hits a single
slow/unresponsive server somewhere in a delegation chain, where a
well-provisioned forwarding resolver's warm cache usually wouldn't. Seen
once during testing — a transient PTR lookup timeout caused one retry to
soft-fail (`450`, not `550`) before a second retry succeeded seconds
later. Not expected to be a recurring problem, but worth knowing if a
one-off `450 4.7.25 Client host rejected` shows up in `mail.log`.
## Apache virtual hosts
| ServerName | :80 | :443 | Notes |
|---|---|---|---|
| `dyndns.jayfield.org` | → redirect to :443 | Basic Auth (`Restricted Content`), `DocumentRoot /var/www/dyndns` (PHP) | Also the effective **default vhost** for unmatched hostnames (e.g. bare `alpha.jayfield.org` falls through here) |
| `jayfield.org` | → redirect to :443 | Static site (`/var/www/html`) | Previously had no `:80` vhost of its own — fell through to the `dyndns` default and redirected to the wrong domain; fixed |
| `www.jayfield.org` | → redirect to :443 | Static site (`/var/www/html`) | Renewal fixed to use the `apache` certbot plugin (was `standalone`, which required stopping Apache) |
| `mail.jayfield.org` | → redirect to :443 | Webmail-adjacent, proxies `/rspamd``http://localhost:11334/` | |
| `web.jayfield.org` | → redirect to :443 | Reverse-proxies to the **Docker `web` (nginx) container** on `127.0.0.1:8081` | New — see below |
| `cloud.jayfield.org` | → redirect to :443 | Reverse-proxies to the **Docker `nextcloud` container** on `127.0.0.1:8082`, sets `X-Forwarded-Proto: https` | Nextcloud — see below |
| default (`_default_:443`, `000-default` on :80) | Apache's stock placeholder page | — | |
All Let's Encrypt certs (7 total, one per hostname/group) auto-renew via
`certbot.timer`; verified with `certbot renew --dry-run`.
All 6 domain `:443` blocks send `Strict-Transport-Security: max-age=63072000;
includeSubDomains` (`mod_headers`). Deliberately skipped on the
`default-ssl.conf` catch-all — it's the self-signed fallback for unmatched
SNI/Host headers, not a real trusted hostname a browser could pin.
## Docker services (visible in Portainer)
| Container | Image | Ports | Purpose |
|---|---|---|---|
| `portainer` | `portainer/portainer-ce:2.39.5` | `8000` (edge-agent tunnel, published), `9443` (HTTPS UI, published), `9000` (UI, internal only) | Docker management UI — `https://alpha.jayfield.org:9443` |
| `web` | `nginx:latest` | `127.0.0.1:8081` (localhost-only, not published externally) | Backing service for `web.jayfield.org`; only reachable through the Apache reverse proxy |
| `nextcloud` | `nextcloud:apache` | `127.0.0.1:8082` (localhost-only) | Nextcloud app (PHP+Apache bundled in the image); backing service for `cloud.jayfield.org` |
| `nextcloud-db` | `mariadb:lts` | internal only (`nextcloud_net`) | Dedicated DB for Nextcloud — kept separate from the host's own MySQL (which only holds `vmail`) |
| `nextcloud-redis` | `redis:alpine` | internal only (`nextcloud_net`) | File locking + distributed cache + PHP session storage for Nextcloud. Dedicated container rather than reusing the host's native `redis-server`, since that instance is bound to `127.0.0.1`/`::1` only with no auth — reusing it would have meant loosening a shared service's network exposure just to save one small container |
Data persists in the `portainer_data` named volume, independent of the
container itself — updating Portainer means recreating the container
against a freshly pulled image while keeping this volume and the
`/var/run/docker.sock` bind mount; the old container is kept renamed
(not deleted) until the new one is verified healthy.
Two early-test containers (`my-apache-app`, a stopped `httpd`, and
`clever_jepsen`, a stale `hello-world` container from initial Docker
setup) have been removed as cleanup.
### `web.jayfield.org` request flow
```
Internet → Apache :80/:443 (web.jayfield.org vhost)
→ Let's Encrypt TLS termination
→ ProxyPass http://127.0.0.1:8081/
→ Docker container "web" (nginx) on the docker0 bridge
```
The container is intentionally bound to `127.0.0.1` only, so it cannot be
reached directly on port 8081 from the internet — Apache is the only path in.
This is the pattern to follow for any future container-backed site: publish
the container to `127.0.0.1:<port>`, add an Apache vhost for the subdomain
that proxies to it, then run `certbot --apache -d <subdomain>` to get HTTPS.
### Nextcloud (`cloud.jayfield.org`)
Minimal, data-hosting-focused deployment — core Files app only. No
Collabora/OnlyOffice, Talk, full-text search, external storage backends,
or LDAP/SSO; those can be layered on later without re-architecting.
```
Internet → Apache :80/:443 (cloud.jayfield.org vhost)
→ Let's Encrypt TLS termination, X-Forwarded-Proto: https
→ ProxyPass http://127.0.0.1:8082/
→ container "nextcloud" (nextcloud:apache)
↕ private network "nextcloud_net"
→ container "nextcloud-db" (mariadb:lts)
→ container "nextcloud-redis" (redis:alpine)
```
- **DNS**: `cloud` A record added to `/etc/bind/db.jayfield.org`
(serial bumped 2022032213 → 2022032214).
- **Data volume**: bind-mounted at `/srv/nextcloud/data` on the host
(owned by uid/gid `33`, matching `www-data` both on the host and
inside the container) rather than an opaque named Docker volume — so
it's directly inspectable/backupable with normal tools (`du`,
`rsync`, `borg`).
- **App volume**: named Docker volume `nextcloud_html`, holds Nextcloud
core + `config.php`; survives image upgrades.
- **DB volume**: named Docker volume `nextcloud_db`.
- **Auto-install**: done via the image's env-var-driven installer
(`MYSQL_*`, `NEXTCLOUD_ADMIN_USER`/`NEXTCLOUD_ADMIN_PASSWORD`,
`NEXTCLOUD_TRUSTED_DOMAINS`, `TRUSTED_PROXIES`,
`OVERWRITEPROTOCOL=https`, `REDIS_HOST`) rather than leaving an
unconfigured setup wizard reachable on the public internet.
- **Credentials**: DB root/user passwords and the initial admin
password are in `/root/.nextcloud-credentials` on the server
(`600 root:root`) — not committed anywhere, not in this repo.
- **Cron**: Nextcloud's background-job mode is set to `cron` (`occ
background:cron`); a root crontab entry runs
`docker exec -u www-data nextcloud php -f /var/www/html/cron.php`
every 5 minutes. No sidecar cron container.
- **PHP limits**: image defaults are already generous for file hosting
(512M `memory_limit`/`upload_max_filesize`/`post_max_size`, unlimited
`max_execution_time`) — no tuning needed for this pass.
### Password vault sync (KeePass, via Nextcloud)
Each user's KeePass database now syncs through Nextcloud's own WebDAV
endpoint instead of a bespoke Apache-WebDAV + SFTP-upload pipeline:
| User | Nextcloud account | Vault file | WebDAV URL |
|---|---|---|---|
| `jens` | `jens` | `sicher_jens.kdbx` | `https://cloud.jayfield.org/remote.php/dav/files/jens/sicher_jens.kdbx` |
| `alex` | `alex` | `sicher_alex.kdbx` | `https://cloud.jayfield.org/remote.php/dav/files/alex/sicher_alex.kdbx` |
- Each `.kdbx` lives at the root of that user's Nextcloud Files storage
(`/srv/nextcloud/data/<user>/files/`, `640 www-data:www-data`), picked
up via `occ files:scan <user>`.
- Verified 2026-07-19 with a raw WebDAV `PROPFIND`/`GET`/`PUT`/`GET`
round trip against the `alex` account — correct `Content-Length`,
matching SHA-256 before and after a write. (KeePassXC has no native
WebDAV client and there's no display on this host to drive a real
KeePass session, so this HTTP-level check is the practical
equivalent — it exercises the same verbs a WebDAV-aware sync client
or plugin would use.)
- A `600`-permission backup copy of each vault also still lives in the
respective user's home dir (`/home/jens/sicher_jens.kdbx`,
`/home/alex/sicher_alex.kdbx`) on this host.
- **Superseded setup**: an Apache `mod_dav_fs` container (`docker-apache2-php` /
`my-httpd` image, HTTP Basic Auth via `.htpasswd`) running on the admin
workstation used to serve the vault directly, with files uploaded to
this host via the chrooted `ftp_jens`/`ftp_alex` SFTP accounts under
`/var/sftp/`. That local container/image has been removed (it ran with
`--rm` and had no cron/systemd autostart, so nothing else depended on
it). The `ftp_jens`/`ftp_alex` accounts remain on this host (chroot and
files untouched) but are password-locked (`passwd -l`) since nothing
uses them anymore — reversible with `passwd -u` if SFTP is ever needed
again.
## Open items / known quirks
- **Intranet migration from `vlda-01` (files, Gitea, Jellyfin, Dogecoin
node/wallet)** — planning stage only, not started. See `SYNC-PLAN.md`
for the full per-service design, open questions, and build order.
- **PTR/EHLO mismatch (workaround applied, real fix still open)**: the
PTR record for this host's IP (`89.58.8.149`) resolves to
`alpha.jayfield.org`, but Postfix's `myhostname` was `mail.jayfield.org`
(matching the MX record) — a mismatch some strict receivers penalize.
2026-07-19: `myhostname` changed to `alpha.jayfield.org` to match the
existing PTR (verified live: `EHLO` → `250-alpha.jayfield.org`); see
SETUP.md's mail-stack section. The real fix is repointing the PTR to
`mail.jayfield.org` and then switching `myhostname` back — not done
yet, since PTR isn't controlled from this host's own DNS zone. Checked
2026-07-19: the ISP's control panel has no self-service PTR/rDNS
option at all, so this needs a support ticket to the ISP asking them
to set the PTR for `89.58.8.149` to `mail.jayfield.org` (worth noting
in the ticket that the forward record already resolves correctly —
`mail.jayfield.org` → `89.58.8.149` — since some providers require
that before they'll set rDNS). If the ISP can't or won't do it, the
current `myhostname = alpha.jayfield.org` workaround is a fine
permanent setup as-is, not just a stopgap — PTR and EHLO already
agree, which is what actually matters for deliverability.
- **Distributed low-and-slow scan on `apache-noscript`**: found 2026-07-19
while auditing fail2ban jails for the `sshd` timing-evasion pattern below
— a cluster of ~20 IPs across three `/24`s (`91.230.225.0/24`,
`185.192.71.0/24`, `185.92.25.0/24`) each probed exactly once, never
repeating. No per-IP `findtime`/`maxretry` tuning can catch this by
design — it needs subnet-level (CIDR) banning instead, which risks
blocking legitimate traffic sharing that block (e.g. carrier NAT). Left
as routine scan noise for now; revisit if it escalates.
- **No real nameserver redundancy**: 2026-07-19, `ns1.jayfield.org` and
`ns2.jayfield.org` were registered as this domain's NS records at the
registrar. Delegation checked and confirmed consistent — the `.org`
registry (checked against two separate registry servers), this host's
own authoritative zone, and both `ns1`/`ns2` themselves (queried
directly, `aa` flag set, matching SOA serial `2022032214` at the time
of that check — serial has since moved to `2022032216` from the
SPF/DMARC/DKIM fix below, unrelated to NS/glue) all agree.
But both names glue to the *same* IP (`89.58.8.149`, i.e. this same
host) — RFC 2182 §3 recommends at least two nameservers not on the
same network segment, and this setup has zero real redundancy: if
`alpha` goes down, both "nameservers" disappear together. Tried
unregistering `ns2` at the registrar since it wasn't adding real
redundancy anyway — confirmed the ISP/registrar requires a minimum of
two NS records at the delegation layer and rejected the change, so
both stay registered as-is. Redundancy risk remains open; the only
real fix is a second nameserver on genuinely separate infrastructure.
(Previously resolved: legacy `/portainer` proxy paths on the
default vhost and `jayfield.org`, stale DNS records `test`, `kacke`,
`pupsi`, `mausi`, `hase`, `super`, and a disabled `nextcloud.conf` vhost
plus its `a2dissite` marker file — a leftover from a Nextcloud install
that was disabled at some point but never actually deployed (no
`/var/www/nextcloud`, no package, no DB, no cert) — all cleaned up.
Also: `mnt-vlda-01.mount`'s recurring boot failure — root-caused to a
dormant WireGuard tunnel (`alpha_at_moon`, routing the same
`192.168.22.0/24` home-LAN range the CIFS target lived on) — resolved
2026-07-19 by removing all three together rather than reviving the
tunnel: `wireguard`/`wireguard-tools` purged, `nginx` +
`libnginx-mod-rtmp` purged (an unrelated, previously-uncatalogued RTMP
streaming service on `:1935`, found via the same diff pass, also unused
and removed), and `samba`/`cifs-utils` purged along with the `fstab`
entry and stored CIFS credentials. Configs backed up to
`/root/removed-configs-backup/` on the server before deletion, in case
any of the three are wanted again. Zero failed systemd units after.
Also: KeePass vault sync migrated 2026-07-19 from local Apache-WebDAV +
SFTP-upload to Nextcloud's built-in WebDAV — see "Password vault sync"
above. Also: SPF, DMARC, and both DKIM (`2021`/`2022._domainkey`) TXT
records in `db.jayfield.org` were silently broken — root cause was
**unquoted TXT rdata in the zone file**: BIND's zone parser treats an
unquoted `;` as a start-of-comment (so `v=DMARC1; p=reject;` was being
served as just `"v=DMARC1"`, and both DKIM keys as just `"v=DKIM1"` with
the `k=rsa; p=<key>` portion silently dropped — meaning DKIM signature
verification had been failing for all outbound mail), and an unquoted
multi-token SPF line was being split into multiple concatenated
character-strings instead of one, merging in an unrelated
`google-site-verification=` token and invalidating the SPF syntax.
Fixed 2026-07-19 by quoting every TXT value (splitting the long DKIM
keys across two `<=255`-byte quoted strings each, since a single
character-string is capped at 255 bytes — see `named-checkzone`'s
"text string too long" if this is missed) and separating
`google-site-verification` into its own TXT record. SOA serial bumped
`2022032214` → `2022032216`. Verified directly against the authoritative
server (`dig ... @89.58.8.149`) post-reload; public resolvers catch up
as the old records' TTLs (up to 3600s) expire.
Also 2026-07-19: audited for phpMyAdmin/Adminer/webmin/cPanel/Plesk/
ISPConfig/phpPgAdmin residue (dpkg logs, `rc`-state packages, filesystem-wide
filename search, Apache configs, MySQL users/DBs, crontabs, Docker
images/volumes) — clean; the only name hits were stock `fail2ban`/`augeas`
package files, not evidence of a prior install. Two stray public files
(`ShowKeyPlus_x64.zip`, `MediaCreationTool1909.exe` — unrelated pirated
Windows tooling, plus an empty `test` file) were found sitting in
`/var/www/html/pub`, publicly served off the default vhost; moved to
`/home/jens/pub` and the now-empty `pub/` directory removed from the web
root. Also: SSH hardened (`PermitRootLogin no`, `PasswordAuthentication no`,
key-only) and the `sshd` fail2ban jail's `findtime` widened from the
30-minute default to 24h (`maxretry` 3→4, `bantime` 4h→24h) after
`47.76.192.176` was found grinding root/`jayfield` SSH logins roughly every
33 minutes for most of a day — just outside the old 30-minute window, so it
never accumulated enough strikes to get banned; see `TODO.md` and
`SETUP.md` §1/§3 for the fix and the same evasion check applied to every
other jail (only one other pattern turned up — see "Open items" above).)
+732
View File
@@ -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.
+111
View File
@@ -0,0 +1,111 @@
# alpha.jayfield.org — Intranet Sync Plan (vlda-01 → alpha)
**Status: planning only, nothing implemented yet.** This is a point-in-time
plan for later reference, not a live inventory (`README.md` is the live
inventory) and not a build runbook (`SETUP.md` is that). Update this file
as decisions get made and pieces get built; move finished work into
`README.md` once it's live, the way the KeePass/Nextcloud migration did.
## Problem
`vlda-01` (home intranet server, behind a home LAN, not 24/7 online) hosts
four things the household wants reliably available: plain files, Gitea,
Jellyfin, and a Dogecoin node/wallet used for daily transaction catchup.
Goal: use `alpha.jayfield.org` (public, 24/7, already running Docker +
Nextcloud) as the always-available side, without reproducing the failure
mode that got the old setup removed.
**Why not just revive the old WireGuard tunnel + CIFS mount** (see
`README.md`'s "Previously resolved" history — `alpha_at_moon` tunnel +
`192.168.22.0/24` CIFS mount, purged 2026-07-19, configs backed up to
`/root/removed-configs-backup/` if ever needed as a reference): that setup
required `vlda-01` to be reachable *at the moment alpha wanted the data*
a live mount. That's structurally incompatible with "not 24/7 online." Each
service below instead either pulls data to alpha ahead of time or migrates
outright, so alpha's availability stops depending on vlda-01's.
## Per-service plan
### 1. Files → Syncthing
- Syncthing container on alpha, data lands at `/srv/intranet-data/<folder>`
(host-inspectable, matching the `/srv/nextcloud/data` pattern). Admin GUI
bound to `127.0.0.1:8384`, reverse-proxied through Apache like
Portainer/Nextcloud — never expose the setup UI directly.
- Syncthing client on `vlda-01` dials **out** to alpha — no inbound port
forwarding needed at home, unlike the old tunnel. Catches up automatically
whenever vlda-01 gets power + internet.
- Direction: start **send-only from vlda-01 → receive-only on alpha**
(vlda-01 stays authoritative, alpha is a live mirror). Revisit bidirectional
later once trusted.
- Firewall: open `22000/tcp` + `21027/udp` on alpha for Syncthing.
- Expose via Nextcloud: enable the built-in `files_external` app, mount
`/srv/intranet-data/<folder>` as local external storage — same web UI
(`cloud.jayfield.org`) already used for the KeePass vaults.
**Status: not started.**
### 2. Gitea → full migration, not sync
Just repos + a small DB — no reason to keep this tied to vlda-01's uptime.
- Stop the Gitea container on vlda-01, copy its data volume (repos + DB +
config) to alpha.
- Bring it up on alpha as a permanent container, following the exact
pattern already used for `web`/`cloud.jayfield.org`: publish to
`127.0.0.1:<port>` → Apache vhost (`git.jayfield.org`) → `certbot --apache`.
- Decommission or keep vlda-01's copy as a cold backup, admin's choice.
**Status: not started. No open questions — ready to build when wanted.**
### 3. Jellyfin → scoping question, not yet planned
Media libraries are often hundreds of GB to multiple TB, and local hardware
transcoding at home may not have an equivalent on the VPS. Before sketching
an approach, need:
- Approximate library size.
- Is the actual goal *remote* streaming (away from home), or is Jellyfin
only ever watched on the home LAN? If the latter, migrating buys nothing —
Jellyfin only needs to be up when someone's actually watching, which is
whenever vlda-01 would be on anyway. If remote access is wanted, next step
is sizing alpha's disk/bandwidth budget for it.
**Status: blocked on the above — no plan yet.**
### 4. Dogecoin node/wallet → security-conscious split, needs sign-off
**Do not migrate the wallet wholesale without explicit confirmation.** Per
the attacker report (`SendUserFile`'d 2026-07-19), this host takes on the
order of 10,000 automated SSH/scan attempts a month. Putting spending keys
on the same public, constantly-probed box is a materially different risk
than at home.
Recommended design:
- Run `dogecoind` on alpha as a Docker container with **`disablewallet=1`**
— blockchain sync only, no private keys, no funds at risk. Stays
permanently caught up with the network regardless of vlda-01's uptime.
- Keep the actual wallet (`wallet.dat`, spending keys) only on vlda-01.
Point vlda-01's `dogecoin-cli`/GUI at alpha's node as its backend
(`-connect=`), with RPC reachable **only** over an authenticated tunnel
(SSH-forwarded or WireGuard) — never bind RPC to a public interface; an
open Dogecoin RPC port is exactly the kind of thing scanners probe for.
- Result: "daily transaction catchup" becomes near-instant once vlda-01
turns on, since the chain is already synced — without private keys ever
touching the internet-facing box.
**Open question, needs the user's explicit answer before implementing**:
go with the wallet-split design above, or is the fund exposure small enough
to just run the wallet on alpha too, for simplicity? Do not proceed with
either without confirmation — this is the one piece of this plan with real
financial/security stakes.
## Suggested build order
1. Gitea (no open questions, most self-contained, validates the general
"migrate a dockerized service to alpha + Apache vhost" pattern).
2. Files via Syncthing (validates the intermittent-sync approach before
trusting it with anything else).
3. Dogecoin node — **only after explicit sign-off on the wallet-split
question above.**
4. Jellyfin — **only after the scoping question above is answered.**
+76
View File
@@ -0,0 +1,76 @@
# alpha.jayfield.org — TODO
Outstanding hardening items (see `README.md` for the full service overview).
Nothing here is urgent; all are low-risk, no-downtime changes.
- [x] **Lock down the dyndns TSIG key file permissions** — done 2026-07-19
`/etc/bind/dyndns.jayfield.org.key` was `644` (world-readable), holding
the shared secret that authenticates dynamic DNS updates for
`dyndns.jayfield.org` with no IP-based `allow-update` backing it up.
Fixed: `chmod 640` + `chown root:bind` (owner was already correct),
matching `rndc.key`'s permissions.
- [x] **Suppress BIND version disclosure** — done 2026-07-19
No `version` statement in `named.conf.options`, so
`dig CH TXT version.bind @ns1.jayfield.org` leaked the exact BIND
version (`9.18.39`) — a minor fingerprinting aid for attackers.
Fixed: added `version "unknown";` inside the `options {}` block,
validated with `named-checkconf`, and applied via `rndc reload`.
Verified `dig CH TXT version.bind` now returns `"unknown"`.
- [x] **Add response rate limiting (RRL)** — done 2026-07-19
No `rate-limit {}` block was configured. Since `allow-query { any; }`
is required for a public authoritative zone, RRL is the standard
mitigation against the server being used for spoofed-source DNS
reflection/amplification against third parties.
Fixed: added `rate-limit { responses-per-second 10; window 5; }` to
`named.conf.options`, validated with `named-checkconf`, and applied
via `rndc reload`. Started with conservative defaults (10/s, 5s
window) — revisit if legitimate high-volume resolvers get throttled.
- [x] **Add HSTS headers to all vhosts** — done 2026-07-19
No vhost sent a `Strict-Transport-Security` header, leaving a window
for on-path SSL-stripping on a user's very first plaintext request to
any domain.
Fixed: added `Header always set Strict-Transport-Security
"max-age=63072000; includeSubDomains"` to all 6 domain `:443` blocks
(`dyndns`, `jayfield.org`, `www`, `mail`, `web`, `cloud`) — skipped
`default-ssl.conf` since it's the self-signed catch-all with no real
trusted hostname for a browser to pin. Validated with
`apache2ctl configtest`, applied via `systemctl reload apache2`,
verified live with `curl -sI` against all 6 domains.
- [x] **`jayfield.org` had no `:80` vhost of its own** — done 2026-07-19
Bare `http://jayfield.org` fell through to the `dyndns` default vhost
and redirected to the wrong domain.
Fixed: added a `:80` block to `jayfield.org.conf` redirecting to
`https://jayfield.org/`, matching the `dyndns`/`mail` pattern.
Verified: `curl -sI http://jayfield.org/``302` with
`Location: https://jayfield.org/`.
- [x] **SSH: disable root login + password auth** — done 2026-07-19
`PermitRootLogin yes` and `PasswordAuthentication yes` were both still
enabled (the "known deviation" flagged in `SETUP.md` §1) — every
credential-stuffing bot on the internet got a real login prompt to
grind against.
Fixed: `PermitRootLogin no`, `PasswordAuthentication no`,
`PubkeyAuthentication yes` in `/etc/ssh/sshd_config` (backed up as
`sshd_config.bak.20260719225249`). The `Match Group sftp` block's own
`PasswordAuthentication yes` was left as-is — separate, already-chrooted,
no-shell/no-forwarding accounts, not part of this risk. Validated with
`sshd -t`, applied via `systemctl reload sshd`, verified live with
`sshd -T` and a fresh key-only connection before considering it done.
- [x] **fail2ban: `sshd` jail was evadable via low-and-slow attempts** — done 2026-07-19
Default `findtime=30m`/`maxretry=3` never triggered for `47.76.192.176`,
which had been hitting `root`/`jayfield` logins roughly every 33 minutes
for most of a day — just outside the 30-minute window, so failures kept
aging out before reaching 3 strikes. Confirmed via `fail2ban.log`
(`Found`/`Ban` counts) that no other jail showed the same per-IP timing
pattern; a separate, *distributed* single-shot scan on `apache-noscript`
was noted but left open (see `README.md`).
Fixed: `/etc/fail2ban/jail.d/sshd-findtime.local` overrides `sshd` to
`findtime=1d`, `maxretry=4`, `bantime=1d`, kept in its own drop-in
rather than editing the Debian-packaged jail file. Applied via
`fail2ban-client reload sshd`; `47.76.192.176` was banned on its very
next attempt after the change.