commit e3d59a678861301062d9fa86ba7eba6418272d2a Author: Olivier Date: Fri May 22 15:26:27 2026 +0200 Initial commit : scripts de gestion pfSense via REST API v2 Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..905bfc2 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# URL racine pfSense, sans slash final. +PFSENSE_URL="https://pfsense.example.local" + +# Compte pfSense autorise a utiliser l'API REST. +PFSENSE_USER="api-user" +PFSENSE_PASSWORD="change-me" + +# Endpoint de test en lecture seule. +PFSENSE_ENDPOINT="/api/v2/status/system" + +# Mettre a true uniquement si le certificat TLS est auto-signe. +PFSENSE_INSECURE=false + +# Valeurs optionnelles utilisees par scripts/add-port-forward.sh. +PFSENSE_NAT_INTERFACE="wan" +PFSENSE_NAT_PROTOCOL="tcp" +PFSENSE_NAT_ASSOCIATED_RULE="new" +PFSENSE_NAT_APPLY=true + +# Valeurs optionnelles utilisees par scripts/add-ip-alias.sh. +PFSENSE_ALIAS_TYPE="host" +PFSENSE_ALIAS_APPLY=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c162318 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.env.* +!.env.example + +AGENTS.md + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc3a017 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# pfSense API scripts + +Scripts Bash pour interagir avec pfSense via le paquet REST API v2. + +## Arborescence + +```text +pfsense-api/ +├── AGENTS.md +├── README.md +├── .gitignore +├── .env.example +└── scripts/ + ├── add-ip-alias.sh + ├── add-port-forward.sh + ├── manage-port-forwards.sh + └── test-api-connection.sh +``` + +## Prerequis + +- pfSense avec le paquet REST API v2 installe et active. +- Authentification Basic active cote API. +- Un utilisateur pfSense ayant acces a l'endpoint de test. +- `curl` installe localement. +- `jq` installe localement pour generer les payloads JSON. + +## Configuration + +Copier l'exemple puis adapter les valeurs localement : + +```bash +cp .env.example .env +``` + +Variables principales : + +```bash +PFSENSE_URL="https://pfsense.example.local" +PFSENSE_USER="api-user" +PFSENSE_PASSWORD="change-me" +``` + +Si pfSense utilise un certificat auto-signe : + +```bash +PFSENSE_INSECURE=true +``` + +## Tester la connexion API + +```bash +./scripts/test-api-connection.sh +``` + +Par defaut, le script teste : + +```text +/api/v2/status/system +``` + +Pour tester un autre endpoint en lecture seule : + +```bash +PFSENSE_ENDPOINT="/api/v2/firewall/rules" ./scripts/test-api-connection.sh +``` + +## Creer une redirection de port + +En mode interactif, lancer simplement : + +```bash +./scripts/add-port-forward.sh +``` + +Le script demandera les valeurs manquantes : port WAN, IP interne, port interne, +protocole, interface, description, regle firewall associee et application des +changements. + +Exemple : exposer le port TCP `8443` de l'adresse WAN pfSense vers le port +`443` de la machine interne `192.168.1.50` : + +```bash +./scripts/add-port-forward.sh \ + --external-port 8443 \ + --target 192.168.1.50 \ + --target-port 443 \ + --protocol tcp \ + --description "WAN 8443 vers serveur web interne" +``` + +Par defaut, le script : + +- cree la regle sur l'interface `wan`; +- utilise la destination `wan:ip`, c'est-a-dire l'adresse WAN pfSense; +- cree une regle firewall associee avec `associated_rule_id=new`; +- applique explicitement les changements via `/api/v2/firewall/apply`. + +Pour creer la regle sans appliquer tout de suite : + +```bash +./scripts/add-port-forward.sh \ + --external-port 2222 \ + --target 192.168.1.20 \ + --target-port 22 \ + --no-apply +``` + +## Creer un alias IP + +En mode interactif : + +```bash +./scripts/add-ip-alias.sh +``` + +Exemple avec une seule IP : + +```bash +./scripts/add-ip-alias.sh \ + --name SRV_WEB \ + --addresses 192.168.1.50 \ + --description "Serveur web interne" +``` + +Exemple avec plusieurs entrees : + +```bash +./scripts/add-ip-alias.sh \ + --name SERVEURS_WEB \ + --addresses 192.168.1.50,192.168.1.51 \ + --details "web-01,web-02" +``` + +Par defaut, le script cree un alias de type `host` et applique explicitement +les changements via `/api/v2/firewall/apply`. + +## Gerer les redirections NAT + +Lister les redirections NAT, puis en selectionner une pour la modifier ou la +supprimer : + +```bash +./scripts/manage-port-forwards.sh +``` + +Le script appelle `/api/v2/firewall/nat/port_forwards` pour lister les regles, +puis `/api/v2/firewall/nat/port_forward` en `PATCH` ou `DELETE` selon l'action +choisie. Les changements sont appliques via `/api/v2/firewall/apply`, sauf avec +`--no-apply`. diff --git a/scripts/add-ip-alias.sh b/scripts/add-ip-alias.sh new file mode 100755 index 0000000..b871672 --- /dev/null +++ b/scripts/add-ip-alias.sh @@ -0,0 +1,460 @@ +#!/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 "$@" diff --git a/scripts/add-port-forward.sh b/scripts/add-port-forward.sh new file mode 100755 index 0000000..40727d4 --- /dev/null +++ b/scripts/add-port-forward.sh @@ -0,0 +1,396 @@ +#!/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 "$@" diff --git a/scripts/manage-port-forwards.sh b/scripts/manage-port-forwards.sh new file mode 100755 index 0000000..06edf00 --- /dev/null +++ b/scripts/manage-port-forwards.sh @@ -0,0 +1,398 @@ +#!/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 "$@" diff --git a/scripts/test-api-connection.sh b/scripts/test-api-connection.sh new file mode 100755 index 0000000..8c5aaec --- /dev/null +++ b/scripts/test-api-connection.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +die() { + printf 'Erreur: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "commande requise introuvable: $1" +} + +load_env_file() { + if [[ -f ".env" ]]; then + set -a + # shellcheck disable=SC1091 + . ".env" + set +a + fi +} + +require_var() { + local name="$1" + [[ -n "${!name:-}" ]] || die "variable obligatoire manquante: $name" +} + +normalize_url() { + local base_url="$1" + printf '%s' "${base_url%/}" +} + +normalize_endpoint() { + local endpoint="$1" + if [[ "$endpoint" == /* ]]; then + printf '%s' "$endpoint" + else + printf '/%s' "$endpoint" + fi +} + +main() { + require_command curl + load_env_file + + require_var PFSENSE_URL + require_var PFSENSE_USER + require_var PFSENSE_PASSWORD + + local endpoint="${PFSENSE_ENDPOINT:-/api/v2/status/system}" + local url + url="$(normalize_url "$PFSENSE_URL")$(normalize_endpoint "$endpoint")" + + local curl_args=( + --silent + --show-error + --location + --connect-timeout 10 + --max-time 30 + --request GET + --user "${PFSENSE_USER}:${PFSENSE_PASSWORD}" + --header "Accept: application/json" + --output /tmp/pfsense-api-response.$$ + --write-out "%{http_code}" + "$url" + ) + + if [[ "${PFSENSE_INSECURE:-false}" == "true" ]]; then + curl_args=(--insecure "${curl_args[@]}") + fi + + local response_file="/tmp/pfsense-api-response.$$" + trap 'rm -f "$response_file"' EXIT + + printf 'Test API pfSense: %s\n' "$url" + + 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 'Connexion API OK, code HTTP %s.\n' "$http_code" + if command -v jq >/dev/null 2>&1; then + jq . "$response_file" 2>/dev/null || sed -n '1,20p' "$response_file" + else + sed -n '1,20p' "$response_file" + fi + exit 0 + fi + + printf 'Connexion API refusee, code HTTP %s.\n' "$http_code" >&2 + sed -n '1,20p' "$response_file" >&2 + exit 1 +} + +main "$@"