Files
acme.sh/deploy/truenas_websocat.sh
T
Tok-Ra85 0fa785be2a TrueNAS deploy script witch websocat instead of Python midclt internal command (#7216)
* Add files via upload

TrueNAS deploy script for SCALE/CORE using websocket (websocat binary)
It is recommend to use a wildcard certificate

Tested with TrueNAS SCALE 25.10 (API "wss://host/api/current", JSON-RPC 2.0).
Unlike "truenas_ws" hook, this script does NOT use midclt, the truenas_api_client Python package.
It only depends on:
- jq
- websocat  (a static binary you deploy)

Why: avoids installing a Python environment / TrueNAS package on OPNsense just to push a certificate.

IMPORTANT: This script is written in pure POSIX sh (no coproc, no bash arrays).

* Update truenas_websocat.sh

Mistake on port and procotol.

* Update truenas_websocat.sh

Adjustment on the "Why"

* Add files via upload

* Update truenas_websocat.sh

* Update truenas_websocat.sh

Apply shellcheck disable=SC2016 to avoid false positive.

* Update truenas_websocat.sh
2026-09-10 21:39:13 +08:00

519 lines
19 KiB
Bash

#!/usr/bin/env sh
# shellcheck disable=SC2016
# TrueNAS deploy script for SCALE/CORE using websocket (websocat binary)
# It is recommend to use a wildcard certificate
#
# Tested with TrueNAS SCALE 25.10 (API "wss://host/api/current", JSON-RPC 2.0).
#
# Unlike "truenas_ws" hook, this script does NOT use midclt, the truenas_api_client Python package.
# It only depends on:
# - jq
# - websocat (a static binary you deploy)
#
# Why: avoids installing a Python environment / TrueNAS package on remote machine just to push a certificate.
#
# IMPORTANT: This script is written in pure POSIX sh (no coproc, no bash arrays).
#
#
# ---------------------------------------------------------------------------
# Environment variables
# ---------------------------------------------------------------------------
#
# # Use the folowing URL to create a new API token: <TRUENAS_HOSTNAME OR IP>/ui/apikeys
#
# Required:
# export DEPLOY_TRUENAS_APIKEY="<API_KEY_GENERATED_IN_THE_WEB_UI>"
#
# Optional:
# export DEPLOY_TRUENAS_HOSTNAME="<TRUENAS_HOSTNAME_OR_IP>" (required on first run)
# export DEPLOY_TRUENAS_PROTOCOL="ws" # ws or wss (default: ws)
# export DEPLOY_TRUENAS_PORT="80" # 80, 443, 8443 (default: 80)
# NOTE: defaults are intentionally "ws"/80, not "wss"/443: a freshly
# installed TrueNAS serves its Web UI over plain HTTP on port 80 out
# of the box, and port 443 is not listening until HTTPS is configured.
# Port 80 stays reachable even after HTTPS is enabled, so this keeps
# the hook working on first run without extra setup.
# export DEPLOY_TRUENAS_UPDATE_FTP="no" # yes or no (default: no) also updates the FTP certificate
# export DEPLOY_TRUENAS_UPDATE_APPS="no" # yes or no (default: no) also updates the certificate for any
# iX app exposing a "certificate_id" option.
# WARNING: this redeploys (restarts) every matching app.
# ---------------------------------------------------------------------------
########################
### Public functions ###
########################
# truenas_websocat_deploy
#
# Deploy new certificate to TrueNAS services with websocat binary
#
# Arguments
# 1: Domain
# 2: Key-File
# 3: Certificate-File
# 4: CA-File
# 5: FullChain-File
# Returns:
# 0: Success
# 1: Missing or invalid API Key
# 2: TrueNAS not ready (health check failed)
# 3: (reserved)
# 4: FTP & iX App cert error
# 5: WebUI cert error
# 6: Certificate creation job error
# 7: Websocat / transport call error (socket write/read failed)
# 8: Missing binary or invalid configuration
# 9: TrueNAS API returned an explicit error (JSON-RPC .error field)
truenas_websocat_deploy() {
_jq_bin=$(command -v jq 2>/dev/null)
if [ -z "$_jq_bin" ]; then
_err "jq binary not found in PATH. Install it using your system's package manager."
return 8
fi
_websocat_bin=$(command -v websocat 2>/dev/null)
if [ -z "$_websocat_bin" ]; then
_err "websocat binary not found in PATH. Install it using your system's package manager, or download a static binary from https://github.com/vi/websocat/releases."
return 8
fi
_domain="$1"
_file_key="$2"
_file_cert="$3"
_file_cca="$4"
_file_fullchain="$5"
_debug _domain "$_domain"
_debug _file_key "$_file_key"
_debug _file_cert "$_file_cert"
_debug _file_ca "$_file_cca"
_debug _file_fullchain "$_file_fullchain"
if [ ! -x "$_jq_bin" ]; then
_err "Binary not found or not executable: $_jq_bin"
return 8
fi
if [ ! -x "$_websocat_bin" ]; then
_err "Binary not found or not executable: $_websocat_bin"
return 8
fi
### ---- Configuration ----
_info "Checking environment variables..."
_getdeployconf DEPLOY_TRUENAS_APIKEY
_getdeployconf DEPLOY_TRUENAS_HOSTNAME
_getdeployconf DEPLOY_TRUENAS_PROTOCOL
_getdeployconf DEPLOY_TRUENAS_PORT
_getdeployconf DEPLOY_TRUENAS_UPDATE_FTP
_getdeployconf DEPLOY_TRUENAS_UPDATE_APPS
# Check API Key
if [ -z "$DEPLOY_TRUENAS_APIKEY" ]; then
_err "TrueNAS API key not found, please set the DEPLOY_TRUENAS_APIKEY environment variable."
return 1
fi
# Check Hostname, default to localhost if not set
if [ -z "$DEPLOY_TRUENAS_HOSTNAME" ]; then
_info "TrueNAS hostname not set. Using 'localhost'."
DEPLOY_TRUENAS_HOSTNAME="localhost"
fi
# Check protocol, default to ws if not set: a freshly installed TrueNAS serves its Web UI over plain HTTP, so wss/443 is not available out of the box.
# Use DEPLOY_TRUENAS_PROTOCOL="wss" once HTTPS is configured, since the payload otherwise carries the API key and private key in plain text.
if [ -z "$DEPLOY_TRUENAS_PROTOCOL" ]; then
_info "TrueNAS protocol not set. Using 'ws'."
DEPLOY_TRUENAS_PROTOCOL="ws"
fi
# Check port, default to 80 if not set (see protocol comment above)
if [ -z "$DEPLOY_TRUENAS_PORT" ]; then
_info "TrueNAS port not set. Using '80'."
DEPLOY_TRUENAS_PORT="80"
fi
case "$DEPLOY_TRUENAS_PORT" in
'' | *[!0-9]*)
_err "Invalid TrueNAS port '$DEPLOY_TRUENAS_PORT'. DEPLOY_TRUENAS_PORT must be numeric."
return 8
;;
esac
_truenas_websocat_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME:$DEPLOY_TRUENAS_PORT/api/current"
# Check FTP update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_FTP" ]; then
_info "Certificate update for FTP is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_FTP="no"
fi
# Check Apps update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_APPS" ]; then
_info "Certificate update for Apps is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_APPS="no"
fi
_debug2 DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_debug2 DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_debug2 DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_debug2 DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_debug _truenas_websocat_uri "$_truenas_websocat_uri"
_secure_debug2 DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_info "Environment variables: OK"
### ---- Persistent WebSocket connection (FIFOs, sh/dash compatible) ----
#
# Authentication is tied to the WebSocket connection:
# the SAME connection must stay open from login until the end, otherwise every subsequent call comes back unauthenticated.
# We use two FIFOs + `exec` to talk to a background websocat process, without relying on bash-only extensions.
_websocat_tmpdir=$(mktemp -d /tmp/truenas_websocat.XXXXXX) || {
_err "mktemp failed"
return 3
}
_websocat_fifo_in="${_websocat_tmpdir}/in"
_websocat_fifo_out="${_websocat_tmpdir}/out"
mkfifo "$_websocat_fifo_in" "$_websocat_fifo_out" || {
_err "mkfifo failed"
rm -rf "$_websocat_tmpdir"
return 3
}
"$_websocat_bin" -n -k "$_truenas_websocat_uri" <"$_websocat_fifo_in" >"$_websocat_fifo_out" 2>"${_websocat_tmpdir}/err.log" &
_websocat_pid=$!
# Opening "in" for read+write avoids a deadlock if websocat hasn't opened the fifo for reading yet at the time we write to it.
exec 3<>"$_websocat_fifo_in"
exec 4<"$_websocat_fifo_out"
sleep 1
if ! kill -0 "$_websocat_pid" 2>/dev/null; then
_err "websocat exited prematurely."
_err "$(cat "${_websocat_tmpdir}/err.log" 2>/dev/null)"
exec 3>&- 4<&-
rm -rf "$_websocat_tmpdir"
return 3
fi
_websocat_req_counter=0
_truenas_websocat_cleanup() {
exec 3>&- 2>/dev/null
exec 4<&- 2>/dev/null
[ -n "$_websocat_pid" ] && kill "$_websocat_pid" 2>/dev/null
rm -rf "$_websocat_tmpdir" 2>/dev/null
}
# _truenas_websocat_rpc_call <method> <json_params>
# Does NOT log the payload/response: some calls (certificate.create, core.get_jobs) contain the certificate and private key in plain text, which would massively bloat the logs.
_truenas_websocat_rpc_call() {
_truenas_websocat_method="$1"
_truenas_websocat_params="$2"
_websocat_req_counter=$((_websocat_req_counter + 1))
_req_id="$_websocat_req_counter"
_truenas_websocat_payload=$("$_jq_bin" -c -n \
--arg jsonrpc "2.0" \
--arg id "$_req_id" \
--arg method "$_truenas_websocat_method" \
--argjson params "$_truenas_websocat_params" \
'{jsonrpc: $jsonrpc, id: $id, method: $method, params: $params}')
printf '%s\n' "$_truenas_websocat_payload" >&3 || {
_err "Socket write failed (method: $_truenas_websocat_method)"
return 7
}
IFS= read -r _truenas_websocat_response <&4 || {
_err "Socket read failed (method: $_truenas_websocat_method)"
return 7
}
printf '%s' "$_truenas_websocat_response"
}
# _truenas_websocat_rpc_has_error <response> <label> -> returns 0 (and prints) if an error was found, 1 otherwise
_truenas_websocat_rpc_has_error() {
_msg=$(printf '%s' "$1" | "$_jq_bin" -r '.error.message // empty' 2>/dev/null)
if [ -n "$_msg" ]; then
_err "RPC error ($2): $_msg"
return 0
fi
return 1
}
# Polls once per second and gives up after _TRUENAS_WEBSOCAT_JOB_TIMEOUT seconds (default 60s)
# if the job never leaves RUNNING/WAITING, so a stuck TrueNAS job cannot hang the deploy hook forever.
# NOTE: this same timeout also bounds app.update jobs when DEPLOY_TRUENAS_UPDATE_APPS=yes (iX App redeploy)
# raise _TRUENAS_WEBSOCAT_JOB_TIMEOUT if an app takes longer than that to redeploy (e.g. image pull, migrations).
_truenas_websocat_wait_for_job() {
_jobid="$1"
_truenas_websocat_job_elapsed=0
_truenas_websocat_job_timeout="${_TRUENAS_WEBSOCAT_JOB_TIMEOUT:-60}"
while true; do
if [ "$_truenas_websocat_job_elapsed" -ge "$_truenas_websocat_job_timeout" ]; then
_err "Job $_jobid: timed out after ${_truenas_websocat_job_timeout}s."
return 6
fi
sleep 1
_truenas_websocat_job_elapsed=$((_truenas_websocat_job_elapsed + 1))
_job_resp=$(_truenas_websocat_rpc_call "core.get_jobs" "[[[\"id\",\"=\",${_jobid}]]]") || return 6
if _truenas_websocat_rpc_has_error "$_job_resp" "core.get_jobs"; then return 6; fi
_state=$(printf '%s' "$_job_resp" | "$_jq_bin" -r '.result[0].state // empty')
case "$_state" in
SUCCESS)
printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].result'
return 0
;;
FAILED | ABORTED)
_err "Job $_jobid failed: $(printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].error')"
return 6
;;
"")
_err "Job $_jobid: unexpected response."
return 6
;;
esac
done
}
### ---- 1. Health check ----
_info "Testing connection to TrueNAS WebSocket at $_truenas_websocat_uri..."
_ping_resp=$(_truenas_websocat_rpc_call "core.ping" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ping_resp" "core.ping"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_ping_resp" | "$_jq_bin" -r '.result // empty')" != "pong" ]; then
_err "Health check failed (no pong received)."
_truenas_websocat_cleanup
return 2
fi
_info "Health check OK (pong received)."
### ---- 2. Authentication & Check ----
_info "Authenticating with API Key..."
_key_params=$("$_jq_bin" -c -n --arg k "$DEPLOY_TRUENAS_APIKEY" '[$k]')
_auth_resp=$(_truenas_websocat_rpc_call "auth.login_with_api_key" "$_key_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_auth_resp" "auth.login_with_api_key"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_auth_resp" | "$_jq_bin" -r '.result // empty')" != "true" ]; then
_err "Authentication failed (invalid API key?)."
_truenas_websocat_cleanup
return 1
fi
_info "Connected to TrueNAS ($_truenas_websocat_uri)."
_savedeployconf DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_savedeployconf DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_savedeployconf DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_savedeployconf DEPLOY_TRUENAS_PORT "$DEPLOY_TRUENAS_PORT"
_savedeployconf DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_savedeployconf DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_info "Checking TrueNAS system version..."
_ver_resp=$(_truenas_websocat_rpc_call "system.info" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ver_resp" "system.info"; then
_truenas_websocat_cleanup
return 9
fi
_sys_version=$(printf '%s' "$_ver_resp" | "$_jq_bin" -r '.result.version // .result // "Unknown"')
_info "TrueNAS System Version: $_sys_version"
### ---- 3. Read certificate files ----
_info "Reading certificate files for $_domain..."
if [ ! -f "$_file_fullchain" ] || [ ! -f "$_file_key" ]; then
_err "Certificate or key file not found."
_truenas_websocat_cleanup
return 5
fi
_cert_content=$("$_jq_bin" -sR . "$_file_fullchain")
_key_content=$("$_jq_bin" -sR . "$_file_key")
_safe_domain=$(echo "$_domain" | tr '*.' '_')
_cert_name="acme_${_safe_domain}_$(date +%Y%m%d_%H%M%S)"
_debug _certname "$_cert_name"
### ---- 4. Current Web UI certificate ----
_info "Retrieving current Web UI configuration..."
_config_resp=$(_truenas_websocat_rpc_call "system.general.config" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_config_resp" "system.general.config"; then
_truenas_websocat_cleanup
return 9
fi
_old_cert_id=$(printf '%s' "$_config_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
_info "Current Web UI Certificate ID: ${_old_cert_id:-None}"
### ---- 5. Import the new certificate (asynchronous job) ----
_info "Importing new certificate '$_cert_name'..."
_create_params=$("$_jq_bin" -n \
--arg name "$_cert_name" \
--argjson cert "$_cert_content" \
--argjson key "$_key_content" \
'[{create_type: "CERTIFICATE_CREATE_IMPORTED", name: $name, certificate: $cert, privatekey: $key}]')
_new_cert_resp=$(_truenas_websocat_rpc_call "certificate.create" "$_create_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_new_cert_resp" "certificate.create"; then
_truenas_websocat_cleanup
return 9
fi
_new_cert_jobid=$(printf '%s' "$_new_cert_resp" | "$_jq_bin" -r '.result // empty')
case "$_new_cert_jobid" in
'' | *[!0-9]*)
_err "Unexpected response from certificate.create (expected a job ID)."
_truenas_websocat_cleanup
return 6
;;
esac
_info "Import job certificate started (job ID: $_new_cert_jobid), waiting..."
_job_result=$(_truenas_websocat_wait_for_job "$_new_cert_jobid") || {
_truenas_websocat_cleanup
return 6
}
_new_cert_id=$(printf '%s' "$_job_result" | "$_jq_bin" -r '.id // empty')
if [ -z "$_new_cert_id" ]; then
_err "Could not retrieve the imported certificate's ID."
_truenas_websocat_cleanup
return 6
fi
_info "Certificate imported: '$_cert_name' (ID $_new_cert_id)."
### ---- 6. Assign to the Web UI ----
_info "Assigning certificate ID $_new_cert_id to Web UI..."
_update_resp=$(_truenas_websocat_rpc_call "system.general.update" "[{\"ui_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_update_resp" "system.general.update"; then
_truenas_websocat_cleanup
return 9
fi
_assigned_id=$(printf '%s' "$_update_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
if [ "$_assigned_id" != "$_new_cert_id" ]; then
_err "Failed to assign the certificate to the Web UI."
_truenas_websocat_cleanup
return 5
fi
_info "Restarting TrueNAS Web UI service..."
_restart_resp=$(_truenas_websocat_rpc_call "system.general.ui_restart" "[]")
_truenas_websocat_rpc_has_error "$_restart_resp" "system.general.ui_restart"
_info "Web UI certificate updated and UI restarted."
# Need TrueNas to sleep before perform other actions
_info "Waiting for Web UI restart."
sleep 5
### ---- 7. FTP (optional) ----
if [ "$DEPLOY_TRUENAS_UPDATE_FTP" = "yes" ]; then
_info "Sending certicate for FTP service..."
_ftp_resp=$(_truenas_websocat_rpc_call "ftp.update" "[{\"ssltls_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_ftp_resp" "ftp.update"; then
_err "Failed to update the FTP certificate."
_truenas_websocat_cleanup
return 4
else
_ftp_certid=$(printf '%s' "$_ftp_resp" | "$_jq_bin" -r '.result.ssltls_certificate // empty')
if [ "$_ftp_certid" = "$_new_cert_id" ]; then
_info "FTP certificate updated."
else
_err "FTP certificate: unexpected response."
_truenas_websocat_cleanup
return 4
fi
fi
fi
### ---- 8. iX Apps (optional - redeploys every matching app) ----
if [ "$DEPLOY_TRUENAS_UPDATE_APPS" = "yes" ]; then
_info "Sending certicate for iX Apps..."
_apps_resp=$(_truenas_websocat_rpc_call "app.query" "[]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_apps_resp" "app.query"; then
_err "Could not list apps."
_truenas_websocat_cleanup
return 4
else
for _app_name in $(printf '%s' "$_apps_resp" | "$_jq_bin" -r '.result[].name'); do
_app_cfg=$(_truenas_websocat_rpc_call "app.config" "[\"${_app_name}\"]") || {
_truenas_websocat_cleanup
return 4
}
_has_cert_opt=$(printf '%s' "$_app_cfg" | "$_jq_bin" -r '.result.network // {} | has("certificate_id")' 2>/dev/null)
if [ "$_has_cert_opt" = "true" ]; then
_info "Updating certificate for app '$_app_name' (this will redeploy it)..."
_app_update_resp=$(_truenas_websocat_rpc_call "app.update" "[\"${_app_name}\", {\"values\": {\"network\": {\"certificate_id\": ${_new_cert_id}}}}]") || {
_truenas_websocat_cleanup
return 4
}
_app_jobid=$(printf '%s' "$_app_update_resp" | "$_jq_bin" -r '.result // empty')
case "$_app_jobid" in
'' | *[!0-9]*)
_err "App '$_app_name': no job ID returned."
_truenas_websocat_cleanup
return 4
;;
*)
if ! _truenas_websocat_wait_for_job "$_app_jobid" >/dev/null; then
_err "App '$_app_name': update not confirmed."
_truenas_websocat_cleanup
return 4
fi
;;
esac
fi
done
fi
fi
### ---- 9. Delete the old certificate (non blocking) ----
if [ -n "$_old_cert_id" ] && [ "$_old_cert_id" != "$_new_cert_id" ] && [ "$_old_cert_id" != "null" ]; then
_del_resp=$(_truenas_websocat_rpc_call "certificate.delete" "[${_old_cert_id}]")
if ! _truenas_websocat_rpc_has_error "$_del_resp" "certificate.delete"; then
_del_jobid=$(printf '%s' "$_del_resp" | "$_jq_bin" -r '.result // empty')
case "$_del_jobid" in
'' | *[!0-9]*) : ;;
*)
_truenas_websocat_wait_for_job "$_del_jobid" >/dev/null || _info "Old certificate: deletion not confirmed ."
;;
esac
fi
fi
_truenas_websocat_cleanup
_info "TrueNAS deployment completed successfully."
return 0
}