Skip to content
Arvid Norlander edited this page Aug 1, 2022 · 7 revisions

Currently, aconfmgr does not manage users specially; /etc/passwd & co are treated as regular files.

This is sufficient for simple configurations, and has the benefit that changes in these files will show up in the diff when git-diff-ing a configuration after aconfmgr save.

This approach has some disadvantages:

  • All user information is in one place, and information about system users is removed from the rest of their related configuration (related packages and files).
  • Monolithic user/group files also cannot conditionally contain some users on some systems.
  • If /etc/shadow is managed as-is, hashed passwords will appear in the configuration (and will be readable by all who can access the aconfmgr configuration).

Another approach is to take advantage of bash scripting in the aconfmgr configuration, and use some helpers to manage the user list. Create 10-10-user-helpers.sh with:

#!/bin/bash

rm -rf "$tmp_dir"/usergroups
rm -rf "$tmp_dir"/groups

# Add user to /etc/{passwd,shadow} and corresponding group.
function AddUser() {
	local username=$1
	local password=$2
	local uid=$3
	local gid=$4
	local gpassword=$5
	local desc=$6
	local home=$7
	local shell=$8
	local groups=$9

	if [[ -z "$password" ]]
	then
		FatalError 'Attempting to create user with blank password\n'
	fi

	AddGroup "$username" "$gpassword" "$gid"

	mkdir -p "$output_dir"/files/etc
	printf '%s:%s:%d:%d:%s:%s:%s\n' "$username" x "$uid" "$gid" "$desc" "$home" "$shell" >> "$output_dir"/files/etc/passwd
	printf '%s:%s:::::::\n' "$username" "$password" >> "$output_dir"/files/etc/shadow

	mkdir -p "$tmp_dir"/usergroups

	local grouparr=()
	IFS=',' read -ra grouparr <<<"$groups"

	local group
	for group in "${grouparr[@]}"
	do
		printf "%s\n" "$username" >> "$tmp_dir"/usergroups/"$group"
	done
}

# Add group to /etc/{group,gshadow}.
function AddGroup() {
	local name=$1
	local password=$2
	local gid=$3

	mkdir -p "$tmp_dir"/groups
	printf '%s:%s:%d:\n' "$name" x "$gid"    >> "$tmp_dir"/groups/group
	printf '%s:%s::\n'   "$name" "$password" >> "$tmp_dir"/groups/gshadow
}

function AddUserToGroup() {
	local user=$1
	local group=$2

	printf "%s\n" "$user" >> "$tmp_dir"/usergroups/"$group"
}

And, 98-10-apply-users.sh, which will compile the information collected by the above helpers into passwd/group files:

#!/bin/bash

function AppendGroupMembers() {
	while IFS=: read -r name rest
	do
		local members=()
		if [[ -f "$tmp_dir"/usergroups/"$name" ]]
		then
			mapfile -t members < "$tmp_dir"/usergroups/"$name"
		fi

		local memberstr
		local oIFS=$IFS
		IFS=, memberstr="${members[*]}"
		IFS=$oIFS

		printf '%s:%s%s\n' "$name" "$rest" "$memberstr"
	done
}

AppendGroupMembers < "$tmp_dir"/groups/group   > "$output_dir"/files/etc/group
AppendGroupMembers < "$tmp_dir"/groups/gshadow > "$output_dir"/files/etc/gshadow

for f in passwd shadow group gshadow
do
	cp "$output_dir"/files/etc/"$f"{,-}
	if [[ $f == *shadow ]]
	then
		SetFileProperty /etc/"$f" mode 600
		SetFileProperty /etc/"$f"- mode 600
	fi
done

Then, throughout your configuration, you can use the helpers when necessary:

AddPackage util-linux # Miscellaneous system utilities for Linux
AddUser uuidd x 68 68 x uuidd / /usr/bin/nologin ''
AddGroup rfkill x 24
AddUserToGroup alice audio

Don't forget to create system users. E.g. for the root user:

AddUser root "$(GetPassword root)" 0 0 '' root /root /bin/bash root,bin,daemon,sys,adm,disk,wheel,log

Import

To convert your existing password/group files to the above helpers, you can use this script:

#!/bin/bash
set -euo pipefail

tmp_dir=tmp

function Die() {
	local fmt=$1
	shift
	# shellcheck disable=SC2059
	printf "$fmt\n" "$@" 1>&2
	exit 1
}

if [[ -r /etc/shadow ]]
then
	printf 'Importing from system configuration.\n' 1>&2
	fn_passwd=/etc/passwd
	fn_shadow=/etc/shadow
	fn_group=/etc/group
	fn_gshadow=/etc/gshadow
else
	for base in . config ~/.config/aconfmgr
	do
		if [[ -r "$base"/files/etc/passwd ]]
		then
			printf 'Importing from configuration in %s.\n' "$base" 1>&2
			fn_passwd="$base"/files/etc/passwd
			fn_group="$base"/files/etc/group
			fn_gshadow="$base"/files/etc/gshadow
			if [[ -f "$base"/passwd/shadow ]]
			then
				fn_shadow="$base"/passwd/shadow
			else
				fn_shadow="$base"/files/etc/gshadow
			fi
			break
		fi
	done
	if [[ ! -v fn_passwd ]]
	then
		Die "Can't find import location - re-run as root to import from system or run from your aconfmgr config dir."
	fi
fi

# Read users

declare -A users
user_order=()

while IFS=: read -r username ppassword uid gid desc home shell
do
	test -z "${users[$username:seen]+x}" || Die 'Duplicate user in passwd file: %s' "$username"
	user_order+=("$username")
	users[$username:seen]=y
	users[$username:ppassword]=$ppassword
	users[$username:uid]=$uid
	users[$username:gid]=$gid
	users[$username:desc]=$desc
	users[$username:home]=$home
	users[$username:shell]=$shell
done < $fn_passwd

while IFS=: read -r username password last minage maxage pwwarn pwinact acctexp res
do
	test -n "${users[$username:seen]+x}" || Die 'Unknown user in shadow file: %s' "$username"
	users[$username:password]=$password
	users[$username:last]=$last
	users[$username:minage]=$minage
	users[$username:maxage]=$maxage
	users[$username:pwwarn]=$pwwarn
	users[$username:pwinact]=$pwinact
	users[$username:acctexp]=$acctexp
	users[$username:res]=$res
done < $fn_shadow

# Read groups

declare -A groups
group_order=()

rm -rf "$tmp_dir"/usergroups
mkdir -p "$tmp_dir"/usergroups

while IFS=: read -r gname gpassword gid gmembers
do
	test -z "${groups[$gname:seen]+x}" || Die 'Duplicate group in group file %s' "$gname"
	group_order+=("$gname")
	groups[$gname:seen]=y
	groups[$gname:gpassword]=$gpassword
	groups[$gname:gid]=$gid
	groups[$gname:gmembers]=$gmembers

	gmembersarr=()
	IFS=, read -ra gmembersarr <<<"$gmembers"
	for member in "${gmembersarr[@]}"
	do
		printf '%s\n' "$gname" >> "$tmp_dir"/usergroups/"$member"
	done
done < $fn_group

while IFS=: read -r gname gspassword gsadmins gsmembers
do
	test -n "${groups[$gname:seen]+x}" || Die 'Unknown group in gshadow file: %s' "$gname"
	groups[$gname:gspassword]=$gspassword
	groups[$gname:gsadmins]=$gsadmins
	groups[$gname:gsmembers]=$gsmembers
done < $fn_gshadow

# Convert users

declare -A usergroups

rm -f generated-users.sh

for username in "${user_order[@]}"
do
	printf 'Processing user %s\n' "$username" 1>&2
	for var in ppassword uid gid desc home shell  password last minage maxage pwwarn pwinact acctexp res
	do
		declare $var="${users[$username:$var]}"
	done

	[[ "$ppassword" == x            ]] || Die 'passwd file password is not x'
	[[ "$uid"       =~ ^[0-9]+$     ]] || Die 'UID not numeric'
	[[ "$gid"       =~ ^[0-9]+$     ]] || Die 'GID not numeric'
	[[ "$home"      == /*           ]] || Die 'Home directory not absolute path'
	[[ "$shell"     == /*           ]] || Die 'Shell not absolute path'
	[[ "$last"      =~ ^[0-9]*$     ]] || Die 'Last password change not empty or numeric'
	[[ "$minage"    =~ ^0?$         ]] || Die 'Minimum password age not empty or 0'
	[[ "$maxage"    =~ ^(99999|)$   ]] || Die 'Maximum password age not empty or 99999'
	[[ "$pwwarn"    =~ ^7?$         ]] || Die 'Password warning period not empty or 7'
	[[ "$pwinact"   == ''           ]] || Die 'Password inactivity period not empty'
	[[ "$acctexp"   == ''           ]] || Die 'Account expiration date not empty'
	[[ "$res"       == ''           ]] || Die 'Reserved field not empty'

	grouplist=()
	if [[ -f "$tmp_dir"/usergroups/"$username" ]]
	then
		mapfile -t grouplist < "$tmp_dir"/usergroups/"$username"
	fi
	IFS=, groupstr="${grouplist[*]}"

	gpassword="${groups[$username:gspassword]}"

	env printf 'AddUser %q %q %d %d %q %q %q %q %q\n' \
		   "$username" \
		   "$password" \
		   "$uid" \
		   "$gid" \
		   "$gpassword" \
		   "$desc" \
		   "$home" \
		   "$shell" \
		   "$groupstr" \
		   >> generated-users.sh

	usergroups["$username"]="$gid"
done

# Convert groups

for gname in "${group_order[@]}"
do
	printf 'Processing group %s\n' "$gname" 1>&2
	for var in gpassword gid gmembers  gspassword gsadmins gsmembers
	do
		declare $var="${groups[$gname:$var]}"
	done

	[[ "$gpassword"  == x            ]] || Die 'group file password is not x'
	[[ "$gid"        =~ ^[0-9]+$     ]] || Die 'GID not numeric'
	[[ "$gspassword" =~ ^(|!|!!|x)$  ]] || Die 'Group password not empty, !, !!, or x: %q' "$gspassword"
	[[ "$gsadmins"   == ''           ]] || Die 'Group administrators not empty'
	[[ "$gsmembers"  == "$gmembers"  ]] || Die 'group/gshadow group members mismatch'

	if [[ -n "${usergroups[$gname]+x}" ]]
	then
		[[ "$gid" == "${usergroups[$gname]}" ]] || Die 'User GID and user'\''s group GID mismatch: %s/%s' "$gid" "${usergroups[$gname]}"
	else
		env printf 'AddGroup %q %q %d\n' \
			   "$gname" \
			   "$gspassword" \
			   "$gid" \
			   >> generated-users.sh
	fi
done

The script will create a generated-users.sh file in the current directory, which you can add to your aconfmgr configuration.

Passwords

If you plan to push your configuration repository to other machines, you may not want to keep passwords in it. To keep passwords separate from your configuration, you can use a helper such as the following:

# Read the password from a separate file, which is not under version control.
function GetPassword() {
	local username=$1

	if [[ -f "$config_dir"/data/passwords/"$username" ]]
	then
		cat "$config_dir"/data/passwords/"$username"
	else
		# Get password from current machine
		sudo cat /etc/shadow | grep "^$username:" | cut -d : -f 2
	fi
}

Given a $username, the function will read the password from $config_dir/data/passwords/$username (as it appears in /etc/shadow). Do not add the passwords directory to version control (add it to .gitignore). If the file is not present, it will attempt to use the current password from the current machine, which will work if you've already manually created all pertinent users and their passwords.