Files
netbian/lib.sh
2026-03-15 19:58:46 +01:00

740 lines
17 KiB
Bash
Executable File

#!/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 <relpath> <dest_dir>" >&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
}