#!/bin/sh set -eu # Deploy an externally issued OPNsense/os-acme-client certificate to RustProxy 0.23.x. # Configuration is kept separately so the API token does not need to live in this script. CONFIG_FILE="${RUSTPROXY_ACME_CONFIG:-/usr/local/etc/rustproxy-acme.conf}" log() { logger -t rustproxy-acme -- "$*" 2>/dev/null || true printf '%s\n' "$*" } fail() { log "ERROR: $*" exit 1 } [ -r "$CONFIG_FILE" ] || fail "Cannot read config: $CONFIG_FILE" # shellcheck disable=SC1090 . "$CONFIG_FILE" : "${RUSTPROXY_URL:?Set RUSTPROXY_URL in $CONFIG_FILE}" : "${RUSTPROXY_KEY_ID:?Set RUSTPROXY_KEY_ID in $CONFIG_FILE}" : "${RUSTPROXY_TOKEN:?Set RUSTPROXY_TOKEN in $CONFIG_FILE}" : "${CERTIFICATE_ID:?Set CERTIFICATE_ID in $CONFIG_FILE}" : "${CERT_FILE:?Set CERT_FILE in $CONFIG_FILE}" : "${KEY_FILE:?Set KEY_FILE in $CONFIG_FILE}" CURL="${CURL:-$(command -v curl || true)}" OPENSSL="${OPENSSL:-$(command -v openssl || true)}" [ -n "$CURL" ] || fail "curl not found" [ -n "$OPENSSL" ] || fail "openssl not found" case "$CERTIFICATE_ID" in ''|*[!A-Za-z0-9_-]*) fail "CERTIFICATE_ID may contain only A-Z, a-z, 0-9, _ and -" ;; esac [ "${#CERTIFICATE_ID}" -le 64 ] || fail "CERTIFICATE_ID is longer than 64 characters" [ -r "$CERT_FILE" ] || fail "Certificate not readable: $CERT_FILE" [ -r "$KEY_FILE" ] || fail "Private key not readable: $KEY_FILE" # Validate certificate syntax and make sure the certificate and private key belong together. "$OPENSSL" x509 -in "$CERT_FILE" -noout >/dev/null 2>&1 \ || fail "Invalid certificate PEM: $CERT_FILE" "$OPENSSL" pkey -in "$KEY_FILE" -noout >/dev/null 2>&1 \ || fail "Invalid private key PEM: $KEY_FILE" CERT_PUB=$( "$OPENSSL" x509 -in "$CERT_FILE" -pubkey -noout 2>/dev/null \ | "$OPENSSL" pkey -pubin -outform DER 2>/dev/null \ | "$OPENSSL" dgst -sha256 2>/dev/null ) KEY_PUB=$( "$OPENSSL" pkey -in "$KEY_FILE" -pubout -outform DER 2>/dev/null \ | "$OPENSSL" dgst -sha256 2>/dev/null ) [ -n "$CERT_PUB" ] && [ "$CERT_PUB" = "$KEY_PUB" ] \ || fail "Certificate and private key do not match" # Do not publish an already-expired certificate. "$OPENSSL" x509 -in "$CERT_FILE" -checkend 0 -noout >/dev/null 2>&1 \ || fail "Certificate is already expired" # Always reconcile with RustProxy. The deploy endpoint is idempotent: an identical # RustProxy-owned generation is a server-side no-op, while a missing certificate id # is created and a changed manual certificate is rotated atomically. This avoids # trusting stale local state when the RustProxy configuration was changed separately. umask 077 TMP_JSON=$(mktemp "${TMPDIR:-/tmp}/rustproxy-cert.XXXXXX") || fail "mktemp failed" TMP_CURL=$(mktemp "${TMPDIR:-/tmp}/rustproxy-curl.XXXXXX") || { rm -f "$TMP_JSON"; fail "mktemp failed"; } TMP_RESPONSE=$(mktemp "${TMPDIR:-/tmp}/rustproxy-response.XXXXXX") || { rm -f "$TMP_JSON" "$TMP_CURL"; fail "mktemp failed"; } trap 'rm -f "$TMP_JSON" "$TMP_CURL" "$TMP_RESPONSE"' EXIT HUP INT TERM # PEM is line-oriented Base64 plus BEGIN/END markers. Escape it as a JSON string # without needing jq/python on OPNsense. pem_json() { awk '{ gsub(/\\/, "\\\\"); gsub(/\"/, "\\\""); sub(/\r$/, ""); printf "%s\\n", $0; }' "$1" } { printf '{"certificate_id":"%s","cert_pem":"' "$CERTIFICATE_ID" pem_json "$CERT_FILE" printf '","key_pem":"' pem_json "$KEY_FILE" printf '"}\n' } > "$TMP_JSON" # Keep the Bearer token out of curl's process arguments by using a mode-0600 config file. cat > "$TMP_CURL" <> "$TMP_CURL" fi # Optional curl --resolve equivalent, e.g. rustproxy.lan:8443:10.10.10.20. # This lets the URL use the certificate DNS name while traffic goes to a fixed LAN IP. if [ -n "${RUSTPROXY_RESOLVE:-}" ]; then printf 'resolve = "%s"\n' "$RUSTPROXY_RESOLVE" >> "$TMP_CURL" fi HTTP_CODE=$("$CURL" --config "$TMP_CURL") || fail "HTTPS request to RustProxy failed" case "$HTTP_CODE" in 2??) log "RustProxy certificate reconcile succeeded (HTTP $HTTP_CODE, id=$CERTIFICATE_ID)" cat "$TMP_RESPONSE" printf '\n' ;; *) log "RustProxy certificate deploy failed (HTTP $HTTP_CODE)" cat "$TMP_RESPONSE" >&2 printf '\n' >&2 exit 1 ;; esac