#!/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.) => 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 # # UID and GID Range per container: # 0000000 - 9999999 # # Example: # CID=150 # Range=1500000000-1509999999 # # /etc/pve/lxc/.conf # lxc.idmap: u 0 0000000 10000000 # lxc.idmap: g 0 0000000 10000000 # # /etc/subuid # root:0000000:10000000 # # /etc/subgid # root:0000000:10000000 # # Restart: # pct restart # ============================================================================= # --- 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 --- IDMAP_RANGE_MIN="${IDMAP_RANGE_MIN:-100000}" IDMAP_RANGE_SIZE="${IDMAP_RANGE_SIZE:-100000}" # 100k # Last ID in the mapping range. # Example: # MIN=100000 # SIZE=100000 # MAX=199999 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 } 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 } 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 } prompt_if_empty() { # Ensure a variable is set. If empty, prompt interactively (dialog if available, else read). # Args: [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' /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" } detect_proxmox_ctid() { # Best-effort CTID detection from Proxmox LXC mount information. # Typical mountinfo contains strings like "subvol-150-disk-0". local ctid="" if [[ -r /proc/1/mountinfo ]]; then ctid="$(grep -oE 'subvol-[0-9]+' /proc/1/mountinfo 2>/dev/null | head -n1 | cut -d '-' -f2 || true)" fi if [[ -n "$ctid" && "$ctid" =~ ^[0-9]+$ ]]; then echo "$ctid" return 0 fi return 1 } print_lxc_idmap_host_instructions() { # Print host-side Proxmox/LXC idmap guidance for unprivileged containers. local ctid="" local base="" local title="" if ctid="$(detect_proxmox_ctid)"; then title="Configuration for detected CTID ${ctid}:" else ctid="150" title="Example for CTID ${ctid}:" fi base=$((ctid * 10000000)) cat <). 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: 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 <] section of /etc/sssd/sssd.conf. # Args: 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 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" </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" </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 <"$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 warn "LXC container detected. Host-side ID mapping configuration may be required." print_lxc_idmap_host_instructions 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:-}" 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 3) id 4) Test SSH login: ssh @ EOF exit 0