diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cab5c75 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2026-09-06 + +### Added + +- Added configurable Diffie-Hellman parameter generation for one or more key sizes. +- Added global configuration options for temporary and persistent DH parameter directories. +- Added support for simple service definitions using the `service:owner` format. +- Added extended service definitions for per-service DH parameter deployment, destination ownership, and file permissions. +- Added support for both system-wide systemd services and per-user `systemctl --user` services. +- Added support for rootless container service units by executing user service actions with the configured service owner and `XDG_RUNTIME_DIR`. +- Added systemd state inspection through `LoadState`, `ActiveState`, `SubState`, and `Type`. +- Added automatic restart of active units after a successful optional service-specific DH parameter synchronization. +- Added automatic execution of inactive or failed `Type=oneshot` units after a successful DH parameter update. +- Added explicit validation and error reporting for missing users, missing units, unavailable user managers, incomplete service definitions, missing DH files, and failed file operations. +- Added secure, explicit ownership and permission handling for created target directories and deployed DH parameter files. +- Added cleanup and failure handling for temporary DH parameter files and the global synchronization step. + +### Changed + +- Changed service handling so that service-specific DH parameters are deployed before a service is restarted or a one-shot unit is started. +- Changed unit validation to distinguish missing services from inactive, failed, active, and transitional unit states. +- Changed reporting for per-user services to show the configured owner consistently for both status checks and service actions. +- Changed configuration parsing to preserve compatibility with `user.group` ownership syntax while converting it to `user:group` for `chown`. + +### Fixed + +- Fixed misleading success messages for services that do not exist on a host. +- Fixed the possibility of creating or synchronizing DH parameters to a configured target path when the corresponding systemd unit is missing. +- Fixed malformed error-handling blocks around destination-directory creation and DH parameter copy operations. +- Fixed validation of the configured `user.group` ownership field. +- Fixed user-service restart handling so that active `systemctl --user` services are restarted as their configured owner. +- Fixed handling of active `Type=oneshot` units with `SubState=exited` by using `ActiveState` for action decisions. diff --git a/edh-keygen.sh b/edh-keygen.sh index a837413..fef528f 100644 --- a/edh-keygen.sh +++ b/edh-keygen.sh @@ -84,7 +84,10 @@ done < "$my_service_conf" # Create path for 'tmp' in '/etc/pki/tls' if [ ! -d "$tls_tmp_path" ]; then - mkdir -p "$tls_tmp_path" + if ! mkdir -p "$tls_tmp_path"; then + echo "Failed to create temporary directory $tls_tmp_path." >&2 + exit 1 + fi fi umask 022 @@ -98,17 +101,165 @@ fi # Generate DH params for bits in $key_sizes; do echo "Generating DH parameters for $bits bits..." - openssl dhparam -out "$tls_tmp_path/dh_${bits}.pem" "$bits" + + if ! openssl dhparam -out "$tls_tmp_path/dh_${bits}.pem" "$bits"; then + echo "Failed to generate ${bits}-bit DH parameters." >&2 + rm -rf "$tls_tmp_path" + exit 1 + fi done # Set permissions -find "$tls_tmp_path" -type f -name "*.pem" -exec chmod 644 {} \; +if ! find "$tls_tmp_path" -type f -name '*.pem' -exec chmod 644 {} \;; then + echo "Failed to set permissions on generated DH parameters." >&2 + rm -rf "$tls_tmp_path" + exit 1 +fi -# Sync certs from temporary to private folder -rsync -a "$tls_tmp_path/"*.pem "$tls_private_path" +# Synchronize DH parameters from the temporary to the private directory. +if [ ! -d "$tls_private_path" ]; then + if ! mkdir -p "$tls_private_path"; then + echo "Failed to create private key directory $tls_private_path." >&2 + rm -rf "$tls_tmp_path" + exit 1 + fi + +fi + +if ! chown root:root "$tls_private_path"; then + echo "Failed to set ownership on private key directory $tls_private_path." >&2 + rm -rf "$tls_tmp_path" + exit 1 +fi + +if ! chmod 750 "$tls_private_path"; then + echo "Failed to set permissions on private key directory $tls_private_path." >&2 + rm -rf "$tls_tmp_path" + exit 1 +fi + +for dh_file in "$tls_tmp_path"/dh_*.pem; do + if [ ! -f "$dh_file" ]; then + echo "No generated DH parameter files found in $tls_tmp_path." >&2 + rm -rf "$tls_tmp_path" + exit 1 + fi + + if ! rsync -a "$dh_file" "$tls_private_path"; then + echo "Failed to synchronize $dh_file to $tls_private_path." >&2 + rm -rf "$tls_tmp_path" + exit 1 + fi +done # Delete the temporary files -rm -rf "$tls_tmp_path" +if ! rm -rf "$tls_tmp_path"; then + echo "Failed to remove temporary directory $tls_tmp_path." >&2 + exit 1 +fi + +# Query systemd unit properties for system-wide and per-user service units. +# +# get_service_property +# +# Prints the value of the requested systemd property to standard output. +# Root-owned services are queried through the system manager. Services owned +# by another user are queried through that user's systemd --user manager. +# The caller must provide the numeric UID for per-user units so that the +# required XDG_RUNTIME_DIR can be set explicitly. +# +# get_service_state +# +# Resolves and validates the systemd state required for service handling. +# On success, it sets the following global variables for the current service: +# +# load_state Unit load state, e.g. loaded or not-found. +# active_state High-level unit state, e.g. active, inactive, or failed. +# sub_state Unit type-specific detailed state, e.g. running, dead, +# exited, or failed. +# service_type Service type, e.g. simple, notify, forking, or oneshot. +# service_uid Numeric UID for per-user units; empty for root units. +# +# A LoadState of "not-found" is returned as a successful lookup so the caller +# can report a missing configured unit explicitly. Other query failures return +# a non-zero status. +get_service_property() { + edh_property_name=$1 + edh_service_name=$2 + edh_service_owner=$3 + edh_service_uid=$4 + + if [ "$edh_service_owner" = "root" ]; then + /usr/bin/systemctl show \ + --property="$edh_property_name" \ + --value \ + "$edh_service_name.service" 2>/dev/null + else + sudo -u "$edh_service_owner" \ + XDG_RUNTIME_DIR="/run/user/$edh_service_uid" \ + /usr/bin/systemctl --user show \ + --property="$edh_property_name" \ + --value \ + "$edh_service_name.service" 2>/dev/null + fi +} + +# Return 0 for a valid unit state or for LoadState=not-found. +# Return non-zero if the owner does not exist or systemd cannot be queried. +# Result values are exported through the global variables documented above. +get_service_state() { + edh_state_service=$1 + edh_state_owner=$2 + + load_state= + active_state= + sub_state= + service_type= + service_uid= + + if [ "$edh_state_owner" != "root" ]; then + service_uid=$(id -u "$edh_state_owner" 2>/dev/null) + if [ -z "$service_uid" ]; then + return 1 + fi + fi + + load_state=$(get_service_property \ + LoadState \ + "$edh_state_service" \ + "$edh_state_owner" \ + "$service_uid") + + if [ -z "$load_state" ]; then + return 1 + fi + + if [ "$load_state" = "not-found" ]; then + return 0 + fi + + active_state=$(get_service_property \ + ActiveState \ + "$edh_state_service" \ + "$edh_state_owner" \ + "$service_uid") || return 1 + + sub_state=$(get_service_property \ + SubState \ + "$edh_state_service" \ + "$edh_state_owner" \ + "$service_uid") || return 1 + + service_type=$(get_service_property \ + Type \ + "$edh_state_service" \ + "$edh_state_owner" \ + "$service_uid") || return 1 + + [ -n "$active_state" ] && + [ -n "$sub_state" ] && + [ -n "$service_type" ] +} # Read and process service list while IFS= read -r line || [ -n "$line" ]; do @@ -118,76 +269,186 @@ while IFS= read -r line || [ -n "$line" ]; do esac # Extract service, owner and sync parameters - service=$(printf "%s" "$line" | awk -F: '{print $1}') - owner=$(printf "%s" "$line" | awk -F: '{print $2}') - key_size=$(printf "%s" "$line" | awk -F: '{print $3}') - sync_path=$(printf "%s" "$line" | awk -F: '{print $4}') - user_group=$(printf "%s" "$line" | awk -F: '{print $5}') - permissions=$(printf "%s" "$line" | awk -F: '{print $6}') + service=$(printf '%s\n' "$line" | awk -F: '{print $1}') + owner=$(printf '%s\n' "$line" | awk -F: '{print $2}') + key_size=$(printf '%s\n' "$line" | awk -F: '{print $3}') + sync_path=$(printf '%s\n' "$line" | awk -F: '{print $4}') + user_group=$(printf '%s\n' "$line" | awk -F: '{print $5}') + permissions=$(printf '%s\n' "$line" | awk -F: '{print $6}') - # Convert legacy user.group notation to user:group - ug_user=${user_group%%.*} - ug_group=${user_group#*.} - user_group=$ug_user:$ug_group + # Validate required base fields. + if [ -z "$service" ] || [ -z "$owner" ]; then + echo "Invalid service definition: $line" >&2 + continue + fi echo "$service.service (owner: $owner)..." - # Check service status (must run as root) - if [ "$owner" = "root" ]; then - mySubState=$(systemctl show -p SubState --value "$service.service" 2>/dev/null) - else - uid=$(id -u "$owner" 2>/dev/null) - if [ -n "$uid" ]; then - mySubState=$(sudo -u "$owner" XDG_RUNTIME_DIR="/run/user/$uid" /usr/bin/systemctl --user show -p SubState --value "$service.service" 2>/dev/null) + if ! get_service_state "$service" "$owner"; then + if [ "$owner" != "root" ] && [ -z "$service_uid" ]; then + echo "User $owner not found. Skipping $service." >&2 else - echo "User $owner not found! Skipping $service." 1>&2 - continue + echo "Could not query the systemd state of $service.service. Skipping restart and DH key sync." >&2 fi + continue fi - if [ "$mySubState" = "running" ]; then - echo "$service.service is running, restarting as $owner..." + if [ "$load_state" = "not-found" ]; then if [ "$owner" = "root" ]; then - /usr/bin/systemctl restart "$service.service" + echo "$service.service does not exist. Skipping restart and DH key sync." >&2 else - sudo -u "$owner" XDG_RUNTIME_DIR="/run/user/$uid" /usr/bin/systemctl --user restart "$service" + echo "$service.service does not exist for user $owner. Skipping restart and DH key sync." >&2 fi - echo "$service.service restarted." + continue fi - # Handle DH key sync if parameters exist - if [ -n "$key_size" ] && [ -n "$sync_path" ] && [ -n "$user_group" ] && [ -n "$permissions" ]; then - if ! echo "$user_group" | grep -q "."; then - echo "Error: user_group must be 'user.group' for $service. Skipping sync." 1>&2 - continue - fi + # Decide which action to apply after a successful optional DH key sync. + service_action=none - dh_file="$tls_private_path/dh_${key_size}.pem" - if [ ! -f "$dh_file" ]; then - echo "DH key $dh_file not found. Skipping sync for $service." 1>&2 + case "$active_state" in + active) + service_action=restart + ;; + inactive) + if [ "$service_type" = "oneshot" ]; then + service_action=start + echo "$service.service is inactive (SubState: $sub_state, Type: oneshot). Syncing DH key and starting unit." + else + echo "$service.service is inactive (SubState: $sub_state). DH parameters are updated; skipping service action." + fi + ;; + failed) + if [ "$service_type" = "oneshot" ]; then + service_action=start + echo "$service.service is failed (SubState: $sub_state, Type: oneshot). Syncing DH key and starting unit." >&2 + else + echo "$service.service is failed (SubState: $sub_state). Syncing DH key without restart." >&2 + fi + ;; + *) + echo "$service.service is $active_state (SubState: $sub_state). Skipping restart and DH key sync." >&2 continue - fi + ;; + esac - # Create directory if missing - if [ ! -d "$sync_path" ]; then - mkdir -p "$sync_path" || { - echo "Failed to create $sync_path for $service." 1>&2 + # Synchronize DH parameters when a complete extended configuration is present. + if [ -z "$key_size" ] && [ -z "$sync_path" ] && [ -z "$user_group" ] && [ -z "$permissions" ]; then + : + elif [ -z "$key_size" ] || [ -z "$sync_path" ] || [ -z "$user_group" ] || [ -z "$permissions" ]; then + echo "Incomplete DH key sync configuration for $service. Skipping sync and service action." >&2 + continue + else + # The configuration format uses user.group. Convert it to user:group for chown. + case "$user_group" in + *.*) + ug_user=${user_group%%.*} + ug_group=${user_group#*.} + ;; + *) + echo "Invalid owner '$user_group' for $service; expected user.group. Skipping sync." >&2 continue - } - chown "$user_group" "$sync_path" - chmod 750 "$sync_path" + ;; + esac + + if [ -z "$ug_user" ] || [ -z "$ug_group" ]; then + echo "Invalid owner '$user_group' for $service; expected user.group. Skipping sync." >&2 + continue + fi + + dh_file="${tls_private_path%/}/dh_${key_size}.pem" + target_file="${sync_path%/}/dh_${key_size}.pem" + + if [ ! -f "$dh_file" ]; then + echo "DH key $dh_file not found. Skipping sync for $service." >&2 + continue + fi + + # Create the destination directory if it does not exist. + if [ ! -d "$sync_path" ]; then + if ! mkdir -p "$sync_path"; then + echo "Failed to create $sync_path for $service." >&2 + continue + fi + + if ! chown "$ug_user:$ug_group" "$sync_path"; then + echo "Failed to set ownership on $sync_path for $service." >&2 + continue + fi + + if ! chmod 750 "$sync_path"; then + echo "Failed to set permissions on $sync_path for $service." >&2 + continue + fi + echo "Created directory $sync_path for $service." fi - # Copy DH key and set permissions - cp "$dh_file" "$sync_path/" || { - echo "Failed to copy DH key to $sync_path for $service." 1>&2 + if ! cp "$dh_file" "$target_file"; then + echo "Failed to copy DH key to $target_file for $service." >&2 continue - } - chown "$user_group" "$sync_path/dh_${key_size}.pem" - chmod "$permissions" "$sync_path/dh_${key_size}.pem" + fi + + if ! chown "$ug_user:$ug_group" "$target_file"; then + echo "Failed to set ownership on $target_file for $service." >&2 + continue + fi + + if ! chmod "$permissions" "$target_file"; then + echo "Failed to set permissions on $target_file for $service." >&2 + continue + fi + echo "Synced DH key (${key_size}-bit) to $sync_path for $service." fi + + # Apply the selected action after the optional DH key synchronization. + case "$service_action" in + restart) + echo "$service.service is active (SubState: $sub_state), restarting as $owner..." + + if [ "$owner" = "root" ]; then + if ! /usr/bin/systemctl restart "$service.service"; then + echo "Failed to restart $service.service." >&2 + continue + fi + else + if ! sudo -u "$owner" \ + XDG_RUNTIME_DIR="/run/user/$service_uid" \ + /usr/bin/systemctl --user restart "$service.service"; then + echo "Failed to restart $service.service as $owner." >&2 + continue + fi + fi + + echo "$service.service restarted." + ;; + start) + echo "$service.service is $active_state oneshot, starting as $owner..." + + if [ "$owner" = "root" ]; then + if ! /usr/bin/systemctl start "$service.service"; then + echo "Failed to start $service.service." >&2 + continue + fi + else + if ! sudo -u "$owner" \ + XDG_RUNTIME_DIR="/run/user/$service_uid" \ + /usr/bin/systemctl --user start "$service.service"; then + echo "Failed to start $service.service as $owner." >&2 + continue + fi + fi + + echo "$service.service started." + ;; + none) + : + ;; + *) + echo "Internal error: unsupported action '$service_action' for $service." >&2 + continue + ;; + esac done < "$my_service_conf"