All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 24s
1116 lines
32 KiB
Bash
1116 lines
32 KiB
Bash
#!/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 |