feat(edh-keygen): support user and oneshot service actions

Validate configured systemd units before applying service-specific DH
parameter synchronization.

Synchronize per-service DH parameters before restarting active units. Query
system-wide and per-user systemd managers with LoadState, ActiveState,
SubState, and Type.

Restart active units, start inactive or failed Type=oneshot units, and leave
inactive or failed non-oneshot units without a service action. Skip missing
units, unavailable user managers, and invalid or incomplete service
definitions without creating misleading synchronization messages.

Add robust error handling for DH parameter generation, permission changes,
directory creation, global synchronization, and temporary file cleanup.
This commit is contained in:
2026-09-06 14:03:01 +02:00
parent cc9f6106a5
commit 27f665bbf7
2 changed files with 353 additions and 53 deletions
+39
View File
@@ -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.
+316 -55
View File
@@ -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 <property> <service> <owner> <uid>
#
# 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 <service> <owner>
#
# 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)
if ! get_service_state "$service" "$owner"; then
if [ "$owner" != "root" ] && [ -z "$service_uid" ]; then
echo "User $owner not found. Skipping $service." >&2
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)
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
fi
if [ "$mySubState" = "running" ]; then
echo "$service.service is running, restarting as $owner..."
if [ "$owner" = "root" ]; then
/usr/bin/systemctl restart "$service.service"
else
sudo -u "$owner" XDG_RUNTIME_DIR="/run/user/$uid" /usr/bin/systemctl --user restart "$service"
fi
echo "$service.service restarted."
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
dh_file="$tls_private_path/dh_${key_size}.pem"
if [ "$load_state" = "not-found" ]; then
if [ "$owner" = "root" ]; then
echo "$service.service does not exist. Skipping restart and DH key sync." >&2
else
echo "$service.service does not exist for user $owner. Skipping restart and DH key sync." >&2
fi
continue
fi
# Decide which action to apply after a successful optional DH key sync.
service_action=none
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
;;
esac
# 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
;;
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." 1>&2
echo "DH key $dh_file not found. Skipping sync for $service." >&2
continue
fi
# Create directory if missing
# Create the destination directory if it does not exist.
if [ ! -d "$sync_path" ]; then
mkdir -p "$sync_path" || {
echo "Failed to create $sync_path for $service." 1>&2
if ! mkdir -p "$sync_path"; then
echo "Failed to create $sync_path for $service." >&2
continue
}
chown "$user_group" "$sync_path"
chmod 750 "$sync_path"
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"