From 292407d1e2614c1bbc7c654f533ea51380e7c6e6 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 22 Oct 2026 05:46:36 +0200 Subject: [PATCH] Add deploy hook for pfSense (pfREST) (#7339) * Add deploy hook for pfSense (pfREST) * Fix certificate lookup and deploy config handling * Reject non-ASCII pfSense certificate names * Fix shfmt formatting * Use case instead of grep -F, fix shfmt formatting --- deploy/pfsense.sh | 247 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100755 deploy/pfsense.sh diff --git a/deploy/pfsense.sh b/deploy/pfsense.sh new file mode 100755 index 00000000..0f7207c5 --- /dev/null +++ b/deploy/pfsense.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env sh + +# Deploy hook to deploy a certificate to pfSense using the pfREST API +# (https://pfrest.org). Tested with pfSense 2.9.0 and pfREST 2.10.2. +# +# The certificate is created under DEPLOY_PFSENSE_CERT_NAME, or key and +# certificate are replaced in place if it already exists. Then it is +# optionally assigned to the Unbound DNS-over-TLS listener and finally to the +# web UI, which gets restarted (the API is briefly unreachable afterwards). +# +# Preparation on the pfSense: +# +# - Install pfREST and enable key authentication: +# https://pfrest.org/INSTALL_AND_CONFIG/ +# https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/ +# - Create the API key (System > REST API > Keys) with a user that has +# "WebCfg - All pages". Afterwards use a dedicated user with only these +# privileges, the key is bound to the user that creates it: +# REST API - /api/v2/system/certificate PATCH +# REST API - /api/v2/system/certificate POST +# REST API - /api/v2/system/certificates GET +# REST API - /api/v2/system/webgui/settings PATCH +# REST API - /api/v2/services/dns_resolver/apply POST +# REST API - /api/v2/services/dns_resolver/settings GET +# REST API - /api/v2/services/dns_resolver/settings PATCH +# - DNS-over-TLS only: "Enable SSL/TLS Service" must already be enabled in +# Services > DNS Resolver, otherwise Unbound is skipped. +# +# Environment variables: +# +# DEPLOY_PFSENSE_APIKEY - (REQUIRED) +# pfREST API key. +# DEPLOY_PFSENSE_HOST - pfsense.example.com, 192.168.1.1, etc. (OPTIONAL) +# Host/IP of the pfSense. Defaults to the +# certificate domain, required for wildcard +# certificates. +# DEPLOY_PFSENSE_PORT - 443, 8443, etc. (OPTIONAL) +# Web UI/API port. Defaults to 443. +# DEPLOY_PFSENSE_CERT_NAME - My Certificate, etc. (OPTIONAL) +# Certificate name shown in pfSense (printable +# ASCII only). Defaults to the certificate +# domain. +# DEPLOY_PFSENSE_WEBUI - yes/no (OPTIONAL) +# Assign to the web UI and restart it. Defaults +# to "yes". +# DEPLOY_PFSENSE_UNBOUND_DOT - yes/no (OPTIONAL) +# Also assign to Unbound DNS-over-TLS. Defaults +# to "no". +# DEPLOY_PFSENSE_INSECURE - yes/no (OPTIONAL) +# Do not verify the pfSense TLS certificate, +# needed on the first run with the self-signed +# default certificate. Defaults to "no". +# +# Example: +# +# export DEPLOY_PFSENSE_APIKEY="" +# acme.sh --deploy -d pfsense.example.com --deploy-hook pfsense +# +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +pfsense_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ ! -f "$_ckey" ] || [ ! -f "$_cfullchain" ]; then + _err "Key or fullchain file not found." + return 1 + fi + + _getdeployconf DEPLOY_PFSENSE_APIKEY + if [ -z "$DEPLOY_PFSENSE_APIKEY" ]; then + _err "DEPLOY_PFSENSE_APIKEY is not set." + return 1 + fi + _secure_debug2 DEPLOY_PFSENSE_APIKEY "$DEPLOY_PFSENSE_APIKEY" + + _getdeployconf DEPLOY_PFSENSE_HOST + _getdeployconf DEPLOY_PFSENSE_PORT + _getdeployconf DEPLOY_PFSENSE_CERT_NAME + _getdeployconf DEPLOY_PFSENSE_WEBUI + _getdeployconf DEPLOY_PFSENSE_UNBOUND_DOT + _getdeployconf DEPLOY_PFSENSE_INSECURE + + if [ -z "$DEPLOY_PFSENSE_HOST" ]; then + if _startswith "$_cdomain" "*"; then + _err "DEPLOY_PFSENSE_HOST must be set for wildcard certificates." + return 1 + fi + DEPLOY_PFSENSE_HOST="$_cdomain" + fi + [ -n "$DEPLOY_PFSENSE_PORT" ] || DEPLOY_PFSENSE_PORT="443" + [ -n "$DEPLOY_PFSENSE_CERT_NAME" ] || DEPLOY_PFSENSE_CERT_NAME="$_cdomain" + [ -n "$DEPLOY_PFSENSE_WEBUI" ] || DEPLOY_PFSENSE_WEBUI="yes" + [ -n "$DEPLOY_PFSENSE_UNBOUND_DOT" ] || DEPLOY_PFSENSE_UNBOUND_DOT="no" + [ -n "$DEPLOY_PFSENSE_INSECURE" ] || DEPLOY_PFSENSE_INSECURE="no" + + # pfREST returns non-ASCII as \uXXXX, which cannot be matched reliably + if printf '%s' "$DEPLOY_PFSENSE_CERT_NAME" | LC_ALL=C grep -q '[^ -~]'; then + _err "DEPLOY_PFSENSE_CERT_NAME must only contain printable ASCII characters." + return 1 + fi + + # pfREST rejects these characters in a certificate description + case "$DEPLOY_PFSENSE_CERT_NAME" in + *[\?\>\<\&/\\\"\']*) + _err "DEPLOY_PFSENSE_CERT_NAME must not contain any of: ? > < & / \\ \" '" + return 1 + ;; + esac + + _debug2 DEPLOY_PFSENSE_HOST "$DEPLOY_PFSENSE_HOST" + _debug2 DEPLOY_PFSENSE_PORT "$DEPLOY_PFSENSE_PORT" + _debug2 DEPLOY_PFSENSE_CERT_NAME "$DEPLOY_PFSENSE_CERT_NAME" + _debug2 DEPLOY_PFSENSE_WEBUI "$DEPLOY_PFSENSE_WEBUI" + _debug2 DEPLOY_PFSENSE_UNBOUND_DOT "$DEPLOY_PFSENSE_UNBOUND_DOT" + _debug2 DEPLOY_PFSENSE_INSECURE "$DEPLOY_PFSENSE_INSECURE" + + _pfsense_api_url="https://${DEPLOY_PFSENSE_HOST}:${DEPLOY_PFSENSE_PORT}/api/v2" + if [ "$DEPLOY_PFSENSE_INSECURE" = "yes" ]; then + export HTTPS_INSECURE=1 + fi + + _info "Looking up certificate '$DEPLOY_PFSENSE_CERT_NAME' on pfSense" + _pfsense_call GET "/system/certificates?descr=$(printf '%s' "$DEPLOY_PFSENSE_CERT_NAME" | _url_encode)" || return 1 + # do not rely on the server-side filter, only accept an exact descr match + _pf_match="" + _pf_objs="$(printf '%s\n' "$_pfsense_response" | sed 's/} *, *{/}\ +{/g')" + while IFS= read -r _pf_line; do + case "$_pf_line" in + *"\"descr\":\"$DEPLOY_PFSENSE_CERT_NAME\""*) + _pf_match="$_pf_line" + break + ;; + esac + done < sets _pfsense_response, returns 1 on non-2xx +_pfsense_call() { + _pf_method="$1" + _pf_path="$2" + _pf_body="$3" + + _H1="x-api-key: $DEPLOY_PFSENSE_APIKEY" + export _H1 + _debug "$_pf_method" "$_pfsense_api_url$_pf_path" + + if [ "$_pf_method" = "GET" ]; then + _pfsense_response="$(_get "$_pfsense_api_url$_pf_path")" + else + _pfsense_response="$(_post "$_pf_body" "$_pfsense_api_url$_pf_path" "" "$_pf_method" "application/json")" + fi + _pf_ret=$? + + _pf_status="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _pfsense_response="$(printf '%s' "$_pfsense_response" | tr -d '\r\n' | sed 's/" *: */":/g')" + _debug2 "HTTP status" "$_pf_status" + _secure_debug3 response "$_pfsense_response" + + case "$_pf_status" in + 2[0-9][0-9]) + [ "$_pf_ret" = "0" ] && return 0 + ;; + esac + + _pf_msg="$(printf '%s' "$_pfsense_response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + _err "pfSense API call failed: $_pf_method $_pf_path (HTTP ${_pf_status:-none}). $_pf_msg" + if [ "$_pf_status" = "401" ] || [ "$_pf_status" = "403" ] || [ -z "$_pf_status" ]; then + _err "Check DEPLOY_PFSENSE_APIKEY, the user privileges and DEPLOY_PFSENSE_HOST/PORT. Use DEPLOY_PFSENSE_INSECURE=yes for an untrusted web UI certificate." + fi + return 1 +} + +# refid +_pfsense_unbound_dot() { + _pf_dot_refid="$1" + + _info "Checking Unbound DNS-over-TLS" + _pfsense_call GET "/services/dns_resolver/settings" || return 1 + if ! _contains "$_pfsense_response" '"enablessl":true'; then + _info "SSL/TLS is not enabled in the DNS Resolver, skipping Unbound" + return 0 + fi + + _info "Assigning certificate to Unbound and applying" + _pfsense_call PATCH "/services/dns_resolver/settings" "{\"sslcertref\": \"$_pf_dot_refid\"}" || return 1 + _pfsense_call POST "/services/dns_resolver/apply" "{}" || return 1 + return 0 +}