Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

sshd scripts

Five scripts under tools/sshd/ audit sshd, keep it reachable under a connection flood, and stop or start it safely. The installation packages ship them at /yuneta/development/yunetas/tools/sshd/. They are not on the PATH.

The packages do not run them. How sshd behaves is the operating-system policy of the operator of the node. A package that installs the agent can land on a node of another company, so it does not change that policy. Run these scripts by hand, on a node that you operate. On a node of another company, run them only when its operator agrees.

Each script asks for root through sudo if you start it as another user.

T=/yuneta/development/yunetas/tools/sshd
ScriptWhat it does
audit-sshd.shReports failures and possible improvements. Changes nothing.
install-sshd-flood-guard.shKeeps sshd reachable under a connection flood.
install-fail2ban-sshd-jail.shMakes fail2ban watch sshd where the distribution does not.
stop-sshd.shStops sshd and arms a timer that starts it again.
start-sshd.shStarts sshd and cancels that timer.

Why a flood guard

On 2026-09-13 a password botnet sent 2,000-3,000 login attempts an hour to each node. Password login was off, so no attempt got in. But each connection held one of the sshd slots for unauthenticated connections. The stock sshd allows 10 of them. Past 10, sshd drops new connections at random, and the operator’s logins and the deploy tools’ rsync were dropped too: up to 1,464 drops an hour on one node. With the flood guard, the drops went to zero.

The flood guard does not stop brute force. Password login off stops brute force, and audit-sshd.sh reports a node where it is on. The real exposure is port 22 open to the world. The fix for that is a source allowlist (the provider’s firewall, or nftables), and in the end a sealed node with no inbound SSH.

audit-sshd.sh

Reads sshd -T, which prints the values that sshd uses, and reports what is wrong or can be better. It reads sshd -T and not sshd_config, because drop-ins, distribution defaults and crypto policies set the rest.

$T/audit-sshd.sh          # failures and improvements
$T/audit-sshd.sh --all    # also what is already correct

It checks:

Each finding has a level:

LevelMeaning
FAILLets an attacker in, or makes it easier. Fix it.
WARNA real weakness, or a flood that occurs now.
INFOA possible improvement. Compare it with how the node is used.
OKCorrect. Shown only with --all.

The exit status is 2 if there is a FAIL, 1 if there is a WARN, and 0 if not. You can use it in a script:

if ! $T/audit-sshd.sh > /tmp/sshd-audit.txt; then
    echo "sshd audit found problems: see /tmp/sshd-audit.txt"
fi

Example output:

== Authentication
[FAIL] PasswordAuthentication yes: a botnet can try passwords. Fix: PasswordAuthentication no, once key login is confirmed to work
[INFO] No AllowUsers/AllowGroups: every account with a shell can try. Listing the accounts that log in (e.g. AllowUsers yuneta) closes the rest

== Availability under a connection flood
[WARN] MaxStartups 10:30:100: past 10 unauthenticated connections sshd drops new ones AT RANDOM, yours included. See install-sshd-flood-guard.sh (50:30:200)

== Last hour in sshd's journal
[WARN] 412 connections dropped by MaxStartups/penalties in the last hour: legitimate logins lose that lottery too. See install-sshd-flood-guard.sh

1 FAIL, 2 WARN, 1 INFO.

sshd -T prints the global values. A Match block can change them for some users or addresses. The script tells you when there is a Match block. To see the values for one connection, give its user, host and address:

sudo sshd -T -C user=yuneta,host=deploy.example.com,addr=203.0.113.7

install-sshd-flood-guard.sh

Writes /etc/ssh/sshd_config.d/10-yuneta-ssh-flood.conf:

LoginGraceTime 20
MaxStartups 50:30:200
PerSourceMaxStartups 10
DirectiveStockEffect
LoginGraceTime 20120sshd closes a connection that has not authenticated in 20 s, so its slot is free sooner.
MaxStartups 50:30:20010:30:100sshd starts to drop at 50 unauthenticated connections, not 10, and drops all of them at 200.
PerSourceMaxStartups 10no limitOne address can hold 10 slots at most. This stops the few addresses that send many connections, not a flood from many addresses. Needs OpenSSH 8.5 or later.
$T/install-sshd-flood-guard.sh            # install or update
$T/install-sshd-flood-guard.sh --check    # show the values sshd uses, change nothing
$T/install-sshd-flood-guard.sh --remove   # remove the file

What it makes sure of:

sshd keeps the first value that it reads. To use a different value on one node, add a file with a lower number, for example /etc/ssh/sshd_config.d/05-local.conf:

MaxStartups 100:30:300

Then run sudo systemctl reload ssh (Debian) or sudo systemctl reload sshd (RHEL).

install-fail2ban-sshd-jail.sh

Writes /etc/fail2ban/jail.d/yuneta-sshd.conf:

[sshd]
enabled = true
backend = systemd

Debian enables the sshd jail in its own jail.d/defaults-debian.conf. RHEL and Rocky enable no jail. Thus a Rocky node can run fail2ban with nothing that watches sshd. If the distribution already runs an sshd jail, the script tells you and changes nothing.

backend = systemd is necessary because sshd logs to the journal. On EL9 it needs python3-systemd.

$T/install-fail2ban-sshd-jail.sh            # install
$T/install-fail2ban-sshd-jail.sh --check    # show the sshd jail
$T/install-fail2ban-sshd-jail.sh --remove   # remove the jail

The script runs fail2ban-client -t before it reloads fail2ban. A jail that fail2ban cannot configure stops the whole fail2ban server, with all its other jails. If the new jail causes the failure, the previous state comes back.

A jail does not stop a botnet, which uses thousands of addresses and a small number of attempts from each. It stops one address that sends many attempts.

stop-sshd.sh and start-sshd.sh

When sshd stops, port 22 closes, and a connection flood cannot get past a closed port. But on a node that you reach only with SSH, a stopped sshd can also lock you out. Thus stop-sshd.sh:

  1. Runs sshd -t. If it fails, the script stops nothing, because a timer cannot start an sshd that does not start.

  2. Arms yuneta-sshd-autostart.timer, a transient systemd timer that starts sshd again.

  3. Stops sshd only after the timer is armed. If the timer cannot be armed, the script stops nothing.

$T/stop-sshd.sh                 # stop now, start again in 60 minutes
$T/stop-sshd.sh 15              # stop now, start again in 15 minutes
$T/stop-sshd.sh --no-restart    # stop now, arm nothing
systemctl list-timers yuneta-sshd-autostart.timer   # when does it start again?

Use --no-restart only when you can get to the provider’s console.

Sessions that are already open stay open. Debian’s ssh.service and RHEL’s sshd.service stop only the listener (KillMode=process). The script warns you if the unit has a different KillMode.

While sshd is stopped, you can get to the node only without SSH: through the provider’s console, or through the agent’s control plane. From the console, start-sshd.sh starts sshd before the timer does:

$T/start-sshd.sh

It runs sshd -t first and shows the errors if there are errors. It starts sshd (and its socket unit, if that unit is enabled), makes sure that sshd runs, and cancels the timer.

On Debian, ssh.service creates the privilege separation directory /run/sshd when it starts, and systemd removes it when sshd stops. Without it, sshd -t fails with “Missing privilege separation directory: /run/sshd”, also when the configuration is correct. When sshd -t gives that message, all the scripts create the directory (mode 0755, as Debian’s own init script does) and run sshd -t again.