1
0
sssd-configuration/domain-join.sh
Florian Zumpe e399e0be3e
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 24s
Check ID mapping before print hints
2026-06-08 16:59:03 +02:00

1116 lines
32 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Ubuntu -> Active Directory Join (realmd + sssd) for VM and LXC (unprivileged)
# - DC discovery via DNS SRV (_ldap._tcp.<domain>) => no DC input required
# - DNS server discovery via NS records => no DNS IP input required
# - Installs required packages and updates system
# - Prefers systemd-timesyncd; falls back to chrony
# - Detects existing domain membership and asks whether to leave/cleanup before re-join
# - Writes SSSD config with AD provider, ID mapping and optional GPO disable
# - Ensures pam_mkhomedir is present in common-session and common-session-noninteractive
#
# LXC note:
# Unprivileged LXC needs host-side UID/GID mapping large enough for SSSD-mapped IDs.
# Script detects LXC and prints exact host instructions.
#
# Proxmox LXC UID/GID Mapping
#
# LXC user namespace per container:
# Container IDs 0 - 999999 are mapped to a host-side UID/GID block.
#
# Host UID/GID range per container:
# <ctid>000000 - <ctid>999999
#
# SSSD default ID range inside the container:
# 100000 - 999999
#
# The namespace therefore has enough space for local/system IDs below 100000
# and for SSSD/AD-mapped IDs from 100000 through 999999.
#
# /etc/pve/lxc/<ctid>.conf
# lxc.idmap: u 0 <ctid>000000 1000000
# lxc.idmap: g 0 <ctid>000000 1000000
#
# /etc/subuid
# root:<ctid>000000:1000000
#
# /etc/subgid
# root:<ctid>000000:1000000
#
# Restart:
# pct restart <ctid>
# =============================================================================
# --- Active Directory / Join settings ---
AD_DOMAIN_FQDN="${AD_DOMAIN_FQDN:-}" # AD domain FQDN (e.g. corp.example.com)
AD_JOIN_USER="${AD_JOIN_USER:-}" # Join user (without domain, e.g. joinuser)
# --- Session / shell settings ---
SESSION_TIMEOUT_SECONDS="${SESSION_TIMEOUT_SECONDS:-7200}" # 0 = disable idle logout (seconds)
# --- Computer account / naming ---
AD_OU_DN="${AD_OU_DN:-}" # Optional OU DN for the computer object
COMPUTER_NAME="${COMPUTER_NAME:-$(hostname -s)}" # Hostname short name
USE_FQ_NAMES="${USE_FQ_NAMES:-false}" # true/false: use fully qualified usernames
DISABLE_DNS_AUTOCONFIG="${DISABLE_DNS_AUTOCONFIG:-false}" # true/false: skip DNS auto config
# --- SSSD ID mapping tuning ---
LXC_IDMAP_RANGE_SIZE="${LXC_IDMAP_RANGE_SIZE:-1000000}"
IDMAP_RANGE_MIN="${IDMAP_RANGE_MIN:-100000}"
IDMAP_RANGE_SIZE="${IDMAP_RANGE_SIZE:-$((LXC_IDMAP_RANGE_SIZE - IDMAP_RANGE_MIN))}"
# Last ID in the mapping range.
# Example:
# MIN=100000
# LXC_IDMAP_RANGE_SIZE=1000000
# SIZE=900000
# MAX=999999
IDMAP_RANGE_MAX="${IDMAP_RANGE_MAX:-$((IDMAP_RANGE_MIN + IDMAP_RANGE_SIZE - 1))}"
# --- PAM home directory settings ---
PAM_UMASK="${PAM_UMASK:-0027}"
# --- Sudo group selection ---
SUDO_GROUP_FILTER="${SUDO_GROUP_FILTER:-LINUX}" # Default filter substring for group names
SUDOERS_DROPIN_FILE="${SUDOERS_DROPIN_FILE:-/etc/sudoers.d/90-ad-groups}"
# --- Control flags ---
SKIP_JOIN="false"
# --- SSSD credential caching (requires kernel keyring support) ---
ENABLE_CREDENTIAL_CACHING="${ENABLE_CREDENTIAL_CACHING:-false}"
log() { echo "[*] $*"; }
warn() { echo "[!] $*" >&2; }
die() { echo "[x] $*" >&2; exit 1; }
need_root() {
# Ensure the script is executed with root privileges.
[[ "$(id -u)" -eq 0 ]] || die "Please run this script as root."
}
have_dialog() {
command -v dialog >/dev/null 2>&1 && [[ -r /dev/tty ]] && [[ -w /dev/tty ]]
}
dialog_input() {
local title="$1" text="$2" default="${3:-}"
have_dialog || return 2
DIALOG_TTY=1 dialog --clear \
--title "$title" \
--inputbox "$text" 12 80 "$default" \
--stdout \
</dev/tty 2>/dev/tty
}
dialog_password() {
local title="$1" text="$2"
have_dialog || return 2
DIALOG_TTY=1 dialog --clear \
--title "$title" \
--passwordbox "$text" 12 80 \
--stdout \
--insecure \
</dev/tty 2>/dev/tty
}
dialog_yes_no() {
local title="$1" text="$2"
have_dialog || return 2
DIALOG_TTY=1 dialog --clear \
--title "$title" \
--yesno "$text" 12 70 \
</dev/tty 2>/dev/tty
}
prompt_if_empty() {
# Ensure a variable is set. If empty, prompt interactively (dialog if available, else read).
# Args: <varname> <prompt text> [secret=true|false]
local var="$1" text="$2" secret="${3:-false}"
local current="${!var:-}"
local val=""
[[ -n "$current" ]] && return 0
if [[ "$secret" == "true" ]]; then
if have_dialog; then
val="$(dialog_password "Input required" "$text")" || die "Canceled."
else
read -r -s -p "$text: " val; echo
fi
else
if have_dialog; then
val="$(dialog_input "Input required" "$text" "$current")" || die "Canceled."
else
read -r -p "$text: " val
fi
fi
printf -v "$var" '%s' "$val"
}
ask_yes_no() {
# Ask a yes/no question (dialog if available, else y/N prompt).
local text="$1"
if have_dialog; then
dialog_yes_no "Confirm" "$text"
return $?
fi
local ans
read -r -p "$text [y/N]: " ans
[[ "$ans" =~ ^[Yy] ]]
}
is_installed() {
# Check if a Debian package is installed.
dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "install ok installed"
}
detect_env() {
# Detect runtime environment.
# Primary: use systemd-detect-virt when available (VM + container detection).
# Fallback: parse /proc/1/environ (works on Alpine/OpenRC etc.).
#
# Returns: "lxc" | "vm" | "unknown"
# --- systemd path (preferred when present) ---
if command -v systemd-detect-virt >/dev/null 2>&1; then
# Container?
if systemd-detect-virt --container --quiet 2>/dev/null; then
# Identify LXC specifically
if systemd-detect-virt --container 2>/dev/null | grep -qi lxc; then
echo "lxc"
return 0
fi
echo "unknown"
return 0
fi
# VM?
if systemd-detect-virt --vm --quiet 2>/dev/null; then
echo "vm"
return 0
fi
fi
# --- fallback path (no systemd / systemd-detect-virt) ---
if [[ -r /proc/1/environ ]]; then
# /proc/1/environ is NUL-separated; convert to newline-separated KEY=VALUE lines
local env
env="$(tr '\0' '\n' </proc/1/environ 2>/dev/null || true)"
# LXC: container=lxc
if grep -qiE '^container=lxc$' <<<"$env"; then
echo "lxc"
return 0
fi
# Other containers
if grep -qiE '^container=' <<<"$env"; then
echo "unknown"
return 0
fi
fi
# --- last resort fallbacks ---
if [[ -r /proc/1/cgroup ]] && grep -qiE '(^|/)(lxc|liblxc)($|/)' /proc/1/cgroup; then
echo "lxc"
return 0
fi
if [[ -r /proc/1/cgroup ]] && grep -qiE '(docker|kubepods|containerd)' /proc/1/cgroup 2>/dev/null; then
echo "unknown"
return 0
fi
echo "unknown"
}
id_map_covers_container_range() {
# Check whether a /proc/*/{uid,gid}_map covers container IDs 0..LXC_IDMAP_RANGE_SIZE-1.
# Format:
# <container_start> <host_start> <length>
local map_file="$1"
local required_max=$((LXC_IDMAP_RANGE_SIZE - 1))
local container_start host_start length container_end
[[ -r "$map_file" ]] || return 1
while read -r container_start host_start length; do
[[ "$container_start" =~ ^[0-9]+$ ]] || continue
[[ "$host_start" =~ ^[0-9]+$ ]] || continue
[[ "$length" =~ ^[0-9]+$ ]] || continue
container_end=$((container_start + length - 1))
if [[ "$container_start" -le 0 && "$container_end" -ge "$required_max" ]]; then
return 0
fi
done <"$map_file"
return 1
}
lxc_idmap_is_sufficient() {
# The script runs inside the container, so the host-side mapping may already
# be configured. Verify that both UID and GID maps cover all container IDs
# required by the SSSD range before printing host instructions.
id_map_covers_container_range /proc/self/uid_map \
&& id_map_covers_container_range /proc/self/gid_map
}
print_lxc_current_idmaps() {
# Print current namespace mappings for diagnostics.
if [[ -r /proc/self/uid_map ]]; then
log "Current /proc/self/uid_map:"
sed 's/^/ /' /proc/self/uid_map || true
fi
if [[ -r /proc/self/gid_map ]]; then
log "Current /proc/self/gid_map:"
sed 's/^/ /' /proc/self/gid_map || true
fi
}
print_lxc_idmap_host_instructions() {
# Print host-side Proxmox/LXC idmap guidance for unprivileged containers.
cat <<EOF
[!] LXC detected (unprivileged container)
Host-side mapping template for Proxmox CTID <ctid>:
Host base:
<ctid>000000
/etc/pve/lxc/<ctid>.conf
lxc.idmap: u 0 <ctid>000000 ${LXC_IDMAP_RANGE_SIZE}
lxc.idmap: g 0 <ctid>000000 ${LXC_IDMAP_RANGE_SIZE}
/etc/subuid
root:<ctid>000000:${LXC_IDMAP_RANGE_SIZE}
/etc/subgid
root:<ctid>000000:${LXC_IDMAP_RANGE_SIZE}
Restart:
pct restart <ctid>
EOF
}
ensure_sssd_pipes() {
# Ensure SSSD sockets/pipes exist; missing pipes can break responders (PAC/PAM/NSS).
mkdir -p /var/lib/sss/pipes/private
chown -R root:root /var/lib/sss/pipes
chmod 0755 /var/lib/sss/pipes
chmod 0700 /var/lib/sss/pipes/private
}
restart_sssd() {
# Restart SSSD and ensure required pipe directories exist beforehand.
# Args: [context_message]
local ctx="${1:-sssd restart failed}"
ensure_sssd_pipes
if systemctl restart sssd; then
log "SSSD restart successful."
return 0
else
warn "${ctx}"
return 1
fi
}
apt_update_upgrade() {
# Update APT indices and upgrade installed packages.
log "APT: update"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
log "APT: upgrade"
apt-get upgrade -y
}
install_base_packages() {
# Install required packages for realmd/sssd, Kerberos, DNS discovery and dialogs.
log "APT: installing required packages (dig, Kerberos, realmd, sssd, ...)"
apt-get install -y --no-install-recommends \
ca-certificates \
dnsutils \
realmd adcli \
sssd sssd-tools \
libnss-sss libpam-sss \
krb5-user \
samba-common-bin \
packagekit \
net-tools \
ldap-utils \
openssl \
dialog \
keyutils
}
discover_domain_dns_ips() {
# Discover DNS server IPs by resolving authoritative NS records for the domain.
local domain="$1"
dig +short NS "$domain" \
| sed 's/\.$//' \
| while read -r ns; do
[[ -z "$ns" ]] && continue
local a
a="$(dig +short A "$ns" | head -n1 || true)"
if [[ -n "$a" ]]; then
echo "$a"
else
local aaaa
aaaa="$(dig +short AAAA "$ns" | head -n1 || true)"
[[ -n "$aaaa" ]] && echo "$aaaa"
fi
done \
| sort -u
}
discover_dcs() {
# Discover Domain Controllers using DNS SRV records (_ldap._tcp.<domain>).
local domain="$1"
dig +short SRV "_ldap._tcp.${domain}" \
| awk '{print $4}' \
| sed 's/\.$//' \
| sort -u
}
configure_dns() {
# Configure system DNS resolvers based on authoritative NS discovery (best effort).
[[ "$DISABLE_DNS_AUTOCONFIG" == "true" ]] && { warn "DNS autoconfiguration disabled."; return 0; }
log "Discovering DNS servers via NS records for ${AD_DOMAIN_FQDN}"
local dns_ips
dns_ips="$(discover_domain_dns_ips "${AD_DOMAIN_FQDN}" || true)"
if [[ -z "$dns_ips" ]]; then
warn "No NS->IP could be discovered. Keeping existing resolver configuration."
return 0
fi
log "Discovered DNS server IPs:"
while IFS= read -r ip; do
[[ -n "$ip" ]] && printf ' - %s\n' "$ip"
done <<< "$dns_ips"
if systemctl is-active --quiet systemd-resolved 2>/dev/null; then
log "Configuring DNS via systemd-resolved drop-in (/etc/systemd/resolved.conf.d/)"
mkdir -p /etc/systemd/resolved.conf.d
local dropin="/etc/systemd/resolved.conf.d/99-ad-dns.conf"
{
echo "[Resolve]"
echo -n "DNS="
echo "$dns_ips" | tr '\n' ' '
echo
echo "Domains=${AD_DOMAIN_FQDN}"
# We do not touch FallbackDNS/DNSSEC to avoid overriding local policy.
} >"$dropin"
systemctl restart systemd-resolved || true
else
warn "systemd-resolved is not active. Writing /etc/resolv.conf directly."
if [[ -L /etc/resolv.conf ]]; then
warn "/etc/resolv.conf is a symlink. Replacing it with a regular file."
rm -f /etc/resolv.conf
fi
{
echo "search ${AD_DOMAIN_FQDN}"
while read -r ip; do
[[ -n "$ip" ]] && echo "nameserver $ip"
done <<< "$dns_ips"
} >/etc/resolv.conf
fi
}
configure_time_sync() {
# Ensure NTP/time sync is enabled to avoid Kerberos issues (systemd-timesyncd preferred).
log "Checking time synchronization service"
if systemctl list-unit-files 2>/dev/null | grep -q '^systemd-timesyncd\.service'; then
log "systemd-timesyncd available → using it"
systemctl enable --now systemd-timesyncd || true
timedatectl set-ntp true || true
sleep 2
timedatectl status || true
return 0
fi
if command -v chronyc >/dev/null 2>&1 || is_installed chrony; then
log "chrony present → using it"
systemctl enable --now chrony || true
sleep 2
chronyc tracking || true
return 0
fi
warn "systemd-timesyncd not available falling back to chrony"
apt-get install -y --no-install-recommends chrony
systemctl enable --now chrony || true
sleep 2
chronyc tracking || true
}
current_realm() {
# Return the first realm-name from "realm list" output (best effort).
realm list 2>/dev/null | awk -F: '/^realm-name:/{print $2; exit}' | xargs || true
}
is_joined() {
# Check whether the machine is currently joined to any realm.
realm list >/dev/null 2>&1
}
leave_domain() {
# Leave an existing realm and remove local SSSD caches.
# Args: <realm_name> <leave_user> <leave_password>
local realm_name="$1"
local leave_user="$2"
local leave_pass="$3"
[[ -z "$realm_name" ]] && die "leave_domain: realm_name is empty."
log "Leaving existing realm: ${realm_name}"
printf '%s' "$leave_pass" | realm leave --verbose "$realm_name" -U "$leave_user" \
|| die "realm leave failed."
log "Cleaning SSSD cache (db/mc)"
systemctl stop sssd 2>/dev/null || true
rm -f /var/lib/sss/db/* 2>/dev/null || true
rm -f /var/lib/sss/mc/* 2>/dev/null || true
ensure_sssd_pipes
}
cleanup_domain_artifacts() {
# Best-effort local cleanup if realm name cannot be determined.
warn "No realm name could be determined. Performing local cleanup of domain artifacts."
systemctl stop sssd 2>/dev/null || true
systemctl stop realmd 2>/dev/null || true
rm -f /var/lib/sss/db/* 2>/dev/null || true
rm -f /var/lib/sss/mc/* 2>/dev/null || true
ensure_sssd_pipes
# Remove keytab for a clean re-join (comment out if not desired)
rm -f /etc/krb5.keytab 2>/dev/null || true
# Remove SSSD config (will be recreated)
rm -f /etc/sssd/sssd.conf 2>/dev/null || true
# Remove local realmd state
rm -f /var/lib/realmd/realmd-private.conf 2>/dev/null || true
rm -f /var/lib/realmd/realmd-secrets.db 2>/dev/null || true
systemctl restart systemd-resolved 2>/dev/null || true
}
write_krb5_conf() {
# Write a minimal /etc/krb5.conf that relies on DNS for realm/KDC discovery.
log "Writing /etc/krb5.conf (minimal; realm/KDC via DNS)"
cat >/etc/krb5.conf <<EOF
[libdefaults]
default_realm = $(echo "${AD_DOMAIN_FQDN}" | tr '[:lower:]' '[:upper:]')
dns_lookup_realm = true
dns_lookup_kdc = true
rdns = false
ticket_lifetime = 24h
forwardable = true
EOF
}
set_domain_option() {
# Set or replace a key/value inside the [domain/<domain>] section of /etc/sssd/sssd.conf.
# Args: <domain> <key> <value>
local domain="$1" key="$2" value="$3"
local conf="/etc/sssd/sssd.conf"
local tmp
tmp="$(mktemp)"
awk -v domain="$domain" -v key="$key" -v value="$value" '
BEGIN { in_dom = 0; seen_key = 0; target = "[domain/" domain "]" }
{
if ($0 ~ /^\[domain\//) {
if (in_dom == 1 && seen_key == 0) { print key " = " value; seen_key = 1 }
in_dom = ($0 == target) ? 1 : 0
}
if (in_dom == 1 && $0 ~ "^[[:space:]]*" key "[[:space:]]*=") {
print key " = " value
seen_key = 1
next
}
print
}
END { if (in_dom == 1 && seen_key == 0) print key " = " value }
' "$conf" >"$tmp"
mv "$tmp" "$conf"
chmod 600 "$conf"
}
ensure_pam_mkhomedir() {
# Ensure pam_mkhomedir is enabled for interactive and non-interactive sessions.
local line="session required pam_mkhomedir.so skel=/etc/skel umask=${PAM_UMASK}"
log "Ensuring pam_mkhomedir in common-session & common-session-noninteractive (umask=${PAM_UMASK})"
for f in /etc/pam.d/common-session /etc/pam.d/common-session-noninteractive; do
if [[ ! -f "$f" ]]; then
warn "$f does not exist skipping."
continue
fi
if grep -qE 'pam_mkhomedir\.so' "$f"; then
sed -i -E "s|^session\s+required\s+pam_mkhomedir\.so.*|${line}|g" "$f"
else
echo "$line" >>"$f"
fi
done
}
keyring_is_working() {
# Check whether keyctl calls work (not just installed).
command -v keyctl >/dev/null 2>&1 || return 1
keyctl show >/dev/null 2>&1 || return 1
# Write test: create new session + dummy key in @s
local kid
keyctl new_session >/dev/null 2>&1 || return 1
kid="$(printf 'ping' | keyctl padd user adjoin-key-test @s 2>/dev/null)" || return 1
keyctl unlink "$kid" @s >/dev/null 2>&1 || true
return 0
}
maybe_reset_sssd_idmap_interactive() {
# Ask whether to delete local SSSD databases/caches (forces fresh ID mapping).
warn "ID mapping reset:"
warn " This deletes the local SSSD cache (UID/GID mappings)."
warn " Use it if IDMAP_RANGE_* or ldap_id_mapping changed."
if ask_yes_no "Reset ID mapping and delete the SSSD cache?"; then
log "Stopping sssd"
systemctl stop sssd 2>/dev/null || true
log "Deleting SSSD DB and memory cache"
rm -f /var/lib/sss/db/* /var/lib/sss/mc/* 2>/dev/null || true
ensure_sssd_pipes
log "ID mapping reset completed."
return 0
fi
log "ID mapping reset skipped."
return 0
}
configure_sssd() {
# Write /etc/sssd/sssd.conf and restart SSSD. Optionally prompt for ID map reset.
local sssd_conf="/etc/sssd/sssd.conf"
log "Configuring SSSD: ${sssd_conf}"
local expected_max=$((IDMAP_RANGE_MIN + IDMAP_RANGE_SIZE - 1))
if [[ "$IDMAP_RANGE_MAX" -ne "$expected_max" ]]; then
warn "IDMAP_RANGE_MAX (${IDMAP_RANGE_MAX}) does not match MIN+SIZE-1 (${expected_max}). Setting MAX automatically."
IDMAP_RANGE_MAX="$expected_max"
fi
if [[ "$LXC_IDMAP_RANGE_SIZE" -le "$IDMAP_RANGE_MAX" ]]; then
die "LXC_IDMAP_RANGE_SIZE (${LXC_IDMAP_RANGE_SIZE}) is too small for container IDs 0..${IDMAP_RANGE_MAX}. It must be at least $((IDMAP_RANGE_MAX + 1))."
fi
local cache_credentials_val="false"
local store_offline_val="false"
if [[ "${ENABLE_CREDENTIAL_CACHING}" == "true" ]]; then
log "ENABLE_CREDENTIAL_CACHING=true → checking kernel keyring support (keyctl) for SSSD credential caching"
if keyring_is_working; then
log "Keyring OK → enabling cache_credentials and krb5_store_password_if_offline"
cache_credentials_val="true"
store_offline_val="true"
else
warn "Keyring not usable → credential cache remains disabled (recommended for unprivileged LXC without keyctl)."
warn "Proxmox LXC: check CT config 'features: keyctl=1'"
fi
else
log "ENABLE_CREDENTIAL_CACHING=false → skipping keyring check (default: no caching)"
fi
cat >"$sssd_conf" <<EOF
[sssd]
services = nss, pam, pac
config_file_version = 2
domains = ${AD_DOMAIN_FQDN}
[nss]
fallback_homedir = /home/%u
default_shell = /bin/bash
[pam]
pam_passkey_auth = false
[domain/${AD_DOMAIN_FQDN}]
id_provider = ad
auth_provider = ad
access_provider = ad
use_fully_qualified_names = $( [[ "$USE_FQ_NAMES" == "true" ]] && echo "true" || echo "false" )
fallback_homedir = /home/%u
default_shell = /bin/bash
ad_domain = ${AD_DOMAIN_FQDN}
krb5_realm = $(echo "${AD_DOMAIN_FQDN}" | tr '[:lower:]' '[:upper:]')
ldap_id_mapping = true
# ID mapping range
ldap_idmap_range_min = ${IDMAP_RANGE_MIN}
ldap_idmap_range_size = ${IDMAP_RANGE_SIZE}
ldap_idmap_range_max = ${IDMAP_RANGE_MAX}
enumerate = false
cache_credentials = ${cache_credentials_val}
krb5_store_password_if_offline = ${store_offline_val}
# Container-friendly: disable Windows GPO evaluation on Linux
ad_gpo_access_control = disabled
EOF
chmod 600 "$sssd_conf"
if compgen -G "/var/lib/sss/db/*" >/dev/null; then
maybe_reset_sssd_idmap_interactive
else
log "SSSD DB is empty → no ID mapping reset needed"
fi
ensure_pam_mkhomedir
log "Enabling and starting sssd"
systemctl enable --now sssd
restart_sssd "SSSD restart after writing configuration failed" || die "Critical error: aborting"
}
configure_session_timeout() {
# Configure interactive shell idle timeout for Bash (+ Zsh when present).
local timeout="$1"
local bash_file="/etc/profile.d/session-timeout.sh"
if [[ "$timeout" == "0" ]]; then
log "SESSION_TIMEOUT_SECONDS=0 → session timeout disabled"
rm -f "$bash_file"
# Zsh cleanup if present
if [[ -d /etc/zsh ]]; then
rm -f /etc/zsh/zshrc.d/99-session-timeout.zsh 2>/dev/null || true
if [[ -f /etc/zsh/zshrc ]]; then
sed -i '/^# BEGIN ad-join session-timeout$/,/^# END ad-join session-timeout$/d' /etc/zsh/zshrc || true
fi
fi
return 0
fi
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
warn "SESSION_TIMEOUT_SECONDS is not numeric (${timeout}) skipping"
return 0
fi
log "Setting session timeout to ${timeout} seconds"
cat >"$bash_file" <<EOF
# Automatically generated by AD join script
# Interactive shell idle timeout (seconds)
if [[ \$- == *i* ]]; then
TMOUT=${timeout}
readonly TMOUT
export TMOUT
fi
EOF
chmod 0644 "$bash_file"
# Zsh: TIMEOUT is minutes (integer). Round up.
if command -v zsh >/dev/null 2>&1 || [[ -d /etc/zsh ]]; then
local timeout_min
timeout_min=$(( (timeout + 59) / 60 ))
[[ "$timeout_min" -lt 1 ]] && timeout_min=1
log "Zsh detected → setting TMOUT + AUTOLOGOUT + TIMEOUT=${timeout_min}m (readonly)"
local zsh_snippet
zsh_snippet="$(cat <<EOF
# Automatically generated by AD join script
# Interactive shell idle timeout
if [[ -o interactive ]]; then
TMOUT=${timeout}
readonly TMOUT
export TMOUT
setopt AUTOLOGOUT
TIMEOUT=${timeout_min}
readonly TIMEOUT
fi
EOF
)"
if [[ -d /etc/zsh/zshrc.d ]]; then
local zsh_dropin="/etc/zsh/zshrc.d/99-session-timeout.zsh"
printf '%s\n' "$zsh_snippet" >"$zsh_dropin"
chmod 0644 "$zsh_dropin"
else
if [[ -f /etc/zsh/zshrc ]]; then
sed -i '/^# BEGIN ad-join session-timeout$/,/^# END ad-join session-timeout$/d' /etc/zsh/zshrc
else
mkdir -p /etc/zsh
touch /etc/zsh/zshrc
fi
{
echo "# BEGIN ad-join session-timeout"
printf '%s\n' "$zsh_snippet"
echo "# END ad-join session-timeout"
} >>/etc/zsh/zshrc
chmod 0644 /etc/zsh/zshrc
fi
fi
}
list_candidate_groups_nss() {
# List candidate groups from NSS for sudo selection.
# Output format: "groupname:gid"
# Filter logic:
# - group name contains filter substring (case-insensitive), or filter empty
# - optionally prefer gid >= IDMAP_RANGE_MIN (helps reduce local group noise)
local filter="$1"
getent group \
| awk -F: -v min_gid="$IDMAP_RANGE_MIN" -v f="$filter" '
BEGIN { IGNORECASE=1 }
{
name=$1; gid=$3;
if (f != "" && index(tolower(name), tolower(f)) == 0) next;
if (gid ~ /^[0-9]+$/ && gid >= min_gid) {
print name ":" gid
} else if (f != "") {
print name ":" gid
}
}' \
| sort -t: -k1,1
}
select_sudo_groups_dialog() {
# Show a dialog checklist to select AD groups to grant sudo permissions.
local filter="$1"
local items=()
while IFS=: read -r gname gid; do
[[ -z "$gname" ]] && continue
items+=("$gname" "gid=$gid" "off")
done < <(list_candidate_groups_nss "$filter")
if [[ "${#items[@]}" -eq 0 ]]; then
warn "No groups found via NSS (filter: '${filter}')."
return 1
fi
# --stdout avoids hanging when capturing output
# --separate-output prints one selection per line (easier parsing)
DIALOG_TTY=1 dialog --clear \
--title "Select AD groups for sudo" \
--stdout --separate-output \
--checklist "Filter: '${filter}'\n\nSelect AD groups to grant sudo:" \
--nocancel \
20 78 12 \
"${items[@]}"
}
manual_sudo_group_dialog() {
# Ask for one AD group manually when AD/NSS enumeration does not return groups.
# Empty input means: do not create a sudoers entry.
local group=""
if have_dialog; then
group="$(DIALOG_TTY=1 dialog --clear \
--title "Manual sudo group" \
--inputbox "No AD groups were returned by enumeration.\n\nEnter one AD group to grant sudo permissions.\nLeave empty to create no sudoers entry." \
13 80 "" \
--stdout)" || group=""
else
read -r -p "No AD groups were returned. Enter one sudo group manually (empty = none): " group
fi
# Trim leading/trailing whitespace while preserving internal spaces.
group="$(printf '%s' "$group" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
printf '%s\n' "$group"
}
sudoers_escape_group() {
# Escape group names for sudoers. This is required for names with spaces,
# e.g. "Domain Admins" -> "Domain\ Admins".
local group="$1"
local out=""
local char=""
local i
for ((i = 0; i < ${#group}; i++)); do
char="${group:i:1}"
case "$char" in
"\\"|" "|$'\t') out+="\\${char}" ;;
*) out+="${char}" ;;
esac
done
printf '%s' "$out"
}
write_sudoers_dropin() {
# Write a sudoers drop-in file for the selected AD groups and validate with visudo.
local dropin="$1"; shift
local groups=("$@")
if [[ "${#groups[@]}" -eq 0 ]]; then
warn "No groups selected → removing existing drop-in file if present: ${dropin}"
rm -f "$dropin"
return 0
fi
umask 022
{
echo "# Managed by ad-join script"
echo "# AD groups granted sudo:"
local escaped_group
for g in "${groups[@]}"; do
escaped_group="$(sudoers_escape_group "$g")"
echo "%${escaped_group} ALL=(ALL:ALL) ALL"
done
} >"$dropin"
chmod 0440 "$dropin"
if ! visudo -cf /etc/sudoers >/dev/null 2>&1; then
warn "visudo validation failed! Removing ${dropin} for safety."
rm -f "$dropin"
die "Generated sudoers configuration is invalid."
fi
log "Wrote sudoers drop-in: ${dropin}"
}
prompt_sudo_filter_dialog() {
# Ask whether to change the group filter, and return the (possibly updated) filter string.
local current="$1"
if have_dialog; then
if ! DIALOG_TTY=1 dialog --clear \
--title "Sudo group filter" \
--yesno "Current filter: '${current}'\n\nDo you want to change the filter?" \
10 70; then
echo "$current"
return 0
fi
local new
new="$(DIALOG_TTY=1 dialog --clear \
--title "Change sudo group filter" \
--inputbox "Enter a filter substring (case-insensitive).\nEmpty = show all groups." \
12 80 "$current" \
--stdout)" || return 1
echo "$new"
return 0
fi
# Fallback to plain read if no dialog/TTY
local ans
read -r -p "Current filter: '${current}'. Change it? [y/N]: " ans
if [[ "$ans" =~ ^[Yy]$ ]]; then
read -r -p "Enter filter substring (empty = all): " ans
echo "$ans"
else
echo "$current"
fi
}
configure_sudo_groups() {
# Allow interactive selection of AD groups and grant them sudo via a drop-in file.
log "Configuring sudo via AD groups (dialog)"
if [[ ! -t 0 ]]; then
warn "No interactive TTY found → skipping sudo group selection."
return 0
fi
if ! command -v dialog >/dev/null 2>&1; then
warn "dialog is not installed → skipping sudo group selection."
return 0
fi
local filter
filter="$(prompt_sudo_filter_dialog "${SUDO_GROUP_FILTER}")" || {
warn "Filter selection canceled → skipping sudo group selection."
return 0
}
log "Temporarily enabling SSSD enumeration=true to make groups visible via NSS."
set_domain_option "$AD_DOMAIN_FQDN" "enumerate" "true"
restart_sssd "SSSD restart after setting enumerate=true failed" || {
set_domain_option "$AD_DOMAIN_FQDN" "enumerate" "false"
warn "SSSD restart with enumerate=true failed → skipping sudo group selection."
return 0
}
local selected=""
local dialog_ok=0
if selected="$(select_sudo_groups_dialog "$filter")"; then
dialog_ok=1
else
dialog_ok=0
fi
log "Setting SSSD enumeration back to false (production mode)"
set_domain_option "$AD_DOMAIN_FQDN" "enumerate" "false"
restart_sssd "SSSD restart after setting enumerate=false failed" || die "Critical error: aborting"
if [[ "$dialog_ok" -ne 1 ]]; then
warn "AD group enumeration returned no selectable groups. Falling back to manual sudo group input."
selected="$(manual_sudo_group_dialog || true)"
fi
mapfile -t groups < <(printf '%s\n' "$selected" | sed '/^$/d')
if [[ "${#groups[@]}" -eq 0 ]]; then
warn "No sudo group selected or entered → no sudoers entry will be created."
fi
write_sudoers_dropin "$SUDOERS_DROPIN_FILE" "${groups[@]}"
}
# =============================================================================
# MAIN
# =============================================================================
need_root
ENV_KIND="$(detect_env)"
log "Detected environment: ${ENV_KIND}"
apt_update_upgrade
install_base_packages
if [[ "$ENV_KIND" == "lxc" ]]; then
log "LXC container detected. Checking current UID/GID namespace mappings."
print_lxc_current_idmaps
if lxc_idmap_is_sufficient; then
log "Current LXC UID/GID mappings already cover container IDs 0..$((LXC_IDMAP_RANGE_SIZE - 1))."
log "Host-side ID mapping instructions are not required."
else
warn "Current LXC UID/GID mappings do not cover container IDs 0..$((LXC_IDMAP_RANGE_SIZE - 1))."
warn "Host-side ID mapping configuration is required."
print_lxc_idmap_host_instructions
fi
else
log "No LXC environment detected. VM/Baremetal: host ID mapping is not relevant."
fi
prompt_if_empty AD_DOMAIN_FQDN "AD domain (FQDN), e.g. corp.example.com"
if [[ "$SKIP_JOIN" != "true" ]]; then
prompt_if_empty AD_JOIN_USER "Join user (without domain), e.g. joinuser"
fi
configure_dns
configure_time_sync
log "Setting short hostname to: ${COMPUTER_NAME}"
hostnamectl set-hostname "${COMPUTER_NAME}"
AD_JOIN_PASS=""
if [[ "$SKIP_JOIN" != "true" ]]; then
if is_joined; then
EXISTING_REALM="$(current_realm)"
warn "This system is already joined to a realm: ${EXISTING_REALM:-<unknown>}"
if ask_yes_no "Leave/cleanup the realm and perform join again?"; then
if [[ -n "$EXISTING_REALM" ]]; then
prompt_if_empty AD_JOIN_PASS "Password for ${AD_JOIN_USER}" true
leave_domain "$EXISTING_REALM" "$AD_JOIN_USER" "$AD_JOIN_PASS"
else
cleanup_domain_artifacts
fi
SKIP_JOIN="false"
else
if ask_yes_no "Abort? (No = update configuration without joining again)"; then
die "Aborted by user."
fi
warn "No realm leave chosen → skipping Kerberos/realm join steps and only updating configuration."
SKIP_JOIN="true"
fi
fi
fi
log "DNS check: SRV lookup _ldap._tcp.${AD_DOMAIN_FQDN}"
DC_LIST="$(discover_dcs "${AD_DOMAIN_FQDN}" || true)"
if [[ -n "$DC_LIST" ]]; then
log "Discovered Domain Controllers (via SRV):"
while IFS= read -r dc; do
[[ -n "$dc" ]] && printf ' - %s\n' "$dc"
done <<< "$DC_LIST"
else
warn "No DCs found via SRV. Join will likely fail (DNS not AD-capable?)."
fi
if [[ "$SKIP_JOIN" != "true" ]]; then
write_krb5_conf
log "realm discover ${AD_DOMAIN_FQDN}"
realm discover "${AD_DOMAIN_FQDN}" || die "realm discover failed. Check DNS/network."
prompt_if_empty AD_JOIN_PASS "Password for ${AD_JOIN_USER}" true
JOIN_ARGS=(join "--verbose" "${AD_DOMAIN_FQDN}" "-U" "${AD_JOIN_USER}")
if [[ -n "${AD_OU_DN}" ]]; then
JOIN_ARGS+=(--computer-ou="${AD_OU_DN}")
fi
log "Performing domain join (realm join)."
printf '%s' "${AD_JOIN_PASS}" | realm "${JOIN_ARGS[@]}" || die "realm join failed."
log "Verifying realm list"
realm list || die "realm list failed."
else
log "SKIP_JOIN=true → skipping realm discover/join. Verifying current membership:"
if ! realm list >/dev/null 2>&1; then
die "SKIP_JOIN=true but 'realm list' failed. State is inconsistent; please repair the realm/join."
fi
fi
configure_session_timeout "${SESSION_TIMEOUT_SECONDS}"
configure_sssd
configure_sudo_groups
log "Validation: realm list / sssctl"
realm list || true
sssctl config-check || true
sssctl domain-status "${AD_DOMAIN_FQDN}" || true
log "Done. Next tests:"
cat <<'EOF'
1) realm list
2) getent passwd <ADUser>
3) id <ADUser>
4) Test SSH login: ssh <ADUser>@<container-ip>
EOF
exit 0