# CH-J Server Manager - fixed remote helper for the chj.users capability (POSIX sh). # Core runs this text with "sh -c SCRIPT chj-users OPERATION ARGS...". Plugins cannot supply code. # Arguments are validated by Core and again here. Secrets and file contents never appear in argv: # they arrive as base64 stdin frames ("CHJ:") only after the READY marker. set -u umask 077 LC_ALL=C; export LC_ALL PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH SSHD_MAIN=/etc/ssh/sshd_config SSHD_DROPIN=/etc/ssh/sshd_config.d SSHD_MANAGED=$SSHD_DROPIN/01-chj-users.conf STATE_DIR=/var/lib/chj-server-manager/users FRAME= op=${1:-} [ $# -gt 0 ] && shift printf '\036CHJ_READY\n' fail() { printf '\036CHJ_ERR=%s\n' "$1"; [ $# -gt 1 ] && printf '%s\n' "$2" | head -c 2000 >&2; exit 3; } section() { printf '\036CHJ_SECTION=%s\n' "$1"; } oneline() { tr '\r\n|\036' ' ' | cut -c1-400; } step() { printf '\036CHJ_STEP=%s|%s|%s\n' "$1" "$2" "$(printf '%s' "${3:-}" | oneline)"; } is_root() { [ "$(id -u)" = 0 ]; } need_root() { is_root || fail PRIVILEGE_REQUIRED "Root privileges are required."; } valid_name() { case $1 in ''|[!a-z_]*|*[!a-z0-9_-]*) return 1 ;; esac; [ ${#1} -le 32 ]; } valid_addr() { case $1 in ''|*[!0-9a-fA-F:.]*) return 1 ;; esac; [ ${#1} -le 45 ]; } valid_path() { case $1 in /*) ;; *) return 1 ;; esac case $1 in *[!A-Za-z0-9._/@+-]*|*/../*|*/..|*/./*|*//*) return 1 ;; esac [ ${#1} -le 255 ] } have() { command -v "$1" >/dev/null 2>&1; } uid_min() { value=$(awk '$1=="UID_MIN"{print $2; exit}' /etc/login.defs 2>/dev/null) case $value in ''|*[!0-9]*) value=1000 ;; esac printf '%s' "$value" } b64d() { if printf 'YQ==' | base64 -d >/dev/null 2>&1; then base64 -d elif printf 'YQ==' | openssl base64 -d -A >/dev/null 2>&1; then openssl base64 -d -A else return 127; fi } b64e() { base64 | tr -d '\r\n'; } sha256_of() { if [ -f "$1" ]; then if have sha256sum; then sha256sum < "$1" | cut -c1-64; else openssl dgst -sha256 < "$1" | sed 's/^.*= *//'; fi else printf 'absent'; fi } read_frame() { FRAME= while IFS= read -r line; do case $line in "CHJ$1:"*) FRAME=${line#"CHJ$1:"}; return 0 ;; esac done return 1 } passwd_entry() { getent passwd "$1" 2>/dev/null | head -n 1; } user_uid() { passwd_entry "$1" | cut -d: -f3; } user_home() { passwd_entry "$1" | cut -d: -f6; } group_members() { getent group "$1" 2>/dev/null | cut -d: -f4 | tr ',' '\n'; } is_member() { group_members "$2" | grep -qx "$1"; } # Destructive account changes are refused for root, system accounts and the sudo caller. protect() { valid_name "$1" || fail NAME_INVALID "Invalid account name." uid=$(user_uid "$1") [ -n "$uid" ] || fail USER_NOT_FOUND "The account does not exist." [ "$uid" -ne 0 ] || fail PROTECTED_USER "The root account is protected." [ "$uid" -ge "$(uid_min)" ] && [ "$uid" -ne 65534 ] || fail PROTECTED_USER "System accounts are protected." [ "$1" != "${SUDO_USER:-}" ] || fail PROTECTED_USER "The account of the active SSH session is protected." } # Runs a command as the target account so file permissions, not root, govern access. # BusyBox getent (Alpine) has no shadow database; root can read /etc/shadow directly. Only Core-side states leave the server. shadow_db() { out=$(getent shadow 2>/dev/null) if [ -n "$out" ]; then printf '%s\n' "$out"; else cat /etc/shadow 2>/dev/null; fi } run_as() { target=$1; shift if ! is_root; then [ "$(id -un)" = "$target" ] || return 125 "$@"; return $? fi if have runuser; then runuser -u "$target" -- "$@" # BusyBox setpriv (Alpine) has no --reuid; only the util-linux variant is used. elif have setpriv && setpriv --help 2>&1 | grep -q -e '--reuid'; then setpriv --reuid="$(id -u "$target")" --regid="$(id -g "$target")" --init-groups -- "$@" else # BusyBox su (Alpine) and util-linux su hand extra arguments to the shell differently, so the command is passed # as one single-quoted string instead of positional parameters. cmd=exec for arg in "$@"; do cmd="$cmd '$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")'"; done su -s /bin/sh -c "$cmd" "$target" fi } sudo_summary() { have sudo || { printf 'unavailable'; return; } sudo -n -l -U "$1" 2>/dev/null | awk ' /is not allowed to run sudo/ { none=1 } /may run the following commands/ { rules=1; next } rules && /^[ \t]+\(/ { line=$0; nopw=(line ~ /NOPASSWD:/) sub(/^[ \t]+\([^)]*\)[ \t]*/, "", line) while (line ~ /^[A-Z_]+:[ \t]*/) sub(/^[A-Z_]+:[ \t]*/, "", line) gsub(/[ \t]+$/, "", line) if (line == "ALL") { full=1; if (nopw) fullnopw=1 } else any=1 } END { if (full) print (fullnopw ? "full-nopasswd" : "full"); else if (any) print "limited"; else print "none" }' } sudoers_group_rule() { for file in /etc/sudoers /etc/sudoers.d/*; do [ -f "$file" ] || continue grep -hE "^[[:space:]]*%$1[[:space:]]" "$file" 2>/dev/null done | grep -q . } admin_group() { for group in sudo wheel admin; do getent group "$group" >/dev/null 2>&1 && sudoers_group_rule "$group" && { printf '%s' "$group"; return 0; } done return 1 } sshd_bin() { if have sshd; then command -v sshd elif [ -x /usr/sbin/sshd ]; then printf '/usr/sbin/sshd'; fi } # "ok" only when sshd_config includes sshd_config.d before its first Match block. layout_state() { [ -f "$SSHD_MAIN" ] || { printf 'missing-main'; return; } awk '{ l=tolower($0); sub(/^[ \t]+/, "", l) } l ~ /^include[ \t]/ && l ~ /sshd_config\.d\/\*/ && !inc { inc=NR } l ~ /^match[ \t]/ && !m { m=NR } END { if (inc && (!m || inc < m)) print "ok"; else if (inc) print "include-after-match"; else print "include-missing" }' "$SSHD_MAIN" 2>/dev/null || printf 'unreadable' } service_state() { if have systemctl && [ -d /run/systemd/system ]; then for unit in ssh.service sshd.service; do [ "$(systemctl is-active "$unit" 2>/dev/null)" = active ] && { printf 'systemd:%s:active' "$unit"; return; } done for unit in ssh.socket sshd.socket; do [ "$(systemctl is-active "$unit" 2>/dev/null)" = active ] && { printf 'systemd:%s:active' "$unit"; return; } done printf 'systemd:none:inactive'; return fi # OpenRC (Alpine) also writes /run/sshd.pid, so it is checked before the bare pid file. if have rc-service; then rc-service sshd status >/dev/null 2>&1 && { printf 'openrc:sshd:active'; return; } fi for file in /run/sshd.pid /var/run/sshd.pid; do [ -r "$file" ] || continue pid=$(head -n 1 "$file" 2>/dev/null) case $pid in ''|*[!0-9]*) continue ;; esac if kill -0 "$pid" 2>/dev/null; then printf 'pidfile:%s:active' "$file"; else printf 'pidfile:%s:unknown' "$file"; fi return done printf 'unknown:none:unknown' } # After SIGHUP sshd re-executes; some builds come back under a new PID and rewrite the pid file. # Success = a live sshd listed in the pid file within 10 s. sshd_running_after_reload() { tries=0 while [ "$tries" -lt 20 ]; do for file in /run/sshd.pid /var/run/sshd.pid; do [ -r "$file" ] || continue pid=$(head -n 1 "$file" 2>/dev/null) case $pid in ''|*[!0-9]*) continue ;; esac kill -0 "$pid" 2>/dev/null && return 0 done sleep 0.5 2>/dev/null || sleep 1 tries=$((tries + 1)) done return 1 } reload_sshd() { state=$(service_state); kind=${state%%:*}; rest=${state#*:}; unit=${rest%%:*} case $kind in systemd) case $unit in *.socket) printf 'socket-activated'; return 0 ;; none) printf 'sshd service is not active'; return 1 ;; esac systemctl reload "$unit" 2>&1 ;; pidfile) pid=$(head -n 1 "$unit"); kill -HUP "$pid" 2>&1 || return 1 sleep 1; sshd_running_after_reload || { printf 'sshd stopped after reload'; return 1; } ;; openrc) rc-service sshd reload 2>&1 || return 1 sleep 1; sshd_running_after_reload || rc-service sshd status >/dev/null 2>&1 || { printf 'sshd stopped after reload'; return 1; } ;; *) printf 'sshd service manager was not recognised'; return 1 ;; esac } effective() { # $1 user, $2 address; prints only the authentication and access keywords Core evaluates. "$SSHD" -T -C "user=$1,host=$2,addr=$2" 2>&1 | grep -E '^(passwordauthentication|kbdinteractiveauthentication|challengeresponseauthentication|pubkeyauthentication|authenticationmethods|permitrootlogin|denyusers|allowusers|denygroups|allowgroups|authorizedkeysfile|authorizedkeyscommand|addressfamily|listenaddress|port|usepam|strictmodes) |^[^a-z]' | head -n 300 } expand_keys_path() { awk -v s="$1" -v h="$2" -v u="$3" -v id="$4" 'BEGIN { out=""; n=length(s) for (i=1; i<=n; i++) { c=substr(s,i,1) if (c=="%" && i/dev/null || printf 'unknown' else printf 'absent'; fi } print_managed() { section managed if [ -L "$SSHD_MANAGED" ]; then printf 'unsafe=1\n' elif [ -f "$SSHD_MANAGED" ] && [ -r "$SSHD_MANAGED" ]; then printf 'sha256=%s\ndata=' "$(sha256_of "$SSHD_MANAGED")"; b64e < "$SSHD_MANAGED"; printf '\n' elif [ -e "$SSHD_MANAGED" ]; then printf 'unreadable=1\n' else printf 'sha256=absent\n'; fi } READ_KEYS='p=$1 if [ -L "$p" ]; then printf "type=symlink\n"; exit 0; fi if [ ! -e "$p" ]; then if [ -d "${p%/*}" ]; then printf "type=absent\n"; else printf "type=absent-dir\n"; fi; exit 0; fi if [ ! -f "$p" ]; then printf "type=other\n"; exit 0; fi if [ ! -r "$p" ]; then printf "type=unreadable\n"; exit 0; fi size=$(wc -c < "$p" | tr -d " ") printf "type=file\nsize=%s\n" "$size" [ "$size" -le 262144 ] || { printf "tooLarge=1\n"; exit 0; } printf "data="; base64 < "$p" | tr -d "\r\n"; printf "\n"' # Executed as the key owner. Exit codes are mapped by Core. WRITE_KEYS='set -u; target=$1; expected=$2; dir=${target%/*} [ ! -L "$dir" ] || exit 21 if [ ! -e "$dir" ]; then mkdir -m 700 -- "$dir" || exit 22; fi [ -d "$dir" ] || exit 23 [ ! -L "$target" ] || exit 24 if [ -e "$target" ] && [ ! -f "$target" ]; then exit 25; fi if [ -f "$target" ]; then if command -v sha256sum >/dev/null 2>&1; then current=$(sha256sum < "$target" | cut -c1-64); else current=$(openssl dgst -sha256 < "$target" | sed "s/^.*= *//"); fi else current=absent; fi [ "$current" = "$expected" ] || exit 26 tmp=$(mktemp "$dir/.authorized_keys.chj-XXXXXX") || exit 27 if ! cat > "$tmp"; then rm -f -- "$tmp"; exit 28; fi chmod 600 "$tmp" || { rm -f -- "$tmp"; exit 28; } mv -f -- "$tmp" "$target" || { rm -f -- "$tmp"; exit 29; }' op_probe() { printf 'user=%s\nuid=%s\n' "$(id -un)" "$(id -u)" printf 'groups=%s\n' "$(id -Gn 2>/dev/null | tr ' ' ',')" printf 'client=%s\n' "${SSH_CLIENT%% *}" if have sudo; then out=$(sudo -n -p '' true 2>&1 /dev/null | head -n 1 | tr -d '"' | oneline)" printf 'osId=%s\n' "$(sed -n 's/^ID=//p' /etc/os-release 2>/dev/null | head -n 1 | tr -d '"' | oneline)" for tool in useradd usermod userdel gpasswd chpasswd runuser setpriv sudo systemctl pgrep base64; do have "$tool" && printf 'tool=%s\n' "$tool" done SSHD=$(sshd_bin); [ -n "$SSHD" ] && printf 'tool=sshd\n' printf 'layout=%s\n' "$(layout_state)" printf 'managedPath=%s\n' "$SSHD_MANAGED" section passwd; getent passwd | head -n 5000 section group; getent group | head -n 5000 section shells; grep -E '^/' /etc/shells 2>/dev/null print_managed if is_root; then section admingroups for group in sudo wheel admin; do getent group "$group" >/dev/null 2>&1 && sudoers_group_rule "$group" && printf '%s\n' "$group" done section shadow # Password hashes never leave the server: only a coarse state is printed. shadow_db | awk -F: '{ h=$2; s="set" if (h=="") s="empty" else if (h=="!" || h=="!!" || h=="!*" || h=="*" || h=="*LK*") s="none" else if (substr(h,1,1)=="!") s="locked" print $1 ":" s ":" $3 ":" $8 }' section sudo min=$(uid_min) getent passwd | while IFS=: read -r name _ uid _; do case $uid in ''|*[!0-9]*) continue ;; esac if [ "$uid" -eq 0 ] || { [ "$uid" -ge "$min" ] && [ "$uid" -ne 65534 ]; } || is_member "$name" sudo || is_member "$name" wheel || is_member "$name" admin; then printf '%s:%s\n' "$name" "$(sudo_summary "$name")" fi done | head -n 500 fi section end } op_detail() { [ $# -eq 3 ] || fail ARGUMENTS_INVALID "Invalid arguments." name=$1; addr4=$2; addr6=$3 valid_name "$name" || fail NAME_INVALID "Invalid account name." { [ -z "$addr4" ] || valid_addr "$addr4"; } && { [ -z "$addr6" ] || valid_addr "$addr6"; } || fail ARGUMENTS_INVALID "Invalid address." entry=$(passwd_entry "$name"); [ -n "$entry" ] || fail USER_NOT_FOUND "The account does not exist." uid=$(printf '%s' "$entry" | cut -d: -f3); home=$(printf '%s' "$entry" | cut -d: -f6) section account printf 'passwd=%s\n' "$entry" printf 'uidMin=%s\n' "$(uid_min)" printf 'groups=%s\n' "$(id -Gn "$name" 2>/dev/null | tr ' ' ',')" if have pgrep; then printf 'processes=%s\n' "$(pgrep -u "$name" 2>/dev/null | wc -l | tr -d ' ')"; fi printf 'homeMeta=%s\n' "$(file_meta "$home")" if is_root; then printf 'shadow=%s\n' "$(shadow_db | awk -F: -v n="$name" '$1==n' | awk -F: '{ h=$2; s="set"; if (h=="") s="empty"; else if (h=="!" || h=="!!" || h=="!*" || h=="*" || h=="*LK*") s="none"; else if (substr(h,1,1)=="!") s="locked" print s ":" $3 ":" $8 }')" printf 'sudo=%s\n' "$(sudo_summary "$name")" fi section ssh SSHD=$(sshd_bin) akf=".ssh/authorized_keys .ssh/authorized_keys2" if [ -z "$SSHD" ]; then printf 'sshd=missing\n' else printf 'sshd=%s\nversion=%s\n' "$SSHD" "$("$SSHD" -V 2>&1 | head -n 1 | oneline)" printf 'layout=%s\nservice=%s\n' "$(layout_state)" "$(service_state)" if is_root; then out=$("$SSHD" -t 2>&1); code=$? printf 'syntax=%s\nsyntaxMessage=%s\n' "$code" "$(printf '%s' "$out" | head -n 3 | oneline)" if [ -n "$addr4" ]; then section effective4; effective "$name" "$addr4"; fi if [ -n "$addr6" ]; then section effective6; effective "$name" "$addr6"; fi configured=$(effective "$name" "${addr4:-${addr6:-192.0.2.10}}" | sed -n 's/^authorizedkeysfile //p' | head -n 1) [ -n "$configured" ] && akf=$configured fi fi print_managed section keys printf 'home=%s\n' "$home" set -f count=0 for spec in $akf; do count=$((count + 1)); [ "$count" -le 4 ] || break [ "$spec" != none ] || continue path=$(expand_keys_path "$spec" "$home" "$name" "$uid") printf '\036CHJ_KEYFILE=%s\n' "$path" printf 'spec=%s\n' "$spec" case $path in UNSUPPORTED) printf 'type=unsupported\n'; continue ;; esac printf 'meta=%s\ndirMeta=%s\n' "$(file_meta "$path")" "$(file_meta "${path%/*}")" run_as "$name" sh -c "$READ_KEYS" chj-keys "$path" 2>/dev/null || printf 'type=unreadable\n' done set +f section end } op_keys_write() { [ $# -eq 3 ] || fail ARGUMENTS_INVALID "Invalid arguments." name=$1; path=$2; expected=$3 valid_name "$name" || fail NAME_INVALID "Invalid account name." valid_path "$path" || fail KEYS_PATH_UNSUPPORTED "The authorized_keys path is not supported." case $expected in absent|[0-9a-f]*) ;; *) fail ARGUMENTS_INVALID "Invalid checksum." ;; esac uid=$(user_uid "$name"); [ -n "$uid" ] || fail USER_NOT_FOUND "The account does not exist." home=$(user_home "$name") valid_path "$home" && [ "$home" != / ] || fail KEYS_PATH_UNSUPPORTED "The home directory is not usable." case $path in "$home"/*) ;; *) fail KEYS_PATH_UNSUPPORTED "Only key files inside the home directory are managed." ;; esac read_frame DATA || fail PROTOCOL_ERROR "Key data was not received." work=$(mktemp -d) || fail TEMPORARY_FAILED "Temporary directory could not be created." trap 'rm -rf -- "$work"' EXIT printf '%s' "$FRAME" | b64d > "$work/data" || fail PROTOCOL_ERROR "Key data could not be decoded." run_as "$name" sh -c "$WRITE_KEYS" chj-keys "$path" "$expected" < "$work/data" code=$? case $code in 0) ;; 21|24) fail KEYS_SYMLINK "The key file or its directory is a symbolic link; it was not changed." ;; 22|23) fail KEYS_DIRECTORY_FAILED "The .ssh directory could not be created or is not a directory." ;; 25) fail KEYS_NOT_REGULAR "The key file is not a regular file." ;; 26) fail KEYS_CHANGED "The key file changed after it was read. Reload and try again." ;; 125) fail PRIVILEGE_REQUIRED "Root privileges are required for keys of another account." ;; *) fail KEYS_WRITE_FAILED "The key file could not be written (exit $code)." ;; esac if is_root && have restorecon; then restorecon -F "${path%/*}" "$path" >/dev/null 2>&1 || true; fi printf 'sha256=%s\nmeta=%s\ndirMeta=%s\n' "$(run_as "$name" sh -c 'if command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | cut -c1-64; else openssl dgst -sha256 < "$1" | sed "s/^.*= *//"; fi' chj-keys "$path")" "$(file_meta "$path")" "$(file_meta "${path%/*}")" printf '\036CHJ_RESULT=ok\n' } check_shell() { valid_path "$1" || fail SHELL_INVALID "Invalid shell path." [ -x "$1" ] || fail SHELL_INVALID "The shell does not exist or is not executable." case ${1##*/} in nologin|false) return 0 ;; esac grep -qxF "$1" /etc/shells 2>/dev/null || fail SHELL_INVALID "The shell is not listed in /etc/shells." } set_password() { have chpasswd || { ERRMSG="chpasswd is not available"; return 127; } read_frame SECRET || { ERRMSG="password was not received"; return 2; } secret=$(printf '%s' "$FRAME" | b64d) || { FRAME=; ERRMSG="password could not be decoded"; return 3; } FRAME= [ -n "$secret" ] || { ERRMSG="empty password"; return 4; } ERRMSG=$(printf '%s:%s\n' "$1" "$secret" | chpasswd 2>&1); code=$? secret= return $code } grant_sudo() { group=$(admin_group) || { ERRMSG="No sudo, wheel or admin group is referenced by sudoers; sudoers was not changed."; return 2; } ERRMSG=$(usermod -a -G "$group" -- "$1" 2>&1) || return 1 ERRMSG="group $group" } revoke_sudo() { removed= for group in sudo wheel admin; do is_member "$1" "$group" || continue if have gpasswd; then ERRMSG=$(gpasswd -d "$1" "$group" 2>&1) || return 1 elif have deluser; then ERRMSG=$(deluser "$1" "$group" 2>&1) || return 1 else ERRMSG="gpasswd is not available"; return 127; fi removed="$removed $group" done ERRMSG="removed from:${removed:- none}" } op_create() { [ $# -eq 9 ] || fail ARGUMENTS_INVALID "Invalid arguments." name=$1; shell=$2; home=$3; mkhome=$4; primary=$5; groups=$6; comment=$7; admin=$8; withpw=$9 need_root valid_name "$name" || fail NAME_INVALID "Invalid account name." have useradd || fail TOOL_MISSING "useradd is not available on this server." [ -z "$(passwd_entry "$name")" ] || fail USER_EXISTS "The account already exists." if [ -z "$primary" ] && getent group "$name" >/dev/null 2>&1; then fail GROUP_EXISTS "A group with this name already exists; select a primary group."; fi check_shell "$shell" if [ -n "$home" ]; then valid_path "$home" || fail HOME_INVALID "Invalid home directory."; fi if [ -n "$home" ] && [ "$mkhome" = 1 ] && [ -e "$home" ]; then fail HOME_EXISTS "The home directory already exists."; fi if [ -n "$primary" ]; then valid_name "$primary" && getent group "$primary" >/dev/null 2>&1 || fail GROUP_NOT_FOUND "Primary group does not exist."; fi for group in $(printf '%s' "$groups" | tr ',' ' '); do valid_name "$group" && getent group "$group" >/dev/null 2>&1 || fail GROUP_NOT_FOUND "Group $group does not exist." done set -- -s "$shell" if [ "$mkhome" = 1 ]; then set -- "$@" -m; else set -- "$@" -M; fi [ -n "$home" ] && set -- "$@" -d "$home" if [ -n "$primary" ]; then set -- "$@" -g "$primary"; else set -- "$@" -U; fi [ -n "$groups" ] && set -- "$@" -G "$groups" [ -n "$comment" ] && set -- "$@" -c "$comment" out=$(useradd "$@" -- "$name" 2>&1) || fail USERADD_FAILED "$out" step create ok "$out" result=ok if [ "$withpw" = 1 ]; then if set_password "$name"; then step password ok; else step password failed "$ERRMSG"; result=partial; fi fi if [ "$admin" = 1 ]; then if grant_sudo "$name"; then step sudo ok "$ERRMSG"; else step sudo failed "$ERRMSG"; result=partial; fi fi printf '\036CHJ_RESULT=%s\n' "$result" } op_account() { [ $# -ge 2 ] || fail ARGUMENTS_INVALID "Invalid arguments." action=$1; name=$2; value=${3:-}; flag=${4:-0} need_root protect "$name" have usermod || fail TOOL_MISSING "usermod is not available on this server." case $action in lock) out=$(usermod -L -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; unlock) out=$(usermod -U -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; expire) out=$(usermod -e 1 -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; unexpire) out=$(usermod -e '' -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; password) set_password "$name" || fail PASSWORD_FAILED "$ERRMSG" ;; shell) check_shell "$value"; out=$(usermod -s "$value" -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; comment) out=$(usermod -c "$value" -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; home) valid_path "$value" && [ "$value" != / ] || fail HOME_INVALID "Invalid home directory." if [ "$flag" = 1 ]; then [ ! -e "$value" ] || fail HOME_EXISTS "The target directory already exists." out=$(usermod -d "$value" -m -- "$name" 2>&1) || fail USERMOD_FAILED "$out" else out=$(usermod -d "$value" -- "$name" 2>&1) || fail USERMOD_FAILED "$out"; fi ;; primary) valid_name "$value" && getent group "$value" >/dev/null 2>&1 || fail GROUP_NOT_FOUND "The group does not exist." out=$(usermod -g "$value" -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; group-add) valid_name "$value" && getent group "$value" >/dev/null 2>&1 || fail GROUP_NOT_FOUND "The group does not exist." out=$(usermod -a -G "$value" -- "$name" 2>&1) || fail USERMOD_FAILED "$out" ;; group-remove) valid_name "$value" || fail GROUP_NOT_FOUND "Invalid group." is_member "$name" "$value" || fail GROUP_NOT_MEMBER "The account is not a supplementary member of this group." if have gpasswd; then out=$(gpasswd -d "$name" "$value" 2>&1) || fail GROUP_CHANGE_FAILED "$out" elif have deluser; then out=$(deluser "$name" "$value" 2>&1) || fail GROUP_CHANGE_FAILED "$out" else fail TOOL_MISSING "gpasswd is not available on this server."; fi ;; sudo-grant) grant_sudo "$name" || fail SUDO_GROUP_UNAVAILABLE "$ERRMSG"; step sudo ok "$ERRMSG" ;; sudo-revoke) revoke_sudo "$name" || fail GROUP_CHANGE_FAILED "$ERRMSG"; step sudo ok "$ERRMSG" ;; *) fail ACTION_INVALID "Unsupported account action." ;; esac [ "$action" = sudo-grant ] || [ "$action" = sudo-revoke ] || step "$action" ok if [ "$action" = sudo-grant ] || [ "$action" = sudo-revoke ]; then printf '\036CHJ_SUDO=%s\n' "$(sudo_summary "$name")"; fi printf '\036CHJ_RESULT=ok\n' } op_delete() { [ $# -eq 2 ] || fail ARGUMENTS_INVALID "Invalid arguments." name=$1; remove_home=$2 need_root protect "$name" have userdel || fail TOOL_MISSING "userdel is not available on this server." if have pgrep && pgrep -u "$name" >/dev/null 2>&1; then fail USER_HAS_PROCESSES "The account still has running processes."; fi if [ "$remove_home" = 1 ]; then out=$(userdel -r -- "$name" 2>&1); code=$?; else out=$(userdel -- "$name" 2>&1); code=$?; fi [ -z "$(passwd_entry "$name")" ] || fail USERDEL_FAILED "$out" if [ "$code" -eq 0 ]; then step delete ok; printf '\036CHJ_RESULT=ok\n' else step delete ok; step home failed "$out"; printf '\036CHJ_RESULT=partial\n'; fi } # Staged and confirmed change of the CH-J managed sshd drop-in. Core decides via stdin: # COMMIT reloads sshd, CONFIRM keeps it; ROLLBACK, EOF or the watchdog restores the previous file. op_ssh_apply() { [ $# -ge 2 ] || fail ARGUMENTS_INVALID "Invalid arguments." expected=$1; timeout=$2; shift 2 case $expected in absent|[0-9a-f]*) ;; *) fail ARGUMENTS_INVALID "Invalid checksum." ;; esac case $timeout in ''|*[!0-9]*) fail ARGUMENTS_INVALID "Invalid timeout." ;; esac for tuple in "$@"; do valid_name "${tuple%%|*}" && valid_addr "${tuple#*|}" || fail ARGUMENTS_INVALID "Invalid verification target."; done need_root SSHD=$(sshd_bin); [ -n "$SSHD" ] || fail SSHD_MISSING "sshd was not found." [ "$(layout_state)" = ok ] || fail SSH_LAYOUT_UNSUPPORTED "sshd_config does not include sshd_config.d before its first Match block." [ -d "$SSHD_DROPIN" ] && [ ! -L "$SSHD_DROPIN" ] || fail SSH_LAYOUT_UNSUPPORTED "sshd_config.d is missing or is a symbolic link." if [ -e "$SSHD_MANAGED" ] || [ -L "$SSHD_MANAGED" ]; then [ -f "$SSHD_MANAGED" ] && [ ! -L "$SSHD_MANAGED" ] || fail SSH_MANAGED_FILE_UNSAFE "The managed file is not a regular file." fi [ "$(sha256_of "$SSHD_MANAGED")" = "$expected" ] || fail SSH_POLICY_CHANGED "The managed SSH policy changed after it was read." out=$("$SSHD" -t 2>&1) || fail SSH_CONFIG_ALREADY_INVALID "$out" read_frame DATA || fail PROTOCOL_ERROR "Policy data was not received." mkdir -p "$STATE_DIR" && chmod 700 "$STATE_DIR" || fail STATE_FAILED "State directory could not be created." work=$(mktemp -d "$STATE_DIR/apply.XXXXXX") || fail STATE_FAILED "Work directory could not be created." printf '%s' "$FRAME" | b64d > "$work/new" || { rm -rf -- "$work"; fail PROTOCOL_ERROR "Policy data could not be decoded."; } stamp=$(date -u +%Y%m%dT%H%M%SZ) if [ -f "$SSHD_MANAGED" ]; then had=1; backup="$STATE_DIR/sshd-managed.$stamp.conf.bak"; cp -p "$SSHD_MANAGED" "$backup" || fail STATE_FAILED "Backup failed." else had=0; backup="$STATE_DIR/sshd-managed.$stamp.absent"; : > "$backup"; fi ls -1t "$STATE_DIR"/sshd-managed.* 2>/dev/null | tail -n +11 | while IFS= read -r old; do rm -f -- "$old"; done restore() { if [ "$had" = 1 ]; then cp -p "$backup" "$SSHD_DROPIN/.chj-users.restore" && mv -f "$SSHD_DROPIN/.chj-users.restore" "$SSHD_MANAGED" else rm -f -- "$SSHD_MANAGED"; fi } claim() { mv "$work/pending" "$work/claimed" 2>/dev/null; } for tuple in "$@"; do printf '\036CHJ_BEFORE=%s\n' "$tuple"; effective "${tuple%%|*}" "${tuple#*|}"; done if [ -s "$work/new" ]; then cp "$work/new" "$SSHD_DROPIN/.chj-users.tmp" && chmod 644 "$SSHD_DROPIN/.chj-users.tmp" && chown 0:0 "$SSHD_DROPIN/.chj-users.tmp" \ && mv -f "$SSHD_DROPIN/.chj-users.tmp" "$SSHD_MANAGED" || { rm -f -- "$SSHD_DROPIN/.chj-users.tmp"; restore; fail SSH_WRITE_FAILED "The managed file could not be written."; } else rm -f -- "$SSHD_MANAGED"; fi if ! out=$("$SSHD" -t 2>&1); then restore; rm -rf -- "$work"; fail SSH_CONFIG_INVALID "$out"; fi for tuple in "$@"; do printf '\036CHJ_AFTER=%s\n' "$tuple"; effective "${tuple%%|*}" "${tuple#*|}"; done : > "$work/pending" ( trap '' HUP INT sleep "$timeout" if mv "$work/pending" "$work/claimed" 2>/dev/null; then restore [ -e "$work/reloaded" ] && reload_sshd >/dev/null 2>&1 printf 'watchdog rollback %s\n' "$stamp" > "$STATE_DIR/last-watchdog-rollback" fi rm -rf -- "$work" ) /dev/null 2>&1 & printf '\036CHJ_STAGED\n' IFS= read -r decision || decision=EOF if [ "$decision" != COMMIT ]; then claim && restore printf '\036CHJ_ROLLED_BACK=%s\n' "$decision"; exit 0 fi if ! out=$(reload_sshd); then claim && restore "$SSHD" -t >/dev/null 2>&1 && reload_sshd >/dev/null 2>&1 fail SSH_RELOAD_FAILED "$out" fi : > "$work/reloaded" printf '\036CHJ_RELOADED=%s|%s\n' "$(service_state)" "$(printf '%s' "$out" | oneline)" IFS= read -r decision || decision=EOF if [ "$decision" = CONFIRM ] && claim; then printf '\036CHJ_CONFIRMED=%s\n' "$backup"; exit 0 fi if claim; then restore; reload_sshd >/dev/null 2>&1; printf '\036CHJ_ROLLED_BACK=%s\n' "$decision" else printf '\036CHJ_ROLLED_BACK=watchdog\n'; fi exit 0 } case $op in probe) op_probe ;; inventory) op_inventory ;; detail) op_detail "$@" ;; keys-write) op_keys_write "$@" ;; create) op_create "$@" ;; account) op_account "$@" ;; delete) op_delete "$@" ;; ssh-apply) op_ssh_apply "$@" ;; *) fail ACTION_INVALID "Unsupported operation." ;; esac