#!/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-port-forward.sh --external-port PORT --target IP --target-port PORT [options] Options: --external-port PORT Port expose sur l'adresse WAN pfSense. --target IP IP de la machine interne. --target-port PORT Port de destination sur la machine interne. --protocol PROTO tcp, udp ou tcp/udp. Defaut: tcp --interface IFACE Interface pfSense. Defaut: wan --description TEXT Description de la regle. --associated-rule MODE new, pass ou none. Defaut: new --no-apply Cree la regle sans appliquer les changements. -h, --help Affiche cette aide. Exemple: ./scripts/add-port-forward.sh --external-port 8443 --target 192.168.1.50 --target-port 443 --protocol tcp 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_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%/}" } 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_EXTERNAL_PORT="${PFSENSE_NAT_EXTERNAL_PORT:-}" PFSENSE_NAT_TARGET="${PFSENSE_NAT_TARGET:-}" PFSENSE_NAT_TARGET_PORT="${PFSENSE_NAT_TARGET_PORT:-}" PFSENSE_NAT_PROTOCOL="${PFSENSE_NAT_PROTOCOL:-}" PFSENSE_NAT_INTERFACE="${PFSENSE_NAT_INTERFACE:-}" PFSENSE_NAT_DESCRIPTION="${PFSENSE_NAT_DESCRIPTION:-}" PFSENSE_NAT_ASSOCIATED_RULE="${PFSENSE_NAT_ASSOCIATED_RULE:-}" PFSENSE_NAT_APPLY="${PFSENSE_NAT_APPLY:-}" while (($#)); do case "$1" in --external-port) PFSENSE_NAT_EXTERNAL_PORT="$(arg_value "$1" "${2:-}")" shift 2 ;; --target) PFSENSE_NAT_TARGET="$(arg_value "$1" "${2:-}")" shift 2 ;; --target-port) PFSENSE_NAT_TARGET_PORT="$(arg_value "$1" "${2:-}")" shift 2 ;; --protocol) PFSENSE_NAT_PROTOCOL="$(arg_value "$1" "${2:-}")" shift 2 ;; --interface) PFSENSE_NAT_INTERFACE="$(arg_value "$1" "${2:-}")" shift 2 ;; --description) PFSENSE_NAT_DESCRIPTION="$(arg_value "$1" "${2:-}")" shift 2 ;; --associated-rule) PFSENSE_NAT_ASSOCIATED_RULE="$(arg_value "$1" "${2:-}")" shift 2 ;; --no-apply) PFSENSE_NAT_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_NAT_EXTERNAL_PORT "Port externe sur WAN" prompt_required PFSENSE_NAT_TARGET "IP de la machine interne" prompt_default PFSENSE_NAT_TARGET_PORT "Port interne cible" "$PFSENSE_NAT_EXTERNAL_PORT" prompt_default PFSENSE_NAT_PROTOCOL "Protocole (tcp, udp, tcp/udp)" "tcp" prompt_default PFSENSE_NAT_INTERFACE "Interface pfSense" "wan" prompt_default PFSENSE_NAT_DESCRIPTION "Description" "Port forward API" prompt_default PFSENSE_NAT_ASSOCIATED_RULE "Regle firewall associee (new, pass, none)" "new" prompt_bool PFSENSE_NAT_APPLY "Appliquer immediatement les changements" "true" } validate_inputs() { require_var PFSENSE_URL require_var PFSENSE_USER require_var PFSENSE_PASSWORD is_port "$PFSENSE_NAT_EXTERNAL_PORT" || die "--external-port doit etre un port entre 1 et 65535" is_port "$PFSENSE_NAT_TARGET_PORT" || die "--target-port doit etre un port entre 1 et 65535" is_ipv4 "$PFSENSE_NAT_TARGET" || die "--target doit etre une adresse IPv4 valide" case "$PFSENSE_NAT_PROTOCOL" in tcp|udp|tcp/udp) ;; *) die "--protocol doit valoir tcp, udp ou tcp/udp" ;; esac case "$PFSENSE_NAT_ASSOCIATED_RULE" in new|pass) ;; none) PFSENSE_NAT_ASSOCIATED_RULE="" ;; *) die "--associated-rule doit valoir new, pass ou none" ;; esac case "$PFSENSE_NAT_APPLY" in true|false) ;; *) die "PFSENSE_NAT_APPLY doit valoir true ou false" ;; esac } build_payload() { jq -n \ --arg interface "$PFSENSE_NAT_INTERFACE" \ --arg protocol "$PFSENSE_NAT_PROTOCOL" \ --arg destination "${PFSENSE_NAT_INTERFACE}:ip" \ --arg destination_port "$PFSENSE_NAT_EXTERNAL_PORT" \ --arg target "$PFSENSE_NAT_TARGET" \ --arg local_port "$PFSENSE_NAT_TARGET_PORT" \ --arg descr "$PFSENSE_NAT_DESCRIPTION" \ --arg associated_rule_id "$PFSENSE_NAT_ASSOCIATED_RULE" \ '{ interface: $interface, ipprotocol: "inet", protocol: $protocol, source: "any", source_port: null, destination: $destination, destination_port: $destination_port, target: $target, local_port: $local_port, disabled: false, descr: $descr, associated_rule_id: $associated_rule_id }' } 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/nat/port_forward?apply=false" apply_url="${base_url}/api/v2/firewall/apply" local create_response_file="/tmp/pfsense-nat-port-forward-create.$$" local apply_response_file="/tmp/pfsense-nat-port-forward-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 redirection: %s:%s -> %s:%s/%s\n' \ "$PFSENSE_NAT_INTERFACE" \ "$PFSENSE_NAT_EXTERNAL_PORT" \ "$PFSENSE_NAT_TARGET" \ "$PFSENSE_NAT_TARGET_PORT" \ "$PFSENSE_NAT_PROTOCOL" 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 'Redirection creee, code HTTP %s.\n' "$http_code" jq . "$create_response_file" 2>/dev/null || sed -n '1,40p' "$create_response_file" if [[ "$PFSENSE_NAT_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 "$@"