46 KiB
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.
-
Weekly attacker-summary report routing to
root@jayfield.org— done 2026-07-26.attacker-check.py's report email was pointed atroot@jayfield.org, but that address didn't resolve to anything (not in/etc/aliases, not invmail.aliases) — would have bounced every week. Added it as a newvmail.aliasesrow →jens@jayfield.org, matching the existingpostmaster/hostmaster/webmaster/wlanpattern. Also added per-run dated report files (/var/log/attacker-check/reports/<date>.txt), alongside the two existing append-only CSVs. Verified:postmap -q root@jayfield.org mysql:/etc/postfix/sql/aliases.cfresolves correctly, a live re-run delivered tojens@jayfield.org(orig_to=<root@jayfield.org>in the Postfix log) and wrotereports/2026-07-26.txt. -
BIND's own DNSSEC validation was broadly broken for signed domains — found and fixed 2026-07-26 while investigating why third-party apt repos (
download.docker.com,ppa.launchpad.net) kept failing to resolve ("Temporary failure resolving") during prep for an OS release upgrade.dig-direct queries andapt/curl(via the real NSS/systemd-resolvedpath,127.0.0.53→ forwards to local BIND on127.0.0.1) gave inconsistent results for the same hostname, which pointed at BIND's own recursion rather than a network flake. Confirmed with a quick sample:github.com,launchpad.net,ppa.launchpad.net,deb.debian.org, anddownload.docker.comall returnedSERVFAILwhen queried directly against127.0.0.1, every time (4/4 reproductions), while unsigned zones (e.g.archive.ubuntu.com) resolved fine — i.e. broken specifically for DNSSEC-signed domains, not domain-specific and not transient. Ruled out fragmentation/router interference: forcing+tcp,+bufsize=512, and+noednsagainst BIND all still SERVFAILed, while the same lookups against8.8.8.8over the same network path succeeded fine (NOERROR) — so it wasn't a UDP/EDNS size or FritzBox path issue.dnssec.log(one of the per-category log files already split out innamed.conf.logging) gave the exact cause:validating com/DS: no valid signature found.rndc managed-keys statusshowed two root trust-anchor keys — key id20326"trusted since" 2022-03-17 and key id38696"trusted since" 2025-02-10 (consistent with the real 2025 ICANN root KSK rollover) — both looked superficially fine (neither marked for removal), but validation against the live root zone was failing regardless.bind9/bind9-libs/dns-root-datawere already at the latest available jammy versions, ruling out a simple stale-package fix. Root cause is almost certainly that this BIND instance only started doing full local recursive resolution on 2026-07-19 (see the forwarders-removed entry below) — before that it was forwarding to the ISP's resolver, so it was never actively tracking/validating the root DNSSEC chain itself, and its RFC 5011 managed-keys state was stale/inconsistent by the time real validation started happening. Fixed with the standard recovery procedure for this class of problem: stoppednamed, backed up/var/cache/bind/managed-keys.bind(.jnl) to/root/removed-configs-backup/bind-managed-keys-reset-20260726131408/, deleted the live copies (forcing BIND to rebuild its trust anchor from scratch via RFC 5011 on next start, exactly as a fresh install would), restartednamed. Verified: all 5 previously SERVFAILing domains above now returnNOERROR;jayfield.org's own authoritative zone still resolves correctly (unaffected by the restart);rndc managed-keys statusnow shows both root keys freshly re-trusted as of the restart timestamp, confirming a real re-validation happened rather than a fluke. Why this mattered beyond the immediate apt problem: this box uses BIND as its general OS resolver (viasystemd-resolved→127.0.0.1), so this was silently breaking DNSSEC validation for any signed external domain something on this host might need to reach — not just apt/PPA lookups. -
Cleaned up an incomplete 20.04→22.04 release upgrade — found and fixed 2026-07-26, while investigating third-party apt repos ahead of a planned 22.04→24.04 upgrade.
/var/log/dist-upgrade/showed ado-release-upgraderun from 2026-07-18 that got most of the way through (core OS, Apache, BIND, SSH — matches everything else fixed this week) but stalled at an interactive dpkg conffile prompt for/etc/systemd/resolved.confand was never resumed. Left behind:mariadb-server-10.3/mariadb-client-10.3(andgalera-3,fuse, a couple oflibtss2/libsemanagelibs) still installed and marked "pending removal" but never actually replaced by jammy'smariadb-server-10.6; and three third-party apt sources (docker.list,rspamd.list,ondrej-ubuntu-php-focal.list) still pointed atfocal, with the PHP PPA disabled entirely (the release-upgrade tool correctly remapped itsdebline tojammybut left it commented out —.distUpgradebackups of the originals were left alongside, clearly meant to be manually re-enabled after upgrade completion, which never happened). System itself was stable throughout (dpkg --audit/apt-get checkboth clean) — not an emergency, just deferred work. Fixed: pointed all three sources atjammy(backed up originals to/root/removed-configs-backup/apt-repo-fixes-20260726132055/anddyndns-per-host-auth-...-adjacent dirs), confirmed withapt updatethat all three now fetch cleanly (only worked once the DNSSEC fix above landed — this Docker repo failure had looked like "just" a stale codename, but was actually blocked by the SERVFAIL bug too). Reviewed rspamd's actual 3.11.0→4.1.3 changelog against this host's livelocal.d/override.dconfig before upgrading — none of the documented breaking changes (ed25519 DKIM,mx_checksymbol renames, neural autolearn, workerssl=true, custom Luatask:get_urls()/origaddress handling) touch anything actually configured here (stock RSA DKIM/ARC, Redis-backed Bayes, milter proxy, no custom Lua). Ranapt upgrade --dry-runfirst and confirmed zero removals and nomariadb/galerainvolvement (a plainapt upgradecorrectly refuses themariadb-servermetapackage transition since it requires package replacement, not just a version bump) before applying: 77 packages upgraded, 13 newly-installed dependency libraries,docker-ce5:28.1.1→5:29.6.2, PHP 7.4.33/8.0.30 rebuilt for jammy (still the same, EOL, PHP versions — just properly matching this OS release instead of running focal binaries), rspamd 3.11.0→4.1.3.needrestartflagged ~25 services after the upgrade, includingssh.service,named.service,mariadb.service,postfix@-.service,dovecot.service, andfail2ban.service. Restartedsshfirst and deliberately (sshd -tconfig check, then restart, then verified with a genuinely fresh SSH connection rather than assuming the existing one survived) given it's the only remote access to this box; then the other mail/DNS/security services one at a time with functional checks after each (SMTP/IMAP banners,fail2ban-client statusconfirming all 19 jails and the customfindtime=1dsshd/dovecot hardening survived, livedigagainst BIND). Confirmeddocker-ce's own postinst had already restarted the daemon automatically (not in needrestart's list) and all 5 containers (portainer,nextcloud,nextcloud-redis,nextcloud-db,web) came back up without disruption;rspamd 4.1.3config syntax checks clean (rspamadm configtest) with only a pre-existing, non-blocking task-timeout-tuning advisory, web UI reachable. Leftuser@1001.service(a per-login systemd session, not a real service) unrestarted since restarting it would disrupt the active login for no real benefit.mariadb-server-10.3→10.6migration completed 2026-07-26: took a full logical backup first (mysqldump --all-databases --routines --triggers --events --single-transaction, ~2MB, verified complete — all 4 databases includingvmailpresent, proper "Dump completed" footer) plus a tarball of/etc/mysql, to/root/db-backups/. Confirmed viaapt install --dry-run mariadb-serverthat the transition was exactly as expected (removemariadb-server/client(-core)-10.3+galera-3, install the-10.6/galera-4equivalents, nothing else touched) before running it for real. Went smoothly: package postinst detected the old 10.3 datadir and ran the system-table upgrade automatically (confirmed viamariadb-upgrade: "already upgraded to 10.6.23-MariaDB... no need to run mysql_upgrade again"). Verified end-to-end afterward —vmailschema intact (1 domain, 2 accounts, 4 aliases, matching pre-upgrade state),doveadm user '*'correctly lists both real mailboxes (jens@/otto2022@jayfield.org), and Postfix's own MySQL lookups (postmap -q ... mysql:/etc/postfix/sql/{domains,accounts}.cf) resolve correctly against the upgraded database. No galera clustering was actually in use (just an unused compiled-in dependency), so thegalera-3→4swap needed no further action. -
22.04 → 24.04 (Noble) release upgrade — completed 2026-07-26, once the box was in a clean, consistent 22.04 state (all the items above). Confirmed beforehand that the dyndns app's PHP 7.4 dependency (already EOL) still has a
noblebuild in Ondřej's PPA (initial check of the plainPackagesfile gave a false "not available" — that file 404s uncompressed, onlyPackages.gzis actually published; the correct check confirmed 7.4 through 8.5 all build fornoble), so no PHP migration was needed first. How it was run, given exactly what killed the previous upgrade attempt (the 2026-07-18 one documented above, stuck forever at an unanswered dpkg prompt): launcheddo-release-upgrade -m server -f DistUpgradeViewNonInteractivefully detached (setsid/nohup, stdin from/dev/null, logged to a file), so it can't hang waiting on an interactive answer and survives regardless of what happens to the SSH session. Did a full test reboot first (not just service restarts) to confirm the box comes back up cleanly on its own — it did, DNS/DNSSEC/mail/Docker all fine — before relying on the same mechanism for the upgrade's own final reboot. First attempt refused to start ("Please install all available updates for your release before upgrading"): 12 more packages turned out to be left over from the original 2026-07-18 incomplete upgrade that the earlier cleanup pass had missed (fuse→fuse3,fwupd,open-vm-tools,passwd,mailutils, etc. — all needing package replacement, same pattern as themariadb-servertransition).apt full-upgrade --dry-runconfirmed a clean transition (11 upgraded, 20 newly installed, only 5 old-focal-era libraries removed, e.g.libmailutils6→8), applied it, then relaunched. Second run completed the package transition successfully (confirmed via/etc/os-release→Ubuntu 24.04.4 LTS), but not before an alarming few minutes where SSH was completely unreachable (Connection refused, not a timeout) — turned out to beopenssh-serveritself being replaced partway through installing/configuring hundreds of packages, not an actual reboot or network failure (ssh.service's own "active since" timestamp landed right in the middle of that window). The non-interactive frontend did hit a real conffile prompt this time (/etc/fail2ban/jail.conf) and — unlike the 2026-07-18 attempt — auto-answered it instead of hanging, proving the fix works. Also correctly recognizedphp7.4/php8.0/rspamdas "foreign" (third-party-sourced, repos disabled during the upgrade) and left them alone rather than deleting them as obsolete — except it did deleterspamdanyway (see below). Rebooted into the new kernel (6.8.0-136-generic) the same way as the earlier test reboot; came back up cleanly again. Two real problems found post-reboot, both fixed same day: -fail2banfailed to start:Bad value substitution ... 'ignorecommands_dir' which is not a valid option namein theapache-fakegooglebotjail. Root cause: the non-interactive frontend kept our existingjail.confat the earlier conffile prompt (we've never touched it directly — all our real customization lives injail.d/*.localdrop-ins), but the upgradedfail2banpackage expects a[DEFAULT]value forignorecommands_dirthat isn't defined anywhere in either the old or the freshly---force-confnew-reinstalledjail.conf— a genuine gap in thenoblepackage (the referenced script directory,/etc/fail2ban/filter.d/ignorecommands/, does exist on disk from the package's own file list, just nothing pointsignorecommands_dirat it). Fixed with a small owned drop-in,jail.d/ignorecommands-dir.local, defining it — same convention as our other fail2ban customizations, no package-owned file touched. Verified: all 19 jails present, customfindtime=1dsshd/dovecot hardening intact. -rspamdwas fully removed (dpkg -lshowedrc, not just held back likephp7.4/php8.0were) — itssystemdunit was also masked (symlinked to/dev/null) by the upgrade tool as a safety measure for a package it could no longer account for. Its.listsource was gone entirely too (unlike Docker/PHP, whose disabled entries were left commented-out) — the upgrade tool had migrated it to the newer deb822.sourcesformat instead (rspamd.sources,Enabled: no, already correctly re-pointed atnoble). Docker's source, meanwhile, was gone with no.sourcesreplacement at all (had to recreatedocker.listfrom scratch, keyring was still intact). Fixed: enabled therspamd.sourcesandondrej-ubuntu-php-*.sourcesfiles, recreateddocker.listpointed atnoble, confirmed all three fetch cleanly, thenapt install rspamd(candidate correctly resolved to the same4.1.3build, not Ubuntu's own older native3.8.1-1ubuntu3noble package). Verified for real this time, not just "service active" (which had been a false positive from a legacy SysV init shim silently succeeding with no binary behind it): real daemon process, listening on11332/11333/11334,rspamadm configtestclean, andrspamc symbolsagainst a test message showing genuine scanning (DMARC checks, ARC signing with the realjayfield.org/2022key, full symbol scoring) — not just an empty process. Note for later:milter_default_action = acceptin Postfix meant mail kept flowing the whole time rspamd was down, just completely unfiltered and unsigned — not a full outage, but worth knowing that gap exists. Full verification pass after all fixes: SSH hardening intact (PermitRootLogin no/PasswordAuthentication no), DNSSEC still validating (github.com→NOERROR),jayfield.orgzone resolving correctly, all 5 Docker containers up without disruption, all 7 Apache vhosts responding as expected, the dyndns app verified end-to-end (real per-host-credentialed update viavpn's login → correct TSIG key → BIND →digconfirms).mariadb-serverwas upgraded again automatically as part of this same run,10.6.23→10.11.14(noble's native version) — verifiedvmailschema intact (1 domain, 2 accounts, 4 aliases, unchanged),doveadm/Postfix MySQL lookups both still correct. -
Mail server IP (
89.58.8.149) listed on Barracuda's BRBL — found 2026-07-25 (user report: "jayfield.org is on the Barracuda MX blacklist"). Confirmed viadig +short 149.8.58.89.b.barracudacentral.org A→127.0.0.2(listed); clean on Spamhaus ZEN, SpamCop, and SORBS — Barracuda-specific. Checked the server for actual abuse before requesting removal: mail queue empty, outbound volume ~11 msgs over the prior week, only known SASL logins (jens@/otto2022@jayfield.orgfrom the usual home IP), no backscatter, correct PTR (alpha.jayfield.org), SPF/DKIM/DMARC (p=reject) all already live — no sign of a compromised account or open relay, so most likely a stale/inherited reputation hit on the IP rather than active abuse from this host. Submitted a removal request viahttps://www.barracudacentral.org/rbl/removal-requeston 2026-07-25. Confirmation number:BBR21785009436-19117-9182. Per Barracuda's response, the IP gets a temporary 48h reputation bump to "normal" while they investigate (up to ~1h to propagate), after which it either stays clear or reverts to listed. Status 2026-07-26: still clear (dig +short 149.8.58.89.b.barracudacentral.org A→ NXDOMAIN, no127.0.0.2) — matches the 1h check (routinetrig_01F1RMhEtNHtAbuHb9ffe1oX, fired 2026-07-25 21:02 UTC, also clear). This is expected during the 48h "normal" bump window, not yet proof of permanent delisting. The real test is the 48h check (routinetrig_019GayXSo5TW5eSxiJ99Ghrp, scheduled 2026-07-27 20:03 UTC) — if it reverts to listed, next step is a manual follow-up to Barracuda citing the confirmation number above. -
dyndns app has no real per-user auth binding — found 2026-07-20, fixed 2026-07-26.
index.php'spass == '_NO_PASS_'check is a hardcoded magic string, not a real per-user credential — nothing tied the single shared Apache Basic Auth identity (jens, the only.htpasswdaccount) to which hostname label got updated. Confirmed live in the access log: a FritzBox ("Fritz!Box DDNS/1.0.3") was authenticating asjenswhile updating theuschirecord — i.e. every device was already relying on this exact gap, self-policing which host it claimed via an unauthenticatedhostname=/user=param. Fixed with real per-host TSIG binding, not just an app-level check: - Added a dedicated.htpasswdaccount for each ofkack,test,uschi,vpn(jens's existing account/password untouched). - Generated 5 per-host TSIG keys (hmac-sha512,tsig-keygen), one per hostname, each in its own/etc/bind/<host>.dyndns.jayfield.org.key(root:bind 640+setfaclgrant forwww-data, same pattern as the original key). - Replaced the zone's flatallow-update { key dyndns.jayfield.org; };innamed.conf.default-zoneswith anupdate-policygranting each new key permission to update only its own name (grant "<host>" name <host>. A AAAA TXT;), plus one explicit legacy fallback grant for the original shared key (subdomainrule, zone-wide) — kept temporarily so unmigrated devices don't break (see below). -nsupdate.php/nsupdate_ipv4.php/nsupdate_ipv6.phpnow sign with the key belonging to$_SERVER['PHP_AUTH_USER'](the authenticated identity), not whatever hostname was requested. If they match, the new per-host key is used — BIND itself then refuses the update if that key isn't granted that name, independent of any PHP-side check. If the authenticated user is specifically the legacyjensaccount and doesn't match, it falls back to the old shared key (logged viasyslog(LOG_WARNING, ...)for migration tracking) — every other account gets rejected outright (-1) on a mismatch, so newly-created per-host logins can never touch another host, even via the fallback. - Also fixed a pre-existing bug found innic/update.phpwhile testing this: it printedgood <ip>unconditionally, without ever checkingnsupdate's return code — so a rejected update silently looked successful to the calling device. Now prints the real dyndns2-protocol response (good <ip>/badauth). All files backed up to/root/removed-configs-backup/dyndns-per-host-auth-20260726094500/. Validated withnamed-checkconf/apache2ctl configtest, applied viarndc reload/systemctl reload apache2. Tested both by drivingnsupdate()directly aswww-dataand end-to-end over HTTPS with the newvpncredentials: own-host updates succeed, cross-host attempts from new accounts are rejected (-1/badauth) with the target record provably unchanged, and the legacyjens→uschipattern still succeeds unchanged. Test IPs used during verification were restored to the real production values (test,uschi,vpn) immediately after; livedigconfirmed the final state matches the pre-change records exactly. Follow-up: thejenslegacy fallback (and its.htpasswdaccount) should stay only until every real device — including whichever ones are still authenticating asjensto updateuschi— has been reconfigured with its own dedicated login. Oncedyndns legacy shared-key updatestops appearing insyslog, remove the fallbackgrantand thejensaccount can be scoped down to just its own host too.vpnmigrated 2026-07-26: home FritzBox reconfigured with the newvpnlogin and confirmed live — after a WAN IP change (real connection recycle, not a test), the access log shows a genuineFritz!Box DDNS/1.0.3request authenticated asvpnupdatingvpn.dyndns.jayfield.orgto the new IP (217.229.60.187), anddigconfirms the zone picked it up. First host fully off the legacy fallback.uschi's device was reconfigured too but hasn't sent a real authenticated update yet (FritzBox only re-registers on WAN IP change or manual renew, neither has happened since) — not yet confirmed live.kack/testremoved 2026-07-26 instead of migrated:kackhadn't updated since Feb 2024 andtestshowed no real traffic in the (short) per-vhost access log history either — both looked like stale/unused entries rather than active devices, so removed outright rather than given dedicated logins. Deleted theirA/AAAA/TXTrecords viansupdate(using the legacy shared key), dropped theirupdate-policygrants andincludelines fromnamed.conf.default-zones, moved their per-host TSIG key files and.htpasswdentries (htpasswd -D) out. Backed up to/root/removed-configs-backup/dyndns-remove-kack-test-20260726101439/. Validated withnamed-checkconf/apache2ctl configtest, applied viarndc reload/systemctl reload apache2, confirmed both names now resolve to nothing (digempty) andjens/uschi/vpnuntouched;rndc syncforced the on-disk zone file to reflect it immediately rather than waiting for BIND's next periodic dump.jens.dyndns.jayfield.orghostname removed 2026-07-26 — same story askack: stale since Feb 2024, no device actually updating it (the onlyjens-authenticated traffic ever seen was the legacy fallback updating other hosts, never its own). Deleted the record, dropped its dedicatedupdate-policygrant and keyincludeline, movedjens.dyndns.jayfield.org.keyto/root/removed-configs-backup/dyndns-remove-jenshost-20260726101844/. Important distinction from thekack/testremoval: this only removed the hostname. Thejenslogin and the legacy shared-key fallback grant (grant "dyndns.jayfield.org" subdomain dyndns.jayfield.org. ANY;) were deliberately left in place, sinceuschi's device still authenticates asjensthrough that fallback — removing the login would have broken it immediately. Re-verified after this change (directnsupdate()call aswww-data, writinguschi's own current real IP back so nothing actually changed) that thejens→uschilegacy path still returns0.named-checkconf/rndc reloadclean throughout. Legacy fallback removed entirely 2026-07-26 — confirmed thejensfallback wasn't needed foruschianymore (its own dedicated login/key from the original fix is what should be used going forward), and withjens's andkack/test's own hostnames already gone, nothing was left depending on it. Removed thegrant "dyndns.jayfield.org" subdomain dyndns.jayfield.org. ANY;fallback grant and its keyincludefromnamed.conf.default-zones; retired the original shared TSIG key itself (/etc/bind/dyndns.jayfield.org.key, in production since March 2022) to/root/removed-configs-backup/dyndns-remove-legacy-fallback-20260726102228/; removed the now-dead legacy-path branch fromnsupdate.php/nsupdate_ipv4.php/nsupdate_ipv6.php—nsupdate()now unconditionally rejects (-1) any request where the authenticated user doesn't match the requested host, no exceptions. Verified:jensattempting to updateuschinow fails immediately (ret=-1, no BIND round-trip at all — rejected in PHP before ever building annsupdatecommand), whileuschiandvpnupdating themselves still succeed (ret=0, confirmed viadigagainst their real IPs). Per-user auth binding for dyndns is now unconditional, closing this item out completely — no more grandfathered exceptions of any kind. Thejens.htpasswdaccount itself was left in place (not asked to remove it, and it's now inert for DNS updates anyway since it has no hostname or grant left to use).uschimigration confirmed live 2026-07-26: after the legacy fallback was removed, a real WAN IP change (connection recycle, not a test) produced a genuineFritz!Box DDNS/1.0.3request authenticated asuschiupdatinguschi.dyndns.jayfield.orgto92.252.63.190, confirmed viadig. (One wrinkle along the way: an earlier verification test of mine — writinguschi's old IP back to confirm the fix — landed moments after that device's first post-migration update and clobbered it back to the stale value; the device's next natural IP change overwrote it correctly, so no lasting effect, but a reminder to use non-colliding test values when a real device might update concurrently.) Bothuschiandvpnare now fully migrated to dedicated per-host logins with no legacy exceptions remaining anywhere in the setup.vpnre-confirmed 2026-07-26: a second WAN IP change produced another clean authenticated update (87.180.9.214), this time with no test collision — TXT timestamp matches exactly. Bothuschiandvpnhave now each been confirmed live across a real IP change with their own dedicated login; this item is fully done, nothing further to track.jenspassword rotated 2026-07-26: since the account is now unused (no hostname or key left to act on) and its old password predates this whole migration, rotated it for hygiene viahtpasswd -b. Old.htpasswdbacked up to/root/removed-configs-backup/dyndns-jens-rotate-20260726124040/. Verified: new password authenticates fine (200), old password now gets401, and — as expected since the fix — even a successfuljenslogin still can't perform any DNS update (badauth). Account kept around (not asked to remove it), but fully inert either way. -
dyndns/default-vhost access logging is a no-op — done 2026-07-25 found 2026-07-20 while investigating the command injection below.
dyndns.jayfield.org,jayfield.org,www.jayfield.org,mail.jayfield.org,web.jayfield.org, andcloud.jayfield.orghad noCustomLogdirective of their own, andaccess.log(only written bydefault-ssl.conf/000-default.conf) had 0-8 lines — looking like no real vhost was being logged at all. Root cause (2026-07-25 re-check): the fallback assumption was wrong. There's no globalCustomLog access.loginapache2.conf; insteadconf-enabled/other-vhosts-access-log.confenablesmod_vhost_combined, which was already writing every vhost-without-its-own-CustomLog's traffic to/var/log/apache2/other_vhosts_access.log(5,432 lines, confirmed real traffic incl. bot scans againstcloud.jayfield.org). So requests were never actually un-logged — just commingled in one shared, harder-to-grep file instead ofaccess.log. Fixed anyway, since a dedicated per-site log remains the better end state for incident review: added an explicitCustomLog ${APACHE_LOG_DIR}/<site>_access.log combinedto every<VirtualHost>block fordyndns,jayfield.org,www,mail,web,cloud, andportainer(the:80and:443/le-sslblocks of each site share one log file). Configs backed up to/root/removed-configs-backup/vhost-customlog-20260725203959/./etc/logrotate.d/apache2already globs*.log, so no logrotate change needed. Validated withapache2ctl configtest, applied viasystemctl reload apache2, verified live withcurlagainst all 7 domains and confirmed each new<site>_access.logrecorded the hit. -
OS command injection in the dyndns update scripts — done 2026-07-20
/var/www/dyndns/nsupdate.php,nsupdate_ipv4.php, andnsupdate_ipv6.php(used by bothindex.phpandnic/update.php, the router/ddclient-facing endpoint) built a shell command string by directly interpolating unsanitized$_GETinput (hostname/myip/ip, which become$host/$ip/$ipv4/$ipv6) and passed it to PHP'sexec(), which runs it via/bin/sh -c— noescapeshellarg()/escapeshellcmd()anywhere. A request likehostname=$(id>/tmp/pwned).dyndns.jayfield.orgwould have executed arbitrary shell commands aswww-data(the domain-suffix check still passed after the.-split).nic/update.phphad no application-level auth of its own at all. Mitigating factor: the wholedyndns.jayfield.orgvhost sits behind Apache Basic Auth, so this needed valid (or leaked/brute-forced) dyndns credentials to reach — not exploitable by an anonymous visitor. Compounding factors found during the audit: PHP is7.4.33, EOL since Nov 2022,disable_functionswas empty (execfully available);index.php'spass == '_NO_PASS_'check is a hardcoded magic string, not real per-user auth, so any valid dyndns credential can update any of the zone's 5 hostnames (jens,kack,test,uschi,vpn), not just its own — left as-is, out of scope for this fix; and the vhost's access logging turned out to be a no-op (sharedaccess.loghas 0 lines), so past exploitation couldn't be fully ruled out from logs alone — though file mtimes,www-dataprocesses/crontab, and zone contents all looked unremarkable, consistent with unexploited. Fixed: all three files backed up to/root/removed-configs-backup/dyndns-preinjectionfix-<timestamp>/, then rewritten to build the nsupdate command list as a plain string written toproc_open()'s stdin pipe using the array form of the command (array("/usr/bin/nsupdate", "-k", ...)) — this never invokes a shell at all, so no value can be interpreted as shell syntax regardless of content. Added strictpreg_matchvalidation requiring$host/$domainto be shaped like a valid hostname (letters/digits/hyphens, dot-separated labels) andfilter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_IPV4/IPV6)on the IP before use, as defense in depth on top of removing the shell. Regression found and fixed along the way: functional testing showed legitimate updates were already silently failing (update failed: REFUSED,permission deniedreading the TSIG key) — turned outwww-datawas never granted read access to/etc/bind/dyndns.jayfield.org.keywhen its permissions were tightened to640 root:bindin the item below, so the dyndns web update feature had been broken for real users since 2026-07-19, independent of this vulnerability. Fixed with a narrowly-scoped POSIX ACL (setfacl -m u:www-data:r /etc/bind/dyndns.jayfield.org.key,aclpackage installed) rather than addingwww-datato thebindgroup — the group also ownsrndc.key(full remote BIND control), which would have been a much bigger privilege grant than dyndns needs. Verified viasudo -u www-data phpdriving the real functions directly (same code path as the web UI, without needing the Basic Auth password): two injection payloads ($(...)and;-chained) both rejected (-1, no/tmpside-effect file created), an invalid IP rejected (-1), and a legitimate update succeeded (0), confirmed live in the zone viadig, then cleaned up (test record deleted viansupdate, temp test script removed). Also confirmed unauthenticated requests still get401from Apache before ever reaching the PHP, andapache2ctl configteststayed clean throughout. -
Public static site (
jayfield.org/www.jayfield.org) was 401'ing every visitor — done 2026-07-20 Found while auditing which services are reachable without credentials: a stray/var/www/html/.htaccess(AuthType Basic, sameAuthName "Restricted Content"/AuthUserFile /etc/apache2/.htpasswdas the intentionaldyndns.jayfield.orgprotection) was silently requiring login for the DocumentRoot bothjayfield.organdwww.jayfield.orgshare —/var/wwwhasAllowOverride all, so it took effect with no vhost-level directive needed. File was dated 2025-02-18 (index.html/apache.htmlthemselves are from 2022) — predates every other change in this doc by well over a year, almost certainly a forgotten leftover rather than intentional, and contradictsREADME.md's own description of both as a public static site. Fixed: moved to/root/removed-configs-backup/htaccess-var-www-html.<timestamp>(recoverable, not deleted — same convention as this file's other removed-config backups). No Apache reload needed (.htaccessis read per-request). Verified bothjayfield.organdwww.jayfield.orgnow return200with real page content;dyndns.jayfield.org's own (intentional, vhost-level) Basic Auth is untouched and still returns401without credentials. -
Lock down the dyndns TSIG key file permissions — done 2026-07-19
/etc/bind/dyndns.jayfield.org.keywas644(world-readable), holding the shared secret that authenticates dynamic DNS updates fordyndns.jayfield.orgwith no IP-basedallow-updatebacking it up. Fixed:chmod 640+chown root:bind(owner was already correct), matchingrndc.key's permissions. -
Suppress BIND version disclosure — done 2026-07-19 No
versionstatement innamed.conf.options, sodig CH TXT version.bind @ns1.jayfield.orgleaked the exact BIND version (9.18.39) — a minor fingerprinting aid for attackers. Fixed: addedversion "unknown";inside theoptions {}block, validated withnamed-checkconf, and applied viarndc reload. Verifieddig CH TXT version.bindnow returns"unknown". -
Add response rate limiting (RRL) — done 2026-07-19 No
rate-limit {}block was configured. Sinceallow-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: addedrate-limit { responses-per-second 10; window 5; }tonamed.conf.options, validated withnamed-checkconf, and applied viarndc reload. Started with conservative defaults (10/s, 5s window) — revisit if legitimate high-volume resolvers get throttled. -
Add HSTS headers to all vhosts — done 2026-07-19 No vhost sent a
Strict-Transport-Securityheader, leaving a window for on-path SSL-stripping on a user's very first plaintext request to any domain. Fixed: addedHeader always set Strict-Transport-Security "max-age=63072000; includeSubDomains"to all 6 domain:443blocks (dyndns,jayfield.org,www,mail,web,cloud) — skippeddefault-ssl.confsince it's the self-signed catch-all with no real trusted hostname for a browser to pin. Validated withapache2ctl configtest, applied viasystemctl reload apache2, verified live withcurl -sIagainst all 6 domains. -
jayfield.orghad no:80vhost of its own — done 2026-07-19 Barehttp://jayfield.orgfell through to thedyndnsdefault vhost and redirected to the wrong domain. Fixed: added a:80block tojayfield.org.confredirecting tohttps://jayfield.org/, matching thedyndns/mailpattern. Verified:curl -sI http://jayfield.org/→302withLocation: https://jayfield.org/. -
SSH: disable root login + password auth — done 2026-07-19
PermitRootLogin yesandPasswordAuthentication yeswere both still enabled (the "known deviation" flagged inSETUP.md§1) — every credential-stuffing bot on the internet got a real login prompt to grind against. Fixed:PermitRootLogin no,PasswordAuthentication no,PubkeyAuthentication yesin/etc/ssh/sshd_config(backed up assshd_config.bak.20260719225249). TheMatch Group sftpblock's ownPasswordAuthentication yeswas left as-is — separate, already-chrooted, no-shell/no-forwarding accounts, not part of this risk. Validated withsshd -t, applied viasystemctl reload sshd, verified live withsshd -Tand a fresh key-only connection before considering it done. -
Portainer's
:9443UI is unreachable in HSTS-enforcing browsers — done 2026-07-20https://alpha.jayfield.org:9443threwMOZILLA_PKIX_ERROR_SELF_SIGNED_CERTin Firefox with no click-through option ("keine Ausnahme kann hinzugefügt werden"). Root cause: theincludeSubDomainsHSTS policy sent by the realalpha.jayfield.orgvhost (Strict-Transport-Securityheader, added in the item above) pins the hostname to HTTPS-with-a-trusted-cert on every port, but Portainer (README.md§Docker services) was still serving its stock self-signed cert directly on9443. Not a MITM — self-inflicted by combining HSTS with an unproxied self-signed service. Fixed: added aportainerA record todb.jayfield.org(SOA serial2022032216→2022032217); recreated theportainercontainer binding the HTTPS UI to127.0.0.1:9443:9443instead of publishing it (old container kept renamed until the new one was verified, then removed, matching the update procedure documented inREADME.md); added an Apache vhost forportainer.jayfield.org(ProxyPass/ProxyPassReversetohttps://127.0.0.1:9443/,SSLProxyEngine on,SSLProxyVerify none+SSLProxyCheckPeerCN off/SSLProxyCheckPeerName offfor the loopback hop to Portainer's self-signed cert) — same pattern asweb.jayfield.org/cloud.jayfield.org(SETUP.md§4/§5); issued a real cert viacertbot --apache -d portainer.jayfield.org --redirect; added the standard HSTS header. Left the8000edge-agent tunnel port published as-is — no evidence it's in use. Verified:openssl s_client/curlconfirm the Let's Encrypt cert (not self-signed) andStrict-Transport-Securityheader onhttps://portainer.jayfield.org,http://redirects 301 tohttps://, andss -ltnpshows9443bound to127.0.0.1only (viadocker-proxy), so it's no longer reachable on the public interface at all — access is now exclusively viahttps://portainer.jayfield.org. One transient wrinkle during rollout: the very first few requests through the new proxy vhost returned a stray401with dyndns's Basic Auth realm ("Restricted Content") — looked alarming, butLogLevel trace8on the vhost showed Apache correctly SNI-matching and proxying to the new container with a clean backend200; resolved itself within seconds (looked like a stale pooled proxy connection surviving the container recreate) and was stable across repeated checks before the debug logging was removed again. -
fail2ban:
sshdjail was evadable via low-and-slow attempts — done 2026-07-19 Defaultfindtime=30m/maxretry=3never triggered for47.76.192.176, which had been hittingroot/jayfieldlogins 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 viafail2ban.log(Found/Bancounts) that no other jail showed the same per-IP timing pattern; a separate, distributed single-shot scan onapache-noscriptwas noted but left open (seeREADME.md). Fixed:/etc/fail2ban/jail.d/sshd-findtime.localoverridessshdtofindtime=1d,maxretry=4,bantime=1d, kept in its own drop-in rather than editing the Debian-packaged jail file. Applied viafail2ban-client reload sshd;47.76.192.176was banned on its very next attempt after the change. -
fail2ban:
dovecotjail had the same low-and-slow gap assshd— done 2026-07-25 found while auditing which IPs were hitting webmail hard enough to deserve a ban but weren't in any currently-banned list. Same defaultfindtime=30m/maxretry=3assshd's pre-fix config. At least 4 IPs (47.95.196.60167 hits,82.157.132.42144,120.48.118.13276,188.164.195.11657,157.245.38.2634) ran a coordinated IMAP password spray against generic mailbox names (admin@,test@,support@,sales@jayfield.org) starting almost simultaneously around 2026-07-21 22:40-23:38, each pacing itself at ~1 attempt/22-25min — just outside the 30-minute window, so none ever tripped 3 strikes.120.48.118.132was still active as late as 2026-07-25 13:32, so the campaign was ongoing, not historical.postfix-saslshares the same default thresholds and mail stack but showed much lower per-IP volume in this pass — worth a follow-up check if it starts trending the same way. Follow-up 2026-07-26: checkedpostfix-sasl— still just scattered background noise, not a coordinated campaign. Top offender (172.94.9.203) logged 32 hits but all from late June/early July, none since; current activity is single/double attempts from many different IPs (no repeated per-IP low-and-slow pattern like the dovecot spray).findtime=30m/maxretry=3defaults left as-is — no fix needed unless this changes. Fixed:/etc/fail2ban/jail.d/dovecot-findtime.local, same values as thesshdfix (findtime=1d,maxretry=4,bantime=1d). Applied viafail2ban-client reload dovecot; verified live withfail2ban-client get dovecot findtime/maxretry/bantimeand confirmed the jail's existing ban state (47.88.56.201, 29 total bans) survived the reload. -
24.04 upgrade left PHP/Docker on stale jammy-built binaries — done 2026-07-26 Found during a docs/scripts recheck:
apt list --upgradableshowed 30 pending packages, alllibapache2-mod-php7.4/php8.0(ondrej/phpPPA, used by the dyndns app) and Docker CE, still installed as+ubuntu22.04.1+deb.sury.org/ jammy-tagged builds even though the OS itself moved to noble on 2026-07-19/26. Root cause:do-release- upgradecorrectly repoints third-party PPA sources at the new codename (apt-cache policyalready showed noble candidates available), but doesn't itself pull down matching rebuilt packages for them — that needs a follow-upapt full-upgradeafter the release upgrade completes, which hadn't been re-run. Fixed:sudo apt full-upgrade -y— clean run, "No services need to be restarted", "No containers need to be restarted". Verified:apt list --upgradablenow empty;sanity-check.shfull pass (48/48, 0 warn/fail) immediately after; all 6 Docker containers confirmed running (they did restart automatically per their own--restart=always, ~22s uptime at check time) and every public vhost including a genuine Gitea API check returned its expected status code;dyndns.jayfield.orgstill401s as expected (PHP module reload didn't disturb Basic Auth).SETUP.mdupdated to a 24.04 baseline and to document the two previously-undocumented third-party repos (ondrej/php,rspamd.com) a fresh build actually needs, plus this exact "release upgrade ≠ full upgrade" gotcha for next time.