Two Alpine-only failures from an irc-1 run.
1. harden-firewall.sh died with
line 161: /usr/local/sbin/firewall-apply: No such file or directory
and left INPUT unfiltered. Alpine's baselayout ships /usr/local/{bin,lib,
share} but no sbin, and nothing created it. Create it before writing the
engine; same guard in oslib's install_boot_hook / install_daily_job (which
`install` into /usr/local/sbin, and `install` does not make parent dirs) and
for /usr/local/bin in harden-jumphost.
2. The ntfy login notifier never fired despite reporting ACTIVE. Alpine keeps
PAM support in a SEPARATE binary: openssh-server gives /usr/sbin/sshd (no
PAM), openssh-server-pam gives /usr/sbin/sshd.pam. The OpenRC init only
picks the PAM one in start_pre (checkconfig -> update_command), and its
reload/stop match the process with `start-stop-daemon --exec "$command"`.
So a host already running /usr/sbin/sshd never swaps: reload signals a
non-matching process, stop matches nothing, a later start hits "address
already in use". sshd keeps serving without PAM, so /etc/pam.d/sshd -- and
the pam_exec hook in it -- is never consulted.
Add oslib sshd_wanted_binary / sshd_running_binary / sshd_apply_config, and
end the harden scripts with sshd_apply_config instead of svc_reload: reload
as before, but on Alpine stop by pidfile and start again when the running
binary isn't the one the config calls for. Established sessions are separate
processes and survive; only the listener blinks.
install_login_notifier now checks that PAM can actually be reached on Alpine
(sshd.pam present, UsePAM yes) and warns with the fix instead of reporting a
hook that can never run as ACTIVE -- the same "silent notifier" trap the
Alma run hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a fresh AlmaLinux 9.8 box, install_bruteforce_protection ran unguarded under
'set -euo pipefail'. When sshguard (from EPEL) wasn't installable at that moment,
the single failed dnf aborted the ENTIRE harden run before it wrote sshd_config
or installed the pam_exec login notifier -- leaving a stock, unhardened box and a
silently-missing ntfy hook.
- oslib: install the iptables backend best-effort first, then sshguard, and
return sshguard's status so callers can treat it as non-fatal.
- harden-ssh/harden-jumphost: install_openssh now dies with a clear message on
failure; sshguard is '|| warn' so sshd hardening and the notifier still apply.
- install_login_notifier verifies the script + pam hook landed and logs
'Login notifier ACTIVE' (or a loud warning) instead of failing silently.
- ntfy-ssh-login.sh: NTFY_DEBUG=1 logs delivery attempts + curl errors to
/var/log/ssh-notify.log so the next silent failure leaves a trace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a reusable iptables baseline that hardens hosts with ICMP + SSH
defaults and lets deployments register the ports they need. INPUT is
deny-by-default (loopback, established, ICMP, SSH on the configured port,
plus registered ports); OUTPUT stays open and FORWARD is left untouched so
Docker container networking is unaffected.
Persistence is native -- no boot hook. Rules are saved and restored by the
distro's own package (iptables/ip6tables on Alpine, iptables-persistent on
Debian, iptables-services on Alma) via the new oslib helpers
install_iptables / fw_save_cmd / fw_enable_restore. The saved ruleset
carries the INPUT->sshguard jump, so brute-force protection survives reboot
without the old sshguard-iptables hook.
A self-contained /usr/local/sbin/firewall-apply rebuilds INPUT from
declarative drop-ins under /etc/firewall/ports.d and runs the native save,
so deployments add a port without needing the repo present:
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule
/usr/local/sbin/firewall-apply
- SSH port read live from sshd_config (custom bastion ports just work);
FW_SSH_SOURCE restricts the source CIDR; FW_ALLOW_PING gates echo
- harden-ssh.sh / harden-jumphost.sh install it when ENABLE_FIREWALL=1
(default) and skip the sshguard-only hook; ENABLE_FIREWALL=0 keeps it
- cloud-init base.yml / jumphost.yml forward the toggle
- the four stack deploy.sh open_web_ports() register 80/443 via the
firewall (ufw/firewalld kept as fallback); Docker-published ports bypass
INPUT, so this is belt-and-braces and self-documenting
- README + cloud-init/README document the mechanism, Docker caveat, and the
`disable` recovery path
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.
- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea