diff --git a/deploy/truenas_websocat.sh b/deploy/truenas_websocat.sh new file mode 100644 index 00000000..c5c7db2e --- /dev/null +++ b/deploy/truenas_websocat.sh @@ -0,0 +1,518 @@ +#!/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: /ui/apikeys +# +# Required: +# export DEPLOY_TRUENAS_APIKEY="" +# +# Optional: +# export DEPLOY_TRUENAS_HOSTNAME="" (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 + # 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