mirror of
https://github.com/Matir/skel.git
synced 2026-05-25 21:19:09 -07:00
Build update-authorized-keys
This commit is contained in:
310
bin/update-authorized-keys
Executable file
310
bin/update-authorized-keys
Executable file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# update-authorized-keys - Manage ~/.ssh/authorized_keys from multiple sources
|
||||
#
|
||||
# BEHAVIOR:
|
||||
# 1. Collects SSH public keys from one or more source directories (default: ~/.ssh/authorized_keys.d).
|
||||
# 2. Skips empty files and files symlinked to /dev/null (masking).
|
||||
# 3. Deterministically concatenates keys into a "managed block" wrapped in markers:
|
||||
# # BEGIN UPDATE-AUTHORIZED-KEYS
|
||||
# # END UPDATE-AUTHORIZED-KEYS
|
||||
# 4. Deduplicates managed keys: if the same key (including options) is found in multiple files,
|
||||
# it is included once with a comment listing all source filenames.
|
||||
# 5. Preserves "manual" keys found in the target file outside the markers.
|
||||
# 6. Removes manual keys that exactly match a managed key (options + key data).
|
||||
# 7. Validates every proposed key individually using 'ssh-keygen -l -f'.
|
||||
# 8. Optionally validates the whole file with 'authorized-keys-test' if available.
|
||||
# 9. Displays a unified diff and prompts for confirmation before atomic replacement.
|
||||
# 10. Supports a --dry-run mode and a --self-test mode for verifying logic.
|
||||
|
||||
set -o nounset
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
CLEANUP_FILES=()
|
||||
cleanup() {
|
||||
rm -rf "${CLEANUP_FILES[@]}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Configuration
|
||||
DEFAULT_DIR="${HOME}/.ssh/authorized_keys.d"
|
||||
DEFAULT_TARGET="${HOME}/.ssh/authorized_keys"
|
||||
BEGIN_MARKER="# BEGIN UPDATE-AUTHORIZED-KEYS"
|
||||
END_MARKER="# END UPDATE-AUTHORIZED-KEYS"
|
||||
|
||||
# State
|
||||
SOURCE_DIRS=()
|
||||
TARGET_FILE="${DEFAULT_TARGET}"
|
||||
DRY_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Options:
|
||||
--dir DIR Primary directory for managed keys (default: ${DEFAULT_DIR})
|
||||
--extra-dir DIR Additional directory to scan for keys (can be repeated)
|
||||
--target FILE Target authorized_keys file (default: ${DEFAULT_TARGET})
|
||||
--dry-run Show changes and validate without modifying the target
|
||||
--self-test Run internal suite of tests to verify script logic
|
||||
--help Show this help message
|
||||
EOF
|
||||
}
|
||||
|
||||
run_self_test() {
|
||||
echo "Running self-test..."
|
||||
local test_root=$(mktemp -d)
|
||||
CLEANUP_FILES+=("${test_root}")
|
||||
|
||||
local d1="${test_root}/d1"
|
||||
local d2="${test_root}/d2"
|
||||
local target="${test_root}/target"
|
||||
mkdir -p "${d1}" "${d2}"
|
||||
|
||||
local key1="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8XoR7N7X5XoR7N7X5XoR7N7X5XoR7N7X5XoR7N7X5X key1"
|
||||
local key2="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL9YpS8O8Y6YpS8O8Y6YpS8O8Y6YpS8O8Y6YpS8O8Y6Y key2"
|
||||
local key_man="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0ZqT9P9Z7ZqT9P9Z7ZqT9P9Z7ZqT9P9Z7ZqT9P9Z7Z manual"
|
||||
local long_opt="environment=\"VAR=VERY_LONG_VALUE_THAT_EXCEEDS_TWENTY_CHARS\""
|
||||
|
||||
echo "${key1}" > "${d1}/k1"
|
||||
echo "${key1}" > "${d2}/k1_dup"
|
||||
echo "${key2}" > "${d2}/k2"
|
||||
echo "${long_opt} ${key1}" > "${d1}/k1_long"
|
||||
echo "${long_opt} ${key2}" > "${d1}/k2_long"
|
||||
ln -s /dev/null "${d1}/masked"
|
||||
|
||||
cat <<EOF > "${target}"
|
||||
${key_man}
|
||||
${key1} # This should be removed as it's now managed
|
||||
EOF
|
||||
|
||||
echo "Executing script in test mode..."
|
||||
# Pipe "y" to handle the TTY check if we are not in a TTY during test
|
||||
echo "y" | "$0" --dir "${d1}" --extra-dir "${d2}" --target "${target}" > /dev/null
|
||||
|
||||
local content=$(cat "${target}")
|
||||
|
||||
echo -n "Check markers... "
|
||||
if [[ "${content}" == *"${BEGIN_MARKER}"* && "${content}" == *"${END_MARKER}"* ]]; then echo "OK"; else echo "FAIL"; exit 1; fi
|
||||
|
||||
echo -n "Check managed deduplication... "
|
||||
if grep -q "Source: k1, k1_dup" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi
|
||||
|
||||
echo -n "Check long option deduplication (should NOT deduplicate different keys)... "
|
||||
if grep -q "k1_long" "${target}" && grep -q "k2_long" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi
|
||||
|
||||
echo -n "Check manual key preservation... "
|
||||
if grep -q "manual" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi
|
||||
|
||||
echo -n "Check manual key filtering... "
|
||||
local manual_count=$(grep -c "${key1}" "${target}")
|
||||
# key1 appears twice in managed block (once plain, once with long opt)
|
||||
# and it was in manual block. The manual one should be removed.
|
||||
# So we expect 2 occurrences in the final file (both in managed block).
|
||||
if [[ ${manual_count} -eq 2 ]]; then echo "OK"; else echo "FAIL (Found ${manual_count} occurrences, expected 2)"; exit 1; fi
|
||||
|
||||
echo -n "Check masking... "
|
||||
if ! grep -q "masked" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi
|
||||
|
||||
echo "Self-test passed successfully!"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dir)
|
||||
[[ -z "${2:-}" ]] && { echo "Error: --dir requires an argument" >&2; exit 1; }
|
||||
SOURCE_DIRS+=("$2"); shift 2 ;;
|
||||
--extra-dir)
|
||||
[[ -z "${2:-}" ]] && { echo "Error: --extra-dir requires an argument" >&2; exit 1; }
|
||||
SOURCE_DIRS+=("$2"); shift 2 ;;
|
||||
--target)
|
||||
[[ -z "${2:-}" ]] && { echo "Error: --target requires an argument" >&2; exit 1; }
|
||||
TARGET_FILE="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--self-test) run_self_test ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#SOURCE_DIRS[@]} -eq 0 ]]; then
|
||||
SOURCE_DIRS+=("${DEFAULT_DIR}")
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${TARGET_FILE}")"
|
||||
TMP_FILE=$(mktemp)
|
||||
CLEANUP_FILES+=("${TMP_FILE}")
|
||||
|
||||
collect_keys() {
|
||||
local dirs=("${@}")
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [[ ! -d "${dir}" ]]; then continue; fi
|
||||
# Use a glob to avoid parsing ls
|
||||
for file in "${dir}"/*; do
|
||||
[[ ! -e "${file}" ]] && continue
|
||||
[[ ! -f "${file}" || ! -s "${file}" ]] && continue
|
||||
if [[ -L "${file}" && "$(readlink "${file}")" == "/dev/null" ]]; then continue; fi
|
||||
while read -r line; do
|
||||
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
|
||||
# Use a specific delimiter that is unlikely to be in the key or filename
|
||||
# If using tabs, ensure we only split on the first one in AWK
|
||||
printf "%s\t%s\n" "$(basename "${file}")" "${line}"
|
||||
done < "${file}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Use a HEREDOC for the complex AWK script to avoid shell interpolation issues
|
||||
MANAGED_BLOCK=$(collect_keys "${SOURCE_DIRS[@]}" | awk -F'\t' '
|
||||
{
|
||||
# Splitting on the first tab manually to be robust
|
||||
tab_idx = index($0, "\t")
|
||||
source = substr($0, 1, tab_idx - 1)
|
||||
full_line = substr($0, tab_idx + 1)
|
||||
|
||||
# Signature detection: all options + key type + key data
|
||||
# (Excludes the comment at the end)
|
||||
n = split(full_line, parts, " ")
|
||||
sig = ""
|
||||
for (i=1; i<=n; i++) {
|
||||
sig = (sig == "" ? parts[i] : sig " " parts[i])
|
||||
# A key line is [options] <type> <base64> [comment]
|
||||
# We stop after the base64 part. Key types start with known prefixes.
|
||||
if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) {
|
||||
sig = sig " " parts[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
# Fallback if no key type found (should not happen with valid keys)
|
||||
if (sig == "") sig = full_line
|
||||
|
||||
if (!(sig in keys)) {
|
||||
keys[sig] = full_line
|
||||
order[++count] = sig
|
||||
}
|
||||
sources[sig] = (sources[sig] ? sources[sig] ", " : "") source
|
||||
}
|
||||
END {
|
||||
for (i=1; i<=count; i++) {
|
||||
sig = order[i]
|
||||
print "# Source: " sources[sig]
|
||||
print keys[sig]
|
||||
}
|
||||
}')
|
||||
|
||||
MANUAL_KEYS=""
|
||||
if [[ -f "${TARGET_FILE}" ]]; then
|
||||
MANUAL_KEYS=$(awk -v begin="${BEGIN_MARKER}" -v end="${END_MARKER}" '
|
||||
BEGIN { inside=0 }
|
||||
$0 == begin { inside=1; next }
|
||||
$0 == end { inside=0; next }
|
||||
!inside { print $0 }
|
||||
' "${TARGET_FILE}")
|
||||
fi
|
||||
|
||||
MANAGED_SIGS_TMP=$(mktemp)
|
||||
echo "${MANAGED_BLOCK}" | awk '/^[^#]/ {
|
||||
n = split($0, parts, " ")
|
||||
sig = ""
|
||||
for (i=1; i<=n; i++) {
|
||||
sig = (sig == "" ? parts[i] : sig " " parts[i])
|
||||
if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) {
|
||||
sig = sig " " parts[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sig != "") print sig
|
||||
}' > "${MANAGED_SIGS_TMP}"
|
||||
|
||||
FINAL_MANUAL_KEYS=$(echo "${MANUAL_KEYS}" | awk -v sigs_file="${MANAGED_SIGS_TMP}" '
|
||||
BEGIN {
|
||||
while ((getline line < sigs_file) > 0) {
|
||||
managed[line] = 1
|
||||
}
|
||||
close(sigs_file)
|
||||
}
|
||||
{
|
||||
if ($0 ~ /^[[:space:]]*$/ || $0 ~ /^[[:space:]]*#/) {
|
||||
print $0
|
||||
next
|
||||
}
|
||||
n = split($0, parts, " ")
|
||||
sig = ""
|
||||
for (i=1; i<=n; i++) {
|
||||
sig = (sig == "" ? parts[i] : sig " " parts[i])
|
||||
if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) {
|
||||
sig = sig " " parts[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!(sig in managed)) {
|
||||
print $0
|
||||
}
|
||||
}')
|
||||
rm -f "${MANAGED_SIGS_TMP}"
|
||||
|
||||
{
|
||||
if [[ -n "${MANAGED_BLOCK}" ]]; then
|
||||
echo "${BEGIN_MARKER}"
|
||||
echo "${MANAGED_BLOCK}"
|
||||
echo "${END_MARKER}"
|
||||
fi
|
||||
echo "${FINAL_MANUAL_KEYS}"
|
||||
} > "${TMP_FILE}"
|
||||
|
||||
echo "Validating proposed changes..."
|
||||
VALID=1
|
||||
while read -r line; do
|
||||
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
|
||||
if ! echo "${line}" | ssh-keygen -l -f - >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid SSH key detected: ${line}" >&2
|
||||
VALID=0
|
||||
fi
|
||||
done < "${TMP_FILE}"
|
||||
|
||||
if command -v authorized-keys-test >/dev/null 2>&1; then
|
||||
if ! authorized-keys-test "${TMP_FILE}"; then
|
||||
echo "ERROR: Proposed file failed authorized-keys-test." >&2
|
||||
VALID=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${VALID} -eq 0 ]]; then
|
||||
echo "Validation failed. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "${TARGET_FILE}" ]]; then
|
||||
diff -u "${TARGET_FILE}" "${TMP_FILE}" || true
|
||||
else
|
||||
echo "Target file does not exist. Proposed content:"
|
||||
cat "${TMP_FILE}"
|
||||
fi
|
||||
|
||||
if [[ ${DRY_RUN} -eq 1 ]]; then
|
||||
echo "Dry run complete. No changes made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -t 0 ]]; then
|
||||
echo -n "Apply these changes to ${TARGET_FILE}? [y/N] "
|
||||
read -r response
|
||||
elif [[ ! -t 0 ]]; then
|
||||
# Read from pipe or file if provided
|
||||
if ! read -r response; then
|
||||
echo "Non-interactive shell detected and no input provided. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${response}" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||
chmod 0600 "${TMP_FILE}"
|
||||
mv "${TMP_FILE}" "${TARGET_FILE}"
|
||||
echo "Changes applied successfully."
|
||||
else
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user