Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking for what shellcheck cannot see. shellcheck itself is clean at error level; its warnings are cross-file false positives and one deliberate tilde in a display string. Everything below is a real defect. ── The Git section aborted on any machine where git was not already configured ── `git config --global --get <key>` exits NON-ZERO when the key is simply unset, and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine — the case this script exists for — the section died at its first assignment, before printing anything, and took the remaining nine sections with it. It passed every earlier test because those harnesses sourced the section under a `bash -c` with no `set -e`. Verified now against a genuinely fresh account with the real script: the section completes and writes a correct .gitconfig. ── An optional step failing aborted the whole run ── Twelve functions ended on a command that can fail — `systemctl enable --now earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`, the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any one of them failing ends the script, so a masked unit or a container without systemd would abort a 28-section run over an optional improvement. They now return 0 explicitly and the callers verify the outcome instead — which also fixed a lie: the sleep section printed "sleep disabled, logind reloaded" whether or not the restart had worked. It now checks the targets and the logind values and reports honestly. ── chown user:user assumed the primary group is named after the user ── True on Debian and Ubuntu, which create a group per user. Not true for an account from LDAP, or made with `useradd -g users`, or on an image with a shared group — there `install -g <user>` fails with "invalid group" and the step aborts. Proved it against an account whose primary group is `oddgroup`: the old form fails, the new one gets ownership right. Eight call sites now ask `id -gn`. ── Also hardened ── agent_path and current_editor gained `|| true` for the same reason git_get needed it: "nothing is set" is an answer, not a failure. Verified afterwards: shellcheck clean at error level, every section runs standalone without aborting, and the two apparent failures in that sweep are correct behaviour — Timezone and Git refusing an empty answer from /dev/null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
6.7 KiB
Bash
164 lines
6.7 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# machine-setup — ssh keys and ssh hardening
|
|
# =============================================================================
|
|
#
|
|
# Definitions only, like the other lib/ files.
|
|
#
|
|
# ── Why the original's hardening did not work, and could not be seen not to ──
|
|
#
|
|
# It sed'd /etc/ssh/sshd_config directly. Two things make that wrong on a modern
|
|
# Ubuntu, and both fail silently:
|
|
#
|
|
# Ubuntu's sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` on line 12,
|
|
# and sshd takes the FIRST value it obtains for a keyword — not the last. Cloud
|
|
# images ship 50-cloud-init.conf containing `PasswordAuthentication yes`, which
|
|
# is read before anything further down the main file. So the sed edits a line
|
|
# sshd never reaches, the script reports "SSH hardened", and password login is
|
|
# still on.
|
|
#
|
|
# It also sed'd ChallengeResponseAuthentication, which OpenSSH renamed to
|
|
# KbdInteractiveAuthentication in 8.7. On 24.04 the old name appears nowhere in
|
|
# the file, so that substitution matched nothing at all.
|
|
#
|
|
# So the settings go in a drop-in named to sort FIRST — 01- beats 50-cloud-init —
|
|
# which is the only placement that actually wins under first-value-wins.
|
|
#
|
|
# ── And the reason it is dangerous ──
|
|
#
|
|
# Step 8 of the original could warn-and-skip (no ssh-keys.zip, or an unrecognised
|
|
# menu choice, since its case had no default arm) and still mark itself done.
|
|
# Step 9 then disabled password authentication and root login regardless. No key,
|
|
# no password, no root: locked out at the next disconnect, on a machine that may
|
|
# be in a datacentre. Nothing here disables password authentication without first
|
|
# confirming a usable key is in place.
|
|
|
|
[[ -n "${MACHINE_SETUP_SSH_LOADED:-}" ]] && return 0
|
|
MACHINE_SETUP_SSH_LOADED=1
|
|
|
|
SSHD_DROPIN=/etc/ssh/sshd_config.d/01-machine-setup.conf
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Keys
|
|
# -----------------------------------------------------------------------------
|
|
|
|
user_ssh_dir() { echo "${USER_HOME}/.ssh"; }
|
|
user_authorized_keys() { echo "${USER_HOME}/.ssh/authorized_keys"; }
|
|
|
|
# How many usable keys the account can log in with.
|
|
#
|
|
# Counted by asking ssh-keygen to parse the file rather than by counting lines:
|
|
# comments, blanks and a half-pasted key all look like lines, and "there is a
|
|
# file" is not the same fact as "there is a key that works".
|
|
authorized_key_count() {
|
|
local file
|
|
file="$(user_authorized_keys)"
|
|
[[ -r "$file" ]] || return 0
|
|
ssh-keygen -l -f "$file" 2>/dev/null | grep -c . || true
|
|
}
|
|
|
|
has_authorized_key() { (($(authorized_key_count) > 0)); }
|
|
|
|
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
|
|
# ignores an authorized_keys file that is group- or world-writable, and does so
|
|
# silently from the client's point of view — the login just fails.
|
|
fix_ssh_permissions() {
|
|
local dir
|
|
dir="$(user_ssh_dir)"
|
|
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
|
|
chmod 700 "$dir"
|
|
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
|
|
find "$dir" -maxdepth 1 -type f -name 'id_*' ! -name '*.pub' -exec chmod 600 {} +
|
|
chown -R "${USERNAME}:$(user_group)" "$dir"
|
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
|
# function that ends on a failing command is fatal under `set -e` when it
|
|
# is called as a plain command — which would abort the remaining sections
|
|
# over something the run could simply report. The caller checks the outcome.
|
|
return 0
|
|
}
|
|
|
|
# Add a public key, once. Appending blindly is how authorized_keys ends up with
|
|
# the same key four times after four runs.
|
|
add_authorized_key() {
|
|
local key="$1" file
|
|
file="$(user_authorized_keys)"
|
|
|
|
# Validated before it is stored. A truncated paste or a private key pasted by
|
|
# mistake would otherwise sit there looking like a key and never work.
|
|
if ! ssh-keygen -l -f /dev/stdin <<<"$key" >/dev/null 2>&1; then
|
|
warn "that does not parse as an ssh public key — nothing added"
|
|
return 1
|
|
fi
|
|
|
|
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
|
|
touch "$file"
|
|
|
|
# Compare on the key body, not the whole line: the trailing comment differs
|
|
# between machines and is not part of the identity.
|
|
local body
|
|
body="$(awk '{print $2}' <<<"$key")"
|
|
if [[ -n "$body" ]] && grep -qF "$body" "$file" 2>/dev/null; then
|
|
info " that key is already authorised"
|
|
return 0
|
|
fi
|
|
|
|
printf '%s\n' "$key" >>"$file"
|
|
fix_ssh_permissions
|
|
}
|
|
|
|
# Generate a keypair for the account and authorise it.
|
|
generate_user_key() {
|
|
local comment="$1" key
|
|
key="$(user_ssh_dir)/id_ed25519"
|
|
|
|
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
|
|
sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$comment" -f "$key" -N "" >/dev/null
|
|
add_authorized_key "$(cat "${key}.pub")"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Hardening
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# What sshd actually resolves a setting to, across the main file and every
|
|
# drop-in. The only honest way to report the current state: reading the config
|
|
# files tells you what is written, not what wins.
|
|
sshd_effective() { sshd -T 2>/dev/null | awk -v k="${1,,}" 'tolower($1) == k { print $2; exit }'; }
|
|
|
|
# Write the drop-in, verify it, and only then reload.
|
|
#
|
|
# Returns non-zero without touching the running daemon if the result would not
|
|
# parse — the alternative is a config that sshd refuses, at which point it will
|
|
# not come back after a restart and the machine has no ssh at all.
|
|
harden_sshd() {
|
|
local backup=""
|
|
|
|
[[ -f "$SSHD_DROPIN" ]] && backup="$(mktemp)" && cp "$SSHD_DROPIN" "$backup"
|
|
|
|
install -d -m 0755 /etc/ssh/sshd_config.d
|
|
cat >"$SSHD_DROPIN" <<'EOF'
|
|
# Written by machine-setup.
|
|
#
|
|
# Named 01- deliberately: sshd uses the FIRST value it obtains for a keyword, and
|
|
# Ubuntu includes this directory from the top of sshd_config. A file sorting
|
|
# after 50-cloud-init.conf would be read too late to override it.
|
|
PasswordAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
PermitRootLogin no
|
|
PubkeyAuthentication yes
|
|
EOF
|
|
chmod 644 "$SSHD_DROPIN"
|
|
|
|
if ! sshd -t 2>/dev/null; then
|
|
warn "sshd rejected the new configuration — reverting, nothing changed"
|
|
if [[ -n "$backup" ]]; then cp "$backup" "$SSHD_DROPIN"; else rm -f "$SSHD_DROPIN"; fi
|
|
[[ -n "$backup" ]] && rm -f "$backup"
|
|
return 1
|
|
fi
|
|
[[ -n "$backup" ]] && rm -f "$backup"
|
|
|
|
# Reload rather than restart: existing sessions keep their sshd, so the
|
|
# connection this is being run over is not the thing being experimented on.
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || systemctl restart ssh
|
|
}
|