Files
automations/README.md
T
57_WolveandClaude Opus 5 a3843d3d85 fix(ssh): build KexAlgorithms from what OpenSSH supports, add classic opt-in
Hardened hosts rejected clients that implement the very same key exchange. The
KEX list was assembled from version arithmetic and emitted only the
standardised spellings:

    KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512

OpenSSH called that hybrid sntrup761x25519-sha512@openssh.com before the method
was standardised (8.5, in the default proposal from 8.9) and
sntrup761x25519-sha512 after (9.9), and KEXINIT matches names byte-exactly with
no alias resolution -- so every client older than the rename was refused with
"no matching key exchange method found" despite implementing the algorithm. The
same arithmetic was a latent server-side bug: on OpenSSH 9.0-9.8 it wrote the
post-standardisation name into sshd_config, which those builds do not know, and
sshd fatals on an unknown KexAlgorithms token rather than starting.

Ask the binary instead of guessing. oslib gains kex_supported(),
ssh_kex_pq_list(), ssh_kex_classic_list(), ssh_kex_list() and ssh_kex_has_pq(),
which filter candidates through `ssh -Q kex` and offer every spelling the host
actually has. Version thresholds are gone, and with them both failure modes --
including on distros whose backports make the version string meaningless.

SSH_ALLOW_CLASSIC_KEX=1 (off by default) additionally offers curve25519-sha256
and its @libssh.org spelling. Some clients have no PQ method at all: notably
Windows' in-box ssh.exe, which is not merely old -- Microsoft's fork compiles
sntrup761 out because it needs C99 VLAs that MSVC lacks, so even a fully patched
9.5p2 reports zero PQ methods. The knob is a real trade and says so in the
warning, the generated sshd_config comment, and the README: such a session is
safe against a classical attacker but has no store-now-decrypt-later protection.
Modern clients still negotiate PQ, since the client's preference order decides.

Three defects found reviewing the above, fixed here:

- the printed pre-reload verification command pinned the server's full list via
  `-o KexAlgorithms=`, which ssh rejects at option-parse time when the client
  lacks any one name. That made the one safety gate before a wholesale
  sshd_config swap a false negative for exactly the clients this commit admits.
  Dropped, matching harden-jumphost.sh.
- the no-PQ branch was unreachable: without the opt-in the classical names are
  never collected, so a host with no PQ hybrid died reporting "no usable key
  exchange method" instead of the actionable message written for it. The branch
  now keys off a separate PQ probe, and the empty-list die is narrowed to a
  genuinely empty `ssh -Q kex`.
- SSH_VER is cosmetic but its grep could abort the whole run under pipefail on
  any banner that does not match (vendor forks, OpenSSH_for_Windows_9.5p2) --
  silently, with no message. Guarded.

Wired through cloud-init/base.yml and jumphost.yml, since harden-ssh.sh rewrites
sshd_config wholesale on every run and a hand edit there does not survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:03:07 -05:00

403 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# automations
Deployment and automation scripts for self-hosted, security-hardened
infrastructure. The host-provisioning scripts run on **Alpine, Debian, and
Alma Linux** (every distro difference is gated in
[`scripts/oslib.sh`](scripts/oslib.sh)); each Docker stack runs behind Caddy
with automatic Let's Encrypt TLS, orchestrated with Docker Compose.
> **About this repo.** This started as years of personal notes and one-off
> scripts I'd accumulated running my own infrastructure. I worked with Claude
> to clean them up, make them consistent and multi-distro, and add some
> quality-of-life options — so friends can use them too, and build on them.
> I'm still working through my collection and adding more as I go, so expect
> this to keep growing. Treat it as a starting point: fork it, wire in your own
> domains/keys, and send improvements back. PRs and ideas welcome.
## One command to run anything
```bash
curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/automations.sh \
| REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git sh
```
Or, from a clone:
```bash
./automations.sh
```
Pipe it to `sh`, not `bash` — a stock Alpine box has busybox `ash` and no bash
at all. The launcher's prologue is POSIX sh: it installs `git`/`bash` for the
distro it finds itself on (apk / apt-get / dnf / yum), then re-execs itself
under bash. `./automations.sh` from a clone does the same, so a bare Alpine
install needs nothing prepared by hand.
[`automations.sh`](automations.sh) opens a **Gum** wizard (auto-installed) that
lets you:
- **Deploy on this host** — pick any deployment or generic script and run it,
prompting for the values it needs.
- **Build artifacts locally** — regenerate a deployment's self-contained
`deploy.sh` and optionally `scp` it to a target host.
Shared defaults (email, repo URL, SSH key source, backup recipient) come from
[`globals/`](globals/) so you set them once.
## Self-contained bundle (no repo access)
For hosts that shouldn't have repo/git access, package the whole repo into one
self-extracting script and serve it from a webserver or a **public Gitea
release** (the repo itself can stay private):
```bash
./build-bundle.sh # -> dist/automations-bundle.sh (HEAD)
./build-bundle.sh v1.2.0 # a specific tag
./build-bundle.sh worktree # current working tree
```
On the target host, download then run it (it extracts itself — it can't read a
pipe, so download first):
```bash
curl -fsSLO https://your-host/automations-bundle.sh
sh automations-bundle.sh # launcher wizard
sh automations-bundle.sh bash scripts/setup-host.sh # run one script
SSH_PORT=2222 sh automations-bundle.sh bash scripts/harden-jumphost.sh
```
Like the launcher, the bundle's stub is POSIX sh and installs `bash` if the
host lacks it, so it runs on a bare Alpine box.
It extracts to `INSTALL_DIR` (default `/opt/automations`) and runs the launcher
or the command you pass. The payload excludes ignored files, so no secrets are
embedded.
**Releases via Gitea Actions:** pushing a `vX.Y.Z` tag runs
[`.gitea/workflows/release.yml`](.gitea/workflows/release.yml), which builds the
bundle and attaches it to a release. Create a secret named **`TOKEN_GITEA`**
(a Gitea access token with `write:repository`) — note Gitea reserves the
`GITEA_` prefix, so the secret can't be called `GITEA_TOKEN`. Adjust `runs-on`
to match your runner. Mark the release public and `automations-bundle.sh` is
fetchable without repo access.
## Layout
The rule: **generic, run-anywhere scripts live in `scripts/`; deployment-specific
files live under `deployments/<name>/`.** Shared values live in `globals/`.
```
automations.sh # the launcher (one-liner entry point)
cloud-init/ # generic base + jumphost cloud-init (any of the 3 distros)
globals/ # shared assets: age-pubkey, authorized_keys, motd, globals.env
scripts/ # generic scripts (Alpine/Debian/Alma)
deployments/<name>/ # one folder per stack
```
### `scripts/` — generic (Alpine / Debian / Alma)
| Script | What it does |
|--------|--------------|
| [`setup-host.sh`](scripts/setup-host.sh) | Set hostname per the naming schema (derives FQDN + Node ID) and render the shared MOTD with auto-computed border spacing. |
| [`harden-ssh.sh`](scripts/harden-ssh.sh) | SSH hardening: post-quantum hybrid KEX (every spelling the host's OpenSSH supports; `SSH_ALLOW_CLASSIC_KEX=1` adds a curve25519 fallback for clients with no PQ method), fresh Ed25519 host keys, key-only auth, external SFTP subsystem, sshguard. |
| [`harden-jumphost.sh`](scripts/harden-jumphost.sh) | Bastion hardening on top of `harden-ssh`: `ssh-admins` (shell) vs `ssh-jumpers` (ProxyJump-only) with a PermitOpen allow-list. |
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default host firewall: **iptables** on Alpine/Debian, **firewalld** on Alma/RHEL (set `FW_BACKEND` to override), **skipped on Proxmox** (`pve-firewall` owns the ruleset). Loopback, established, ICMP, SSH (configurable port) + registered ports; persisted natively (no boot hook). Same `allow`/`deny`/`list`/`disable` sub-commands on both. |
| [`sshuser.sh`](scripts/sshuser.sh) | Add/edit/remove SSH users on a hardened jump host (Gum TUI or CLI flags). Installed standalone as `sshuser`. |
| [`ntfy-ssh-login.sh`](scripts/ntfy-ssh-login.sh) | `pam_exec` hook that posts SSH logins to ntfy (user, source IP, key used, best-effort jump target), gated by group. Config: [`ssh-notify.conf.example`](scripts/ssh-notify.conf.example). |
| [`auto-update.sh`](scripts/auto-update.sh) | Daily unattended package updates; reports (doesn't auto-jump) a new Alpine branch; reboot detection; ntfy summary. `install`/`run`/`uninstall`. |
| [`oslib.sh`](scripts/oslib.sh) | OS-abstraction layer (detection, package manager, init system, SFTP path, hostname, boot hooks, native firewall persistence, sshguard, login notifier). Sourced, not run. **One file holds every distro difference.** |
| [`lib.sh`](scripts/lib.sh) | Launcher helpers (`ensure_gum`, `load_globals`, `resolve_ssh_keys`); sources `oslib.sh`. Not run directly. |
### `deployments/` — per stack
| Deployment | What it is | Depends on |
|------------|------------|------------|
| [`pocket-id`](deployments/pocket-id/) | OIDC provider (Caddy + Anubis PoW gate + Pocket-ID). | — |
| [`beszel`](deployments/beszel/) | Server monitoring hub. OIDC via pocket-id (post-deploy). | pocket-id (OIDC, optional) |
| [`headscale`](deployments/headscale/) | Self-hosted Tailscale control server, OIDC login. | pocket-id (OIDC) |
| [`webfinger`](deployments/webfinger/) | Serves `/.well-known/webfinger` for OIDC discovery; redirects the rest. | pocket-id (issuer) |
| [`squid`](deployments/squid/) | SSL-bump caching forward proxy — static-content cache + TLS interception via a local CA. **The exception: a forward proxy, not a Caddy/LE site.** | — |
| [`copyparty`](deployments/copyparty/) | Portable file server — web UI/WebDAV behind Caddy, plus direct **SFTP** + **FTPS**. Ships a security-notices-aware updater. | — |
| [`simplex`](deployments/simplex/) | SimpleX SMP + XFTP relay with Tor hidden services + encrypted backups. | globals/age-pubkey.txt |
| [`openbao`](deployments/openbao/) | Hardened tape-encryption key store for **Kanrisha** (the LTO tape-archive system — separate repo/host). **Exception: native TLS on the LAN, no Caddy/LE.** raft storage, mlock, self-signed or Smallstep-ACME cert, manual/HSM unseal. | globals/age-pubkey.txt (backups) |
| [`ergo`](deployments/ergo/) | IRC server ([Ergo](https://ergo.chat)) with Caddy for Let's Encrypt + the websocket endpoint. **Both containers use host networking** (real client IPs for bans/cloaking; the host firewall applies). Persistent message history via SQLite (default) or an optional PostgreSQL container. Ships `ergoctl` (admin CLI) and a health-checked updater that also syncs Caddy's cert into Ergo. | — |
## Conventions
- **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack, with two
exceptions: `squid` (a forward proxy with a local TLS-interception CA) and
`openbao` (a same-LAN secrets store with native TLS) — neither uses Caddy/LE.
`ergo` keeps Caddy/LE but runs both containers in the **host network
namespace** (IRC needs real client IPs), so it has no published ports.
- **`build.sh``deploy.sh`**: each stack's `build.sh` embeds its
`docker-compose.yml` / `Caddyfile` / `.env.example` into a single
self-contained `deploy.sh` (base64 tar.gz). That one file can be `scp`'d to a
host and run on its own. Rebuild after editing the loose files.
(simplex is the exception — it deploys via `install-simplex.sh`.)
- **Secrets** live in `.env` (generated on first deploy) and `globals/globals.env`,
both git-ignored. Only `*.example` templates and public keys are committed.
- **Non-interactive**: every `deploy.sh` honors `SKIP_PROMPTS=1` with values
supplied via the environment — which is how the per-deployment
`cloud-init.yml` templates stand a stack up unattended.
## Cloud-init
- **Provision a host**: [`cloud-init/base.yml`](cloud-init/base.yml) (hostname +
MOTD + SSH hardening) or [`cloud-init/jumphost.yml`](cloud-init/jumphost.yml)
(bastion). Distro-agnostic — they install prerequisites for whatever the image
is, then run the scripts.
- **Stand up a stack**: each deployment ships its own `cloud-init.yml` (e.g.
[`deployments/pocket-id/cloud-init.yml`](deployments/pocket-id/cloud-init.yml)).
These assume a fresh VM, so they **harden SSH first** (`harden-ssh.sh`,
`HARDEN_SSH=1` by default) and then deploy the stack.
Fill in `REPO_URL` and the values at the top of the `runcmd` block, paste as
instance user-data, and the host configures itself on first boot.
## Multi-OS notes
The host-provisioning scripts (`setup-host`, `harden-ssh`, `harden-jumphost`,
`sshuser`) and the nine Docker stacks (pocket-id, beszel, headscale, webfinger,
squid, copyparty, simplex, openbao, ergo) run on Alpine, Debian, and Alma. Distro differences live in
[`scripts/oslib.sh`](scripts/oslib.sh) — package manager (`apk`/`apt`/`dnf`),
init system (OpenRC/systemd), sshd service name, the per-distro `sftp-server`
path, hostname, boot hooks, and the sshguard log source/backend.
**simplex** remains **Alpine-targeted** — it depends on `awall` and Tor hidden
services with Alpine-specific wiring, so it isn't part of the tri-distro set.
### Key exchange and old clients
The KEX list is built from what the host's OpenSSH actually supports (`ssh -Q kex`),
not from its version number, and it offers **every spelling** of each algorithm.
That matters: OpenSSH called the same hybrid `sntrup761x25519-sha512@openssh.com`
before the method was standardised and `sntrup761x25519-sha512` after, and SSH
matches algorithm names as exact strings — so a server offering only the new
spelling rejects a client implementing the identical algorithm under the old one,
with `Unable to negotiate ... no matching key exchange method found`.
Client floor for a PQ-only host, from the OpenSSH release notes:
| Client | Result |
|---|---|
| 9.9+ | works — knows both spellings, and ML-KEM |
| 8.9 9.8 | works — its default proposal carries `sntrup761x25519-sha512@openssh.com` |
| 8.5 8.8 | has the algorithm but does **not offer it** by default; needs `KexAlgorithms +sntrup761x25519-sha512@openssh.com` client-side |
| < 8.5 | no PQ hybrid exists — locked out by design |
| **Windows in-box `ssh.exe`** | **locked out at every version, including a fully-patched 9.5p2** |
The Windows case is not an age problem, which makes it easy to misdiagnose: Microsoft's
fork **compiles `sntrup761` out**, because it needs C99 variable-length arrays that MSVC
does not support (Win32-OpenSSH #2140, #2391). So a fully-patched Windows 11 reports
`OpenSSH_for_Windows_9.5p2` — new enough on paper — and still lists zero PQ methods in
`ssh -Q kex`. Two ways out, in order of preference:
1. **Use a client that has PQ.** Check what is already on the box before installing
anything — Git for Windows bundles genuine upstream OpenSSH:
```powershell
& "$env:ProgramFiles\Git\usr\bin\ssh.exe" -Q kex | Select-String 'mlkem|sntrup'
```
If that prints anything, use that binary and install nothing. Otherwise
`winget install --id Microsoft.OpenSSH.Preview` (10.0.0.0p2 added both algorithms).
**`Add-WindowsCapability` does not help** — it installs the same in-box 9.5p2. Mind
the `PATH` order: the MSI appends `C:\Program Files\OpenSSH`, but
`C:\Windows\System32\OpenSSH` is already ahead of it, so a bare `ssh` still resolves
to the old binary. Confirm with `(Get-Command ssh).Source`.
2. **`SSH_ALLOW_CLASSIC_KEX=1`** — also offer `curve25519-sha256` (and its
`@libssh.org` spelling). Off by default. A session that negotiates it is secure
against a classical attacker but has **no** store-now-decrypt-later protection.
Modern clients still pick a PQ method, because the client's preference order
decides. Set it per host rather than fleet-wide, and drop it once the old client
is gone.
> `harden-ssh.sh` rewrites `/etc/ssh/sshd_config` wholesale on every run, so hand
> edits to that file do not survive a re-run. Use the knob, not `sed`.
## Host firewall
[`scripts/harden-firewall.sh`](scripts/harden-firewall.sh) installs a
**deny-by-default** baseline, with the backend chosen per family (override with
`FW_BACKEND=iptables|firewalld`):
- **Alpine / Debian → iptables.** `INPUT` drops everything except loopback,
established/related, ICMP, and SSH on the configured port — plus any ports a
deployment registers.
- **Alma / RHEL → firewalld** (its native firewall). The default zone is already
deny-by-default; we strip the stock `ssh`/`cockpit` services, open SSH +
registered ports, and let sshguard block via the `sshguard-firewalld` backend
(no `INPUT → sshguard` jump needed).
- **Proxmox → nothing. The host is skipped** — and left with no host firewall
until you enable Proxmox's own. See below.
`OUTPUT`/egress stays open and `FORWARD` is left untouched, so Docker container
networking is unaffected. The harden scripts and `cloud-init/base.yml` /
`jumphost.yml` install it automatically (`ENABLE_FIREWALL=1` by default).
- **Configurable SSH port** — read live from `sshd_config`, so a bastion on
`2222` is firewalled correctly with no extra flags. Restrict the source with
`FW_SSH_SOURCE=<cidr>`; drop ping with `FW_ALLOW_PING=0`.
- **Native persistence, no boot hook** — on iptables hosts the ruleset is saved
and restored by the distro's own package: `iptables` + `ip6tables`
(Alpine/OpenRC) or `iptables-persistent` (Debian); the saved ruleset carries
the `INPUT → sshguard` jump. On firewalld hosts every change is `--permanent`,
so it persists across reboot natively and sshguard manages its own blocks.
- **Scripted additions** — deployments drop a rule file and re-apply:
```sh
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule
/usr/local/sbin/firewall-apply
```
or interactively: `harden-firewall.sh allow 443/tcp 51820/udp` /
`allow web` / `deny 51820/udp` / `list`.
- **Docker caveat** — containers published with `-p` (e.g. Caddy's 80/443)
reach the host through nat/`FORWARD` and **bypass `INPUT`**, so the firewall
neither blocks nor needs to open them; the per-stack rule files are
belt-and-braces for any host-bound bind and self-documentation. The one
stack that runs with `network_mode: host` (`ergo`) binds on the host
directly, so there the registered ports are the real gate.
- **Recovery** — `harden-firewall.sh disable` un-locks you: on iptables it
flushes the rules and sets `INPUT` back to `ACCEPT` (persisted); on firewalld it
re-opens SSH (the `ssh` service + the configured port) and leaves firewalld
running. A re-apply never drops the live SSH session — on iptables the
established-connection accept is added before the policy flips to `DROP`, and
firewalld reloads preserve established connections.
### Proxmox hosts are skipped
Proxmox VE and Proxmox Mail Gateway are Debian underneath, so everything else in
this repo treats them as Debian — but they already ship a firewall, and
`pve-firewall` owns the host ruleset. The conflict is not the one you'd expect:
`pve-firewall` does **not** delete third-party rules. It restores with
`iptables-restore -n` (`--noflush`), only ever flushes chains matching its own
patterns (`PVEFW-*`, `tapNiM-*`, `vethNiM-*`, `fwbrN-*`, `GROUP-*`), *appends*
`-A INPUT -j PVEFW-INPUT` only when that hook is missing, and never sets a
built-in chain's policy. Our rules would survive it fine. The damage runs the
other way — **we break Proxmox**:
- `-P INPUT DROP` is ours alone, and `PVEFW-HOST-IN` **returns** on accept rather
than accepting (it still has to check the tap rules), so traffic Proxmox
explicitly allowed falls out of its chain and lands on our `DROP`. We silently
override the platform's own accepts;
- the persistence layer is worse: `netfilter-persistent` restores at boot with a
full `iptables-restore` (no `--noflush`), wiping PVE's `-j PVEFW-INPUT` hook
along with everything else until the daemon re-appends it on its next ~10s pass;
- a deny-by-default chain has to enumerate the whole platform to stay usable:
`8006/tcp` web UI, `5405-5412/udp` corosync (5405 + knet link number, up to 8
links — the bare `5405` is the pre-6.x multicast-era number), `60000-60050/tcp`
migration, `5900-5999/tcp` VNC, `3128/tcp` SPICE, `22/tcp` SSH, `111/udp`
rpcbind with NFS storage, plus Ceph's `6789`/`3300`/`6800-7300` when
hyperconverged. Miss one and you lose the GUI or the cluster; miss `-i lo` and
`pveproxy` can't reach `pvedaemon` on `127.0.0.1:85`, breaking the API locally;
- under the opt-in **nftables** backend (PVE 8.2+), `proxmox-firewall` registers
its own nft input hook, and an nft `DROP` beats an iptables `ACCEPT` — our
rules wouldn't even be authoritative.
So `harden-firewall.sh` detects Proxmox (`is_proxmox` in `oslib.sh`: it looks
for `pve-firewall`/`pveversion`/`/etc/pve/nodes`) and does nothing — `apply`
explains and exits cleanly, `allow`/`deny` refuse loudly rather than pretend,
and `list` shows `pve-firewall status`. `harden-ssh.sh` / `harden-jumphost.sh`
skip the firewall too and install only the `INPUT → sshguard` boot hook, so
brute-force protection still works while the Proxmox firewall is off. Manage the
host firewall where Proxmox expects it — *Datacenter → Firewall* and
*Node → Firewall*, or the `.fw` files directly:
```
[RULES]
IN ACCEPT -p tcp -dport 443
```
`FW_IGNORE_PVE=1` forces our firewall on anyway. It will fight `pve-firewall`
and can lock you out of the GUI and the cluster; Proxmox Backup Server ships no
firewall of its own and is *not* detected, so it hardens as a normal Debian host.
> **Skipped is not the same as protected.** Proxmox's firewall is **off by
> default** — the cluster-wide `enable` in `cluster.fw` defaults to `0`, and while
> it is, the `pve-firewall` daemon actively tears its chains down every ~10 seconds,
> leaving `INPUT` at policy `ACCEPT` with no rules. Until you enable it at
> *Datacenter → Firewall → Options*, a Proxmox host has **no host firewall at all**
> and `8006`, `22`, `3128` and `111` are open on every interface. Don't be reassured
> by the *node* panel reading `Firewall: Yes` — that setting is ignored while the
> datacenter one reads `No`. On these hosts, do your filtering in Proxmox or upstream
> of it.
The `INPUT → sshguard` jump the harden scripts install *is* safe alongside
`pve-firewall`: it is inserted with `-I`, so it sits ahead of the appended
`PVEFW-INPUT` hook and keeps getting first look at new connections either way.
**Already hardened a Proxmox host?** Detection only helps hosts set up from now
on, so `disable` cleans up one that already has our firewall:
```sh
bash scripts/harden-firewall.sh disable
```
On Proxmox that sub-command checks for leftovers (`/etc/firewall`, the engine, or
a `DROP` policy) and, if it finds them, sets `INPUT` back to `ACCEPT` and flushes
it — policy first, so it never drops the SSH session you are running it over —
deletes `/etc/firewall` and `/usr/local/sbin/firewall-apply`, **disables** the
boot-restore service and renames the saved rulesets to `*.bak-harden-firewall`
(saving the open state, as the normal `disable` does, would snapshot
`pve-firewall`'s own `PVEFW-*` chains and restore that stale copy at the next
boot), re-inserts the `INPUT → sshguard` jump, and restarts `pve-firewall`. On a
host that was correctly skipped it finds nothing and says so. `apply` points you
at it when it spots leftovers.
## SSH login notifications
The harden scripts can install a `pam_exec` hook
([`scripts/ntfy-ssh-login.sh`](scripts/ntfy-ssh-login.sh)) that posts every SSH
login to an [ntfy](https://ntfy.sh) topic. Enable it by passing `NTFY_URL` (and
optionally `NTFY_TOKEN`) when running `harden-ssh.sh` / `harden-jumphost.sh`, or
via the launcher / jumphost cloud-init. Each alert reports:
- the **user** and **source IP**,
- the **SSH key** they authenticated with (fingerprint, via `ExposeAuthInfo`),
- a **region tag** so you know which bastion fired it (derived from the host's
FQDN, e.g. `us-evi-1`),
- best-effort the **next hop** of a ProxyJump (see caveat below).
Filtering is by group: `NOTIFY_GROUPS` limits alerts to certain
groups/security levels, and `NOTIFY_PRIORITY_MAP` sets a per-group ntfy
priority. Config lives at `/etc/ssh-notify.conf` (mode 0600;
[`scripts/ssh-notify.conf.example`](scripts/ssh-notify.conf.example) documents
every key). A publish token is optional — leave it empty for a read-gated topic.
> **Jump-target caveat:** a ProxyJump opens a *direct-tcpip* channel (no
> session), so the destination never reaches `pam_exec`. The bastion only logs
> it at `LogLevel VERBOSE`/`DEBUG`; `harden-jumphost.sh` sets `VERBOSE` and the
> notifier parses the log best-effort. If the target isn't in the log it is
> simply omitted.
## Daily updates
[`scripts/auto-update.sh`](scripts/auto-update.sh) keeps a host patched
unattended — ideal for an SSH-only bastion, where a routine upgrade can barely
break anything. `harden-jumphost.sh` schedules it **by default** (set
`AUTO_UPDATE=0` to skip); `harden-ssh.sh` takes `AUTO_UPDATE=1`. It runs
daily via busybox `crond` (`/etc/periodic/daily`) on Alpine or a systemd
timer on Debian/Alma.
Each run:
- applies all **in-branch** package upgrades (`apk`/`apt`/`dnf`);
- **reports** a new Alpine *branch* (e.g. 3.21 → 3.22) by default — that
rewrites the repo branch, so it's opt-in. Set `ALLOW_RELEASE_UPGRADE=1` to
also **apply** it: it repoints `/etc/apk/repositories` to the newest **stable**
`vX.Y` (never `edge`), runs `apk upgrade --available`, and forces a reboot
flag. Debian/Alma stay report-only;
- detects when a **reboot** is needed (kernel/libc/openssl). `AUTO_REBOOT`
controls it: `0` = never (just flag), `1` = always, **`idle` = only when no
SSH connections are active** — so a bastion reboots itself once the admins and
ProxyJump tunnels have cleared, never mid-session. `harden-jumphost.sh`
defaults the bastion to `idle`. A deferred reboot is tracked in `/run` and
retried each day until it happens;
- sends an ntfy summary (reusing `/etc/ssh-notify.conf`).
Schedule it standalone with `auto-update.sh install` (or via the launcher), run
a pass now with `auto-update.sh run`, and preview safely with
`DRY_RUN=1 auto-update.sh run`.
## License
[MIT](LICENSE).