#!/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/manage-port-forwards.sh Liste les redirections NAT pfSense, puis permet de selectionner une regle pour la modifier ou la supprimer. Options: --no-apply N'applique pas les changements apres modification/suppression. -h, --help Affiche cette aide. 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 ]] } 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" } normalize_url() { printf '%s' "${1%/}" } is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( "$1" >= 1 && "$1" <= 65535 )) } 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 } parse_args() { PFSENSE_NAT_APPLY="${PFSENSE_NAT_APPLY:-true}" while (($#)); do case "$1" in --no-apply) PFSENSE_NAT_APPLY=false shift ;; -h|--help) usage exit 0 ;; *) die "argument inconnu: $1" ;; esac done } prompt_missing_config() { 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" } validate_config() { require_var PFSENSE_URL require_var PFSENSE_USER require_var PFSENSE_PASSWORD case "$PFSENSE_NAT_APPLY" in true|false) ;; *) die "PFSENSE_NAT_APPLY doit valoir true ou false" ;; esac } curl_api() { local method="$1" local url="$2" local output_file="$3" local data="${4:-}" local -a curl_args=( --silent --show-error --location --connect-timeout 10 --max-time 60 --request "$method" --user "${PFSENSE_USER}:${PFSENSE_PASSWORD}" --header "Accept: application/json" --output "$output_file" --write-out "%{http_code}" ) if [[ -n "$data" ]]; then curl_args+=(--header "Content-Type: application/json" --data "$data") fi curl_args+=("$url") if [[ "${PFSENSE_INSECURE:-false}" == "true" ]]; then curl_args=(--insecure "${curl_args[@]}") fi curl "${curl_args[@]}" } assert_success() { local http_code="$1" local response_file="$2" local context="$3" if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then return 0 fi printf '%s refuse, code HTTP %s.\n' "$context" "$http_code" >&2 sed -n '1,80p' "$response_file" >&2 exit 1 } extract_rules() { local response_file="$1" local rules_file="$2" jq ' if type == "array" then . elif (.data? | type) == "array" then .data elif (.data?.data? | type) == "array" then .data.data elif (.items? | type) == "array" then .items elif (.results? | type) == "array" then .results elif (.port_forwards? | type) == "array" then .port_forwards else [] end ' "$response_file" > "$rules_file" } print_rules() { local rules_file="$1" jq -r ' "No\tID\tIF\tProto\tWAN port\tTarget\tLocal port\tDescription", "----------------------------------------------------------------------", ( if type == "array" then . else [] end | to_entries[] | (.key + 1) as $no | (.value | if type == "object" then . else {} end) as $r | [$no, ($r.id // "-"), ($r.interface // "-"), ($r.protocol // "-"), ($r.destination_port // "-"), ($r.target // "-"), ($r.local_port // "-"), ($r.descr // "-")] | @tsv ) ' "$rules_file" } prompt_choice() { local rules_file="$1" local count="$2" local choice="" while true; do read -r -p "Numero de la redirection a gerer (q pour quitter): " choice [[ "$choice" == "q" || "$choice" == "Q" ]] && exit 0 [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )) && break printf 'Choix invalide.\n' >&2 done jq ".[$((choice - 1))]" "$rules_file" } prompt_default() { local label="$1" local default_value="$2" local value="" read -r -p "${label} [${default_value}]: " value printf '%s' "${value:-$default_value}" } prompt_bool_default() { local label="$1" local default_value="$2" local answer="" 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 'true'; return 0 ;; false|f|no|n|non|0) printf 'false'; return 0 ;; *) printf 'Reponse attendue: true/false, oui/non ou y/n.\n' >&2 ;; esac done } build_patch_payload() { local selected_file="$1" local id interface protocol destination_port target local_port descr associated_rule_id disabled id="$(jq -r '.id' "$selected_file")" interface="$(prompt_default "Interface" "$(jq -r '.interface // "wan"' "$selected_file")")" protocol="$(prompt_default "Protocole (tcp, udp, tcp/udp)" "$(jq -r '.protocol // "tcp"' "$selected_file")")" destination_port="$(prompt_default "Port WAN" "$(jq -r '.destination_port // ""' "$selected_file")")" target="$(prompt_default "IP cible interne" "$(jq -r '.target // ""' "$selected_file")")" local_port="$(prompt_default "Port cible interne" "$(jq -r '.local_port // ""' "$selected_file")")" descr="$(prompt_default "Description" "$(jq -r '.descr // ""' "$selected_file")")" associated_rule_id="$(prompt_default "Regle firewall associee (new, pass, none ou ID)" "$(jq -r '.associated_rule_id // "new"' "$selected_file")")" disabled="$(prompt_bool_default "Desactiver cette redirection" "$(jq -r '.disabled // false' "$selected_file")")" is_port "$destination_port" || die "port WAN invalide: $destination_port" is_port "$local_port" || die "port cible invalide: $local_port" is_ipv4 "$target" || die "IP cible invalide: $target" case "$protocol" in tcp|udp|tcp/udp) ;; *) die "protocole invalide: $protocol" ;; esac [[ "$associated_rule_id" == "none" ]] && associated_rule_id="" jq -n \ --argjson id "$id" \ --arg interface "$interface" \ --arg protocol "$protocol" \ --arg destination "${interface}:ip" \ --arg destination_port "$destination_port" \ --arg target "$target" \ --arg local_port "$local_port" \ --arg descr "$descr" \ --arg associated_rule_id "$associated_rule_id" \ --argjson disabled "$disabled" \ '{ id: $id, interface: $interface, ipprotocol: "inet", protocol: $protocol, source: "any", source_port: null, destination: $destination, destination_port: $destination_port, target: $target, local_port: $local_port, disabled: $disabled, descr: $descr, associated_rule_id: $associated_rule_id }' } apply_firewall_changes() { local base_url="$1" local response_file="$2" local http_code="" printf 'Application des changements firewall...\n' http_code="$(curl_api POST "${base_url}/api/v2/firewall/apply" "$response_file" '{}')" || \ die "appel curl impossible vers l'endpoint firewall/apply" assert_success "$http_code" "$response_file" "Application" printf 'Changements firewall appliques, code HTTP %s.\n' "$http_code" } main() { require_command curl require_command jq load_env_file parse_args "$@" prompt_missing_config validate_config is_interactive || die "ce script de gestion doit etre lance dans un terminal interactif" local base_url action http_code count id payload confirm # Ces variables sont globales pour que le trap EXIT puisse y acceder. list_response="" rules_file="" selected_file="" response_file="" apply_response_file="" base_url="$(normalize_url "$PFSENSE_URL")" list_response="/tmp/pfsense-nat-list-response.$$" rules_file="/tmp/pfsense-nat-rules.$$" selected_file="/tmp/pfsense-nat-selected.$$" response_file="/tmp/pfsense-nat-action-response.$$" apply_response_file="/tmp/pfsense-nat-apply-response.$$" trap 'rm -f "$list_response" "$rules_file" "$selected_file" "$response_file" "$apply_response_file"' EXIT http_code="$(curl_api GET "${base_url}/api/v2/firewall/nat/port_forwards" "$list_response")" || \ die "appel curl impossible vers pfSense" assert_success "$http_code" "$list_response" "Lecture" extract_rules "$list_response" "$rules_file" count="$(jq 'length' "$rules_file")" [[ "$count" -gt 0 ]] || die "aucune redirection NAT trouvee" print_rules "$rules_file" prompt_choice "$rules_file" "$count" > "$selected_file" id="$(jq -r '.id' "$selected_file")" [[ "$id" != "null" && -n "$id" ]] || die "la redirection selectionnee n'a pas d'ID exploitable" printf '\nAction sur la redirection ID %s:\n' "$id" printf ' 1. Modifier\n' printf ' 2. Supprimer\n' printf ' q. Quitter\n' read -r -p "Choix: " action case "$action" in 1) payload="$(build_patch_payload "$selected_file")" http_code="$(curl_api PATCH "${base_url}/api/v2/firewall/nat/port_forward?apply=false" "$response_file" "$payload")" || \ die "appel curl impossible vers pfSense" assert_success "$http_code" "$response_file" "Modification" printf 'Redirection modifiee, code HTTP %s.\n' "$http_code" jq . "$response_file" 2>/dev/null || sed -n '1,40p' "$response_file" ;; 2) read -r -p "Confirmer la suppression de la redirection ID ${id} ? [non]: " confirm case "$confirm" in oui|o|yes|y) http_code="$(curl_api DELETE "${base_url}/api/v2/firewall/nat/port_forward?id=${id}&apply=false" "$response_file")" || \ die "appel curl impossible vers pfSense" assert_success "$http_code" "$response_file" "Suppression" printf 'Redirection supprimee, code HTTP %s.\n' "$http_code" jq . "$response_file" 2>/dev/null || sed -n '1,40p' "$response_file" ;; *) printf 'Suppression annulee.\n' exit 0 ;; esac ;; q|Q) exit 0 ;; *) die "action invalide" ;; esac if [[ "$PFSENSE_NAT_APPLY" == "true" ]]; then apply_firewall_changes "$base_url" "$apply_response_file" else printf 'Changements non appliques: option --no-apply active.\n' fi } main "$@"