#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" die() { printf 'Erreur: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' Usage: ./scripts/add-ip-alias.sh --name NOM --addresses IP[,IP...] [options] Options: --name NOM Nom de l'alias pfSense. --addresses LISTE IP/FQDN separes par des virgules. --type TYPE host ou network. Defaut: host --description TEXT Description de l'alias. --details LISTE Descriptions des entrees, separees par des virgules. --no-apply Cree l'alias sans appliquer les changements. -h, --help Affiche cette aide. Exemple: ./scripts/add-ip-alias.sh --name SRV_WEB --addresses 192.168.1.50 --description "Serveur web interne" Sans argument, le script demande les valeurs manquantes en interactif. USAGE } require_command() { command -v "$1" >/dev/null 2>&1 || die "commande requise introuvable: $1" } load_env_file() { if [[ -f "${PROJECT_DIR}/.env" ]]; then set -a # shellcheck disable=SC1091 . "${PROJECT_DIR}/.env" set +a fi } require_var() { local name="$1" [[ -n "${!name:-}" ]] || die "variable obligatoire manquante: $name" } is_interactive() { [[ -t 0 && -t 1 ]] } arg_value() { local option="$1" local value="${2:-}" [[ -n "$value" && "$value" != --* ]] || die "valeur manquante pour $option" printf '%s' "$value" } prompt_required() { local var_name="$1" local label="$2" local value="" [[ -n "${!var_name:-}" ]] && return 0 is_interactive || die "variable obligatoire manquante: $var_name" while [[ -z "$value" ]]; do read -r -p "${label}: " value done printf -v "$var_name" '%s' "$value" } prompt_secret() { local var_name="$1" local label="$2" local value="" [[ -n "${!var_name:-}" ]] && return 0 is_interactive || die "variable obligatoire manquante: $var_name" while [[ -z "$value" ]]; do read -r -s -p "${label}: " value printf '\n' done printf -v "$var_name" '%s' "$value" } prompt_default() { local var_name="$1" local label="$2" local default_value="$3" local value="" [[ -n "${!var_name:-}" ]] && return 0 if ! is_interactive; then printf -v "$var_name" '%s' "$default_value" return 0 fi read -r -p "${label} [${default_value}]: " value printf -v "$var_name" '%s' "${value:-$default_value}" } prompt_optional() { local var_name="$1" local label="$2" local value="" [[ -n "${!var_name:-}" ]] && return 0 if ! is_interactive; then printf -v "$var_name" '' return 0 fi read -r -p "${label}: " value printf -v "$var_name" '%s' "$value" } prompt_bool() { local var_name="$1" local label="$2" local default_value="$3" local answer="" [[ -n "${!var_name:-}" ]] && return 0 if ! is_interactive; then printf -v "$var_name" '%s' "$default_value" return 0 fi while true; do read -r -p "${label} [${default_value}]: " answer answer="${answer:-$default_value}" case "$answer" in true|t|yes|y|oui|o|1) printf -v "$var_name" 'true' return 0 ;; false|f|no|n|non|0) printf -v "$var_name" 'false' return 0 ;; *) printf 'Reponse attendue: true/false, oui/non ou y/n.\n' >&2 ;; esac done } normalize_url() { printf '%s' "${1%/}" } trim() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } csv_to_json_array() { local csv="$1" local item="" local -a values=() local IFS=, read -r -a values <<< "$csv" for item in "${values[@]}"; do item="$(trim "$item")" [[ -n "$item" ]] && printf '%s\n' "$item" done | jq -R . | jq -s . } csv_count() { local csv="$1" local item="" local count=0 local -a values=() local IFS=, read -r -a values <<< "$csv" for item in "${values[@]}"; do item="$(trim "$item")" [[ -n "$item" ]] && ((count += 1)) done printf '%s' "$count" } is_ipv4() { local ip="$1" local IFS=. local -a octets [[ "$ip" =~ ^[0-9]+(\.[0-9]+){3}$ ]] || return 1 read -r -a octets <<< "$ip" for octet in "${octets[@]}"; do (( octet >= 0 && octet <= 255 )) || return 1 done } is_ipv4_cidr() { local value="$1" local ip="${value%/*}" local mask="${value#*/}" [[ "$value" == */* ]] || return 1 is_ipv4 "$ip" || return 1 [[ "$mask" =~ ^[0-9]+$ ]] && (( mask >= 0 && mask <= 32 )) } is_fqdn() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] } parse_args() { PFSENSE_ALIAS_NAME="${PFSENSE_ALIAS_NAME:-}" PFSENSE_ALIAS_ADDRESSES="${PFSENSE_ALIAS_ADDRESSES:-}" PFSENSE_ALIAS_TYPE="${PFSENSE_ALIAS_TYPE:-}" PFSENSE_ALIAS_DESCRIPTION="${PFSENSE_ALIAS_DESCRIPTION:-}" PFSENSE_ALIAS_DETAILS="${PFSENSE_ALIAS_DETAILS:-}" PFSENSE_ALIAS_APPLY="${PFSENSE_ALIAS_APPLY:-}" while (($#)); do case "$1" in --name) PFSENSE_ALIAS_NAME="$(arg_value "$1" "${2:-}")" shift 2 ;; --addresses) PFSENSE_ALIAS_ADDRESSES="$(arg_value "$1" "${2:-}")" shift 2 ;; --type) PFSENSE_ALIAS_TYPE="$(arg_value "$1" "${2:-}")" shift 2 ;; --description) PFSENSE_ALIAS_DESCRIPTION="$(arg_value "$1" "${2:-}")" shift 2 ;; --details) PFSENSE_ALIAS_DETAILS="$(arg_value "$1" "${2:-}")" shift 2 ;; --no-apply) PFSENSE_ALIAS_APPLY=false shift ;; -h|--help) usage exit 0 ;; *) die "argument inconnu: $1" ;; esac done } prompt_missing_inputs() { prompt_required PFSENSE_URL "URL pfSense, ex: https://pfsense.example.local" prompt_required PFSENSE_USER "Utilisateur API pfSense" prompt_secret PFSENSE_PASSWORD "Mot de passe API pfSense" prompt_required PFSENSE_ALIAS_NAME "Nom de l'alias" prompt_required PFSENSE_ALIAS_ADDRESSES "IP/FQDN de l'alias, separes par des virgules" prompt_default PFSENSE_ALIAS_TYPE "Type d'alias (host, network)" "host" prompt_default PFSENSE_ALIAS_DESCRIPTION "Description" "Alias IP API" prompt_optional PFSENSE_ALIAS_DETAILS "Descriptions des entrees, separees par des virgules" prompt_bool PFSENSE_ALIAS_APPLY "Appliquer immediatement les changements" "true" } validate_alias_entries() { local entry="" local -a entries=() local IFS=, read -r -a entries <<< "$PFSENSE_ALIAS_ADDRESSES" for entry in "${entries[@]}"; do entry="$(trim "$entry")" [[ -n "$entry" ]] || continue case "$PFSENSE_ALIAS_TYPE" in host) is_ipv4 "$entry" || is_fqdn "$entry" || die "entree invalide pour un alias host: $entry" ;; network) is_ipv4 "$entry" || is_ipv4_cidr "$entry" || is_fqdn "$entry" || die "entree invalide pour un alias network: $entry" ;; esac done } validate_inputs() { require_var PFSENSE_URL require_var PFSENSE_USER require_var PFSENSE_PASSWORD [[ "$PFSENSE_ALIAS_NAME" =~ ^[A-Za-z0-9_]+$ ]] || die "--name doit contenir uniquement lettres, chiffres et underscore" case "$PFSENSE_ALIAS_TYPE" in host|network) ;; *) die "--type doit valoir host ou network" ;; esac [[ "$(csv_count "$PFSENSE_ALIAS_ADDRESSES")" -gt 0 ]] || die "--addresses doit contenir au moins une entree" if [[ -n "$PFSENSE_ALIAS_DETAILS" ]]; then [[ "$(csv_count "$PFSENSE_ALIAS_DETAILS")" -eq "$(csv_count "$PFSENSE_ALIAS_ADDRESSES")" ]] || \ die "--details doit contenir le meme nombre d'entrees que --addresses" fi case "$PFSENSE_ALIAS_APPLY" in true|false) ;; *) die "PFSENSE_ALIAS_APPLY doit valoir true ou false" ;; esac validate_alias_entries } build_payload() { local addresses_json local details_json addresses_json="$(csv_to_json_array "$PFSENSE_ALIAS_ADDRESSES")" if [[ -n "$PFSENSE_ALIAS_DETAILS" ]]; then details_json="$(csv_to_json_array "$PFSENSE_ALIAS_DETAILS")" else details_json="$(jq -n --argjson addresses "$addresses_json" '$addresses | map("")')" fi jq -n \ --arg name "$PFSENSE_ALIAS_NAME" \ --arg type "$PFSENSE_ALIAS_TYPE" \ --arg descr "$PFSENSE_ALIAS_DESCRIPTION" \ --argjson address "$addresses_json" \ --argjson detail "$details_json" \ '{ name: $name, type: $type, descr: $descr, address: $address, detail: $detail }' } apply_firewall_changes() { local apply_url="$1" local response_file="$2" local -a curl_args=( --silent --show-error --location --connect-timeout 10 --max-time 60 --request POST --user "${PFSENSE_USER}:${PFSENSE_PASSWORD}" --header "Accept: application/json" --header "Content-Type: application/json" --data '{}' --output "$response_file" --write-out "%{http_code}" "$apply_url" ) if [[ "${PFSENSE_INSECURE:-false}" == "true" ]]; then curl_args=(--insecure "${curl_args[@]}") fi printf 'Application des changements firewall...\n' local http_code if ! http_code="$(curl "${curl_args[@]}")"; then die "appel curl impossible vers l'endpoint firewall/apply" fi if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then printf 'Changements firewall appliques, code HTTP %s.\n' "$http_code" jq . "$response_file" 2>/dev/null || sed -n '1,40p' "$response_file" return 0 fi printf 'Application refusee, code HTTP %s.\n' "$http_code" >&2 sed -n '1,40p' "$response_file" >&2 exit 1 } main() { require_command curl require_command jq load_env_file parse_args "$@" prompt_missing_inputs validate_inputs local base_url local create_url local apply_url base_url="$(normalize_url "$PFSENSE_URL")" create_url="${base_url}/api/v2/firewall/alias?apply=false" apply_url="${base_url}/api/v2/firewall/apply" local create_response_file="/tmp/pfsense-alias-create.$$" local apply_response_file="/tmp/pfsense-alias-apply.$$" trap 'rm -f "$create_response_file" "$apply_response_file"' EXIT local -a curl_args=( --silent --show-error --location --connect-timeout 10 --max-time 30 --request POST --user "${PFSENSE_USER}:${PFSENSE_PASSWORD}" --header "Accept: application/json" --header "Content-Type: application/json" --data "$(build_payload)" --output "$create_response_file" --write-out "%{http_code}" "$create_url" ) if [[ "${PFSENSE_INSECURE:-false}" == "true" ]]; then curl_args=(--insecure "${curl_args[@]}") fi printf 'Creation alias %s (%s): %s\n' "$PFSENSE_ALIAS_NAME" "$PFSENSE_ALIAS_TYPE" "$PFSENSE_ALIAS_ADDRESSES" local http_code if ! http_code="$(curl "${curl_args[@]}")"; then die "appel curl impossible vers pfSense" fi if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then printf 'Alias cree, code HTTP %s.\n' "$http_code" jq . "$create_response_file" 2>/dev/null || sed -n '1,40p' "$create_response_file" if [[ "$PFSENSE_ALIAS_APPLY" == "true" ]]; then apply_firewall_changes "$apply_url" "$apply_response_file" else printf 'Changements non appliques: option --no-apply active.\n' fi exit 0 fi printf 'Creation refusee, code HTTP %s.\n' "$http_code" >&2 sed -n '1,40p' "$create_response_file" >&2 exit 1 } main "$@"