From 9b09f9cd92c6f14d781b6bbf7d7478d0d742e224 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 3 Jun 2026 12:06:26 +0200 Subject: [PATCH] Setup repository --- .gitignore | 0 LICENSE | 11 + domain-join.sh | 1072 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1083 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 domain-join.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..721fd3f --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Proprietary License + +Copyright (c) 2026 Florian Zumpe. All rights reserved. + +Permission is granted to the recipient to run this script locally for its intended +purpose. + +No permission is granted to copy, modify, distribute, sublicense, publish, sell, +lease, host, or otherwise make this script available to third parties. + +The script is provided "as is", without warranty of any kind. \ No newline at end of file diff --git a/domain-join.sh b/domain-join.sh new file mode 100644 index 0000000..a2e2b15 --- /dev/null +++ b/domain-join.sh @@ -0,0 +1,1072 @@ +#!/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:" + printf ' - %s\n' $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 sid kid + sid="$(keyctl new_session 2>/dev/null)" || 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):" + printf ' - %s\n' $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 \ No newline at end of file