#!/usr/bin/env bash # Bibliotheque centrale if [[ "${NETBIAN_LIB_SOURCED:-}" == "1" ]]; then return 0 2>/dev/null || exit 0 fi NETBIAN_LIB_SOURCED=1 readonly NETBIAN_LIB_SOURCED ############################################################################### ### Helpers generaux # enable_strict_mode() { set -Eeuo pipefail } err() { echo "$@" >&2; } errln() { printf '%s\n' "$*" >&2; } fatal() { err "$@" exit 2 } exists_cmd() { command -v "$1" >/dev/null 2>&1 } safe_tempfile() { mktemp --tmpdir netbian.tmp.XXXXXX 2>/dev/null || mktemp /tmp/netbian.tmp.XXXXXX } require_debian_version() { local requested="${1:-}" [[ -n "$requested" ]] || fatal "Missing required Debian version argument." [[ -r /etc/os-release ]] || fatal "/etc/os-release not found or not readable." . /etc/os-release [[ "${ID:-}" == "debian" ]] || fatal "Not Debian (ID=${ID:-})." case "${VERSION_ID:-}" in "$requested" | "$requested".*) return 0 ;; *) fatal "You are using Debian ${VERSION_ID:-}, Debian ${requested} is required." ;; esac } require_valid_name() { local kind="$1" value="$2" [[ -n "$value" ]] || fatal "Missing ${kind}." [[ "$value" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fatal "Invalid ${kind}: ${value}" } validate_config_file() { local cfg="$1" line lineno=0 key value [[ -f "$cfg" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do lineno=$((lineno + 1)) [[ -z "$line" ]] && continue [[ "$line" =~ ^[[:space:]]*# ]] && continue if [[ ! "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then fatal "Invalid config line ${cfg}:${lineno}: ${line}" fi key="${line%%=*}" value="${line#*=}" case "$key" in profile) require_valid_name "profile" "$value" ;; lang) [[ -z "$value" || "$value" =~ ^[a-z]{2}([-_][a-z]{2})?$ ]] || fatal "Invalid language code in ${cfg}:${lineno}: ${value}" ;; esac done <"$cfg" } ############################################################################### ### Logging and execution summary # log_run() { printf '[RUN] %s\n' "$*"; } log_ok() { printf '[OK] %s\n' "$*"; } log_skip() { NETBIAN_SKIP_EVENTS=$(( ${NETBIAN_SKIP_EVENTS:-0} + 1 )) printf '[SKIP] %s\n' "$*" } log_warn() { printf '[WARN] %s\n' "$*"; } log_info() { printf '[INFO] %s\n' "$*"; } # Runtime flags NETBIAN_DRY_RUN="${NETBIAN_DRY_RUN:-0}" NETBIAN_DEBUG="${NETBIAN_DEBUG:-0}" # Execution summary if [[ -z "${NETBIAN_SUMMARY_INITIALIZED:-}" ]]; then declare -ag EXECUTED_STEPS=() declare -ag SKIPPED_STEPS=() declare -ag EXECUTED_ROLES=() declare -ag FAILED_STEPS=() NETBIAN_SKIP_EVENTS=0 NETBIAN_SUMMARY_INITIALIZED=1 fi debug_enabled() { [[ "${NETBIAN_DEBUG:-0}" == "1" ]] } dry_run_enabled() { [[ "${NETBIAN_DRY_RUN:-0}" == "1" ]] } record_executed_step() { EXECUTED_STEPS+=("$1") } record_skipped_step() { SKIPPED_STEPS+=("$1") } record_failed_step() { FAILED_STEPS+=("$1") } record_executed_role() { EXECUTED_ROLES+=("$1") } print_execution_summary() { local item echo echo "==== Execution summary ====" printf 'Roles executed : %d\n' "${#EXECUTED_ROLES[@]}" printf 'Steps executed : %d\n' "${#EXECUTED_STEPS[@]}" printf 'Steps skipped : %d\n' "${#SKIPPED_STEPS[@]}" printf 'Skip events : %d\n' "${NETBIAN_SKIP_EVENTS:-0}" printf 'Steps failed : %d\n' "${#FAILED_STEPS[@]}" if ((${#EXECUTED_ROLES[@]} > 0)); then echo "Executed roles:" for item in "${EXECUTED_ROLES[@]}"; do printf ' - %s\n' "$item" done fi if ((${#SKIPPED_STEPS[@]} > 0)); then echo "Skipped steps:" for item in "${SKIPPED_STEPS[@]}"; do printf ' - %s\n' "$item" done fi if ((${#FAILED_STEPS[@]} > 0)); then echo "Failed steps:" for item in "${FAILED_STEPS[@]}"; do printf ' - %s\n' "$item" done fi } ############################################################################### ### Gestion de fichiers # write_if_changed() { local src="$1" dst="$2" if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then rm -f "$src" return 1 fi mv "$src" "$dst" chmod 0644 "$dst" return 0 } write_text_file_if_changed() { local content="$1" dst="$2" mode="${3:-0644}" tmp install -d -m0755 "$(dirname "$dst")" tmp="$(safe_tempfile)" || tmp="/tmp/netbian.write.$$" printf '%s' "$content" >"$tmp" if write_if_changed "$tmp" "$dst"; then chmod "$mode" "$dst" return 0 fi return 1 } copy_config() { local rel="$1" dest_dir="$2" src file dest user rootname [[ -n "$rel" && -n "$dest_dir" ]] || { echo "Usage: copy_config " >&2 return 2 } src="$PROJECT_DIR/config/$rel" [[ -f "$src" ]] || { log_warn "$src not found - skipping" return 0 } file="$(basename "$rel")" dest="$(realpath -m "$dest_dir")/$file" install -d "$(dirname "$dest")" rootname="${rel%%/*}" rootname="$(tr '[:lower:]' '[:upper:]' <<<"${rootname:0:1}")${rootname:1}" if [[ ! -f "$dest" ]] || ! cmp -s "$src" "$dest"; then log_info "${rootname} configuration copied" cp -a "$src" "$dest" else log_skip "${rootname} already configured" fi if [[ "$dest" =~ ^/home/([^/]+)/ ]]; then user="${BASH_REMATCH[1]}" if id -u "$user" >/dev/null 2>&1; then chown "$user:$user" "$dest" chmod 0664 "$dest" return 0 fi fi chown root:root "$dest" chmod 0644 "$dest" return 0 } ############################################################################### ### Paquets / APT # ensure_packages_installed() { local to_install=() p prev_deb for p in "$@"; do if ! dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q '^install ok installed$'; then to_install+=("$p") fi done if ((${#to_install[@]} == 0)); then log_skip "packages already installed" return 3 fi prev_deb="${DEBIAN_FRONTEND:-}" export DEBIAN_FRONTEND=noninteractive if ! apt-get install -y "${to_install[@]}"; then if [[ -n "$prev_deb" ]]; then export DEBIAN_FRONTEND="$prev_deb" else unset DEBIAN_FRONTEND fi echo "Failed to install packages: ${to_install[*]}" >&2 return 1 fi if [[ -n "$prev_deb" ]]; then export DEBIAN_FRONTEND="$prev_deb" else unset DEBIAN_FRONTEND fi return 0 } add_apt_key_from_url() { local key_url="$1" keyring_path="$2" tmpkey tmpkeyring install -d -m0755 "$(dirname "$keyring_path")" tmpkey="$(safe_tempfile)" || tmpkey="/tmp/key.$$" tmpkeyring="$(safe_tempfile)" || tmpkeyring="/tmp/keyring.$$" ( set -e if exists_cmd curl; then curl -fsSL "$key_url" -o "$tmpkey" elif exists_cmd wget; then wget -qO "$tmpkey" "$key_url" else echo "curl or wget is required to download the key, but none are installed." >&2 exit 1 fi exists_cmd gpg || { echo "gpg is required to dearmor the key, but is not installed." >&2 exit 1 } gpg --batch --yes --dearmor -o "$tmpkeyring" "$tmpkey" mv -f "$tmpkeyring" "$keyring_path" chown root:root "$keyring_path" || true chmod a+r "$keyring_path" rm -f "$tmpkey" ) } add_apt_source_file() { local content="$1" dest="$2" tmpfile install -d -m0755 "$(dirname "$dest")" tmpfile="$(safe_tempfile)" || tmpfile="/tmp/src.$$" printf '%s\n' "$content" >"$tmpfile" if [[ -f "$dest" ]] && cmp -s "$tmpfile" "$dest"; then rm -f "$tmpfile" return 0 fi mv -f "$tmpfile" "$dest" chmod 0644 "$dest" return 0 } add_apt_sources_file() { add_apt_source_file "$1" "$2" } install_apt_repo() { local key_url="$1" keyring_path="$2" sources_content="$3" sources_path="$4" shift 4 local pkgs=("$@") all_installed=true p key_added=false src_changed=false for p in "${pkgs[@]}"; do if ! dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q 'installed'; then all_installed=false break fi done $all_installed && return 0 if [[ ! -f "$keyring_path" ]]; then if add_apt_key_from_url "$key_url" "$keyring_path"; then key_added=true else log_warn "failed to add key from $key_url" fi fi if [[ ! -f "$sources_path" ]] || ! printf '%s\n' "$sources_content" | cmp -s - "$sources_path"; then src_changed=true add_apt_sources_file "$sources_content" "$sources_path" fi if $key_added || $src_changed; then apt-get update fi return 0 } ############################################################################### ### Systeme # ufw_initialize() { ufw --force reset >/dev/null || true ufw default deny incoming ufw default allow outgoing ufw --force enable } apply_ufw_rules_file() { local rules_file="$1" rule [[ -f "$rules_file" ]] || return 0 while IFS= read -r rule || [[ -n "$rule" ]]; do rule="${rule%%#*}" rule="$(echo "$rule" | xargs)" [[ -z "$rule" ]] && continue log_info "Allowing firewall rule: $rule" ufw allow "$rule" done <"$rules_file" return 0 } remove_primary_network_section() { local if_file="/etc/network/interfaces" header='# The primary network interface' tmp if [[ -f "$if_file" ]] && grep -q "^${header}" "$if_file" && sed --version >/dev/null 2>&1; then tmp="$(mktemp --tmpdir netif.XXXXXX)" || tmp="/tmp/netif.$$" sed "/${header}/Q" "$if_file" >"$tmp" || true mv -f "$tmp" "$if_file" fi } ensure_grub_cmdline() { local f="${1:-/etc/default/grub}" from="${2:-quiet}" to="${3:-quiet loglevel=3 nowatchdog}" local pat="^GRUB_CMDLINE_LINUX_DEFAULT=\"${from}\"$" local repl="GRUB_CMDLINE_LINUX_DEFAULT=\"${to}\"" [[ -r "$f" ]] || { echo "File not found or not readable: $f" >&2 return 1 } if grep -qE "$pat" "$f"; then sed -i "s|${pat}|${repl}|" "$f" command -v update-grub >/dev/null 2>&1 && update-grub || true log_ok "GRUB configuration updated" else log_skip "GRUB command line already compliant" fi return 0 } service_exists() { local service="$1" systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '{print $1}' | grep -Fxq "${service}.service" } restart_service_if_present() { local service="$1" if service_exists "$service"; then log_info "Restarting ${service}" systemctl restart "$service" || true else log_skip "service ${service} absent" fi } ############################################################################### ### Utilisateur / roles # get_target_user() { local user="" if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then printf '%s\n' "$SUDO_USER" return 0 fi if [[ -n "${PKEXEC_UID:-}" ]] && user="$(id -nu "$PKEXEC_UID" 2>/dev/null || true)" && [[ -n "$user" && "$user" != "root" ]]; then printf '%s\n' "$user" return 0 fi user="$(logname 2>/dev/null || true)" if [[ -n "$user" && "$user" != "root" ]]; then printf '%s\n' "$user" return 0 fi return 1 } install_code_extensions() { local code_bin="$1" shift [[ -n "$code_bin" && "$#" -gt 0 ]] || return 0 if ! command -v "$code_bin" >/dev/null 2>&1 && [[ -x "/usr/bin/$code_bin" ]]; then code_bin="/usr/bin/$code_bin" elif ! command -v "$code_bin" >/dev/null 2>&1; then echo "Code binary $code_bin not found; skipping extensions install." >&2 return 0 fi local run_as ext installed_list run_as="$(get_target_user 2>/dev/null || true)" log_info "Synchronizing ${code_bin##*/} extensions" if [[ -n "$run_as" ]]; then installed_list="$(sudo -u "$run_as" env PATH="${PATH:-/usr/bin:/bin}" "$code_bin" --list-extensions 2>/dev/null || true)" else installed_list="$("$code_bin" --list-extensions 2>/dev/null || true)" fi for ext in "$@"; do if grep -Fqx "$ext" <<<"$installed_list"; then log_skip "extension '$ext' already installed" continue fi log_info "Installing extension '$ext'" if [[ -n "$run_as" ]]; then sudo -u "$run_as" env PATH="${PATH:-/usr/bin:/bin}" "$code_bin" --install-extension "$ext" --force || true else "$code_bin" --install-extension "$ext" --force || true fi done return 0 } configure_php_no_jit() { local php_version="${1:-$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || true)}" local mod_name="no-jit" mods_dir mod_file sapi local config_content='; Disable JIT because it conflicts with Xdebug opcache.jit=0 opcache.jit_buffer_size=0 ' [[ -n "$php_version" ]] || { echo "Unable to detect PHP version." >&2 return 1 } log_info "Using PHP version: ${php_version}" [[ -d "/etc/php/${php_version}" ]] || { echo "PHP config directory not found: /etc/php/${php_version}" >&2 return 1 } mods_dir="/etc/php/${php_version}/mods-available" mod_file="${mods_dir}/${mod_name}.ini" install -d "$mods_dir" write_text_file_if_changed "$config_content" "$mod_file" >/dev/null || true if exists_cmd phpenmod; then phpenmod -v "$php_version" "$mod_name" else echo "phpenmod not found; skipping module enablement." >&2 fi if exists_cmd phpquery; then echo echo "Module status by SAPI:" for sapi in cli apache2 fpm; do if phpquery -v "$php_version" -s "$sapi" -m "$mod_name" >/dev/null 2>&1; then echo " - ${sapi}: enabled" elif [[ -d "/etc/php/${php_version}/${sapi}" ]]; then echo " - ${sapi}: not enabled" fi done fi restart_service_if_present apache2 restart_service_if_present "php${php_version}-fpm" echo echo "Verification:" php -i | grep -E '^opcache.jit =>|^opcache.jit_buffer_size =>|^Loaded Configuration File|^Scan this dir for additional .ini files' || true return 0 } load_role_packages() { local role="$1" local packages_file="$ROLE_DIR/$role/packages.list" ROLE_PACKAGES=() if [[ -f "$packages_file" ]]; then # shellcheck disable=SC1090 source "$packages_file" fi return 0 } append_localized_packages() { local -n target_ref="$1" local lang_override="${lang:-}" l10n_fragment="$ROLE_DIR/base/l10n.packages" local mapping base prefix cand [[ -n "$lang_override" && -f "$l10n_fragment" ]] || return 0 L10N_MAP_PKGS=() source "$l10n_fragment" apt-get update >/dev/null 2>&1 || true for mapping in "${L10N_MAP_PKGS[@]:-}"; do base="${mapping%%::*}" prefix="${mapping##*::}" cand="${prefix}-${lang_override}" if apt-cache show "$cand" >/dev/null 2>&1 || { dpkg-query -W -f='${Status}' "$base" 2>/dev/null | grep -q 'installed' && ! dpkg-query -W -f='${Status}' "$cand" 2>/dev/null | grep -q 'installed' }; then case " ${target_ref[*]} " in *" $cand "*) : ;; *) target_ref+=("$cand") ;; esac fi done return 0 } verify_role_manifest() { local role="$1" local manifest="$ROLE_DIR/$role/packages.list" local line trimmed [[ -f "$manifest" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do trimmed="${line#"${line%%[![:space:]]*}"}" [[ -z "$trimmed" ]] && continue [[ "$trimmed" =~ ^# ]] && continue if [[ "$trimmed" =~ [[:space:]] ]]; then fatal "Invalid package entry in $manifest: $trimmed" fi done <"$manifest" return 0 } validate_role() { local role="$1" local role_path="$ROLE_DIR/$role" local allowed='^(repo\.sh|install\.sh|config\.sh|run\.sh|packages\.list|l10n\.packages|rules\.[A-Za-z0-9_-]+\.list)$' local item base require_valid_name "role" "$role" [[ -d "$role_path" ]] || fatal "Role not found: $role" shopt -s nullglob for item in "$role_path"/*; do base="$(basename "$item")" [[ "$base" =~ $allowed ]] || fatal "Unexpected file in role $role: $base" done shopt -u nullglob return 0 } run_role_script() { local role="$1" local step="$2" local script="$ROLE_DIR/$role/$step" local step_name="$role/$step" if [[ ! -f "$script" ]]; then return 0 fi log_run "$step_name" if dry_run_enabled; then log_ok "$step_name (dry-run)" record_skipped_step "$step_name (dry-run)" return 0 fi if bash "$script"; then log_ok "$step_name" record_executed_step "$step_name" else record_failed_step "$step_name" fatal "Step failed: $step_name" fi return 0 } run_role_packages() { local role="$1" local -n pkgs_ref="$2" local step_name="$role/packages.list" if ((${#pkgs_ref[@]} == 0)); then log_skip "$step_name empty" record_skipped_step "$step_name" return 0 fi log_run "$step_name" if dry_run_enabled; then printf '[DRY] Would ensure packages: %s\n' "${pkgs_ref[*]}" log_ok "$step_name (dry-run)" record_skipped_step "$step_name (dry-run)" return 0 fi local rc=0 if ensure_packages_installed "${pkgs_ref[@]}"; then rc=0 else rc=$? fi case "$rc" in 0) log_ok "$step_name" record_executed_step "$step_name" ;; 3) log_ok "$step_name (already compliant)" record_skipped_step "$step_name" ;; *) return 1 ;; esac return 0 } run_role() { local role="$1" local -a role_packages=() validate_role "$role" echo echo "==== Role: $role ====" run_role_script "$role" repo.sh || return 1 load_role_packages "$role" || return 1 role_packages=("${ROLE_PACKAGES[@]:-}") append_localized_packages role_packages || return 1 run_role_packages "$role" role_packages || return 1 run_role_script "$role" install.sh || return 1 run_role_script "$role" config.sh || return 1 run_role_script "$role" run.sh || return 1 record_executed_role "$role" return 0 } list_available_profiles() { local p shopt -s nullglob for p in "$PROFILE_DIR"/*.sh; do basename "${p%.sh}" done shopt -u nullglob return 0 } list_available_roles() { local r shopt -s nullglob for r in "$ROLE_DIR"/*; do [[ -d "$r" ]] || continue basename "$r" done shopt -u nullglob return 0 } validate_profile_definition() { local profile_file="$1" [[ -f "$profile_file" ]] || fatal "Profile definition not found: $profile_file" declare -a ROLE_ORDER=() # shellcheck disable=SC1090 source "$profile_file" ((${#ROLE_ORDER[@]} > 0)) || fatal "No roles declared in $profile_file" return 0 } validate_all_roles() { local role for role in "$@"; do validate_role "$role" verify_role_manifest "$role" done return 0 }