From 2e2782f0d87b620acb1b17935ab658b4b904788c Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Wed, 12 Aug 2026 10:47:58 -0400 Subject: [PATCH 1/3] Remove deprecated Linode API v3 DNS plugin (#7054) Signed-off-by: Zhiwei Liang --- dnsapi/dns_linode.sh | 189 ------------------------------------------- 1 file changed, 189 deletions(-) delete mode 100755 dnsapi/dns_linode.sh diff --git a/dnsapi/dns_linode.sh b/dnsapi/dns_linode.sh deleted file mode 100755 index d74d1fc8..00000000 --- a/dnsapi/dns_linode.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env sh -# shellcheck disable=SC2034 -dns_linode_info='Linode.com (Old) - Deprecated. Use dns_linode_v4 -Site: Linode.com -Options: - LINODE_API_KEY API Key -Author: Philipp Grosswiler -' - -LINODE_API_URL="https://api.linode.com/?api_key=$LINODE_API_KEY&api_action=" - -######## Public functions ##################### - -#Usage: dns_linode_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" -dns_linode_add() { - fulldomain="${1}" - txtvalue="${2}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_add() '${fulldomain}' '${txtvalue}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id&Type=TXT&Name=$_sub_domain&Target=$txtvalue" - - if _rest GET "domain.resource.create" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error adding the domain resource." - return 1 - fi - - _info "Domain resource successfully added." - return 0 - fi - - return 1 -} - -#Usage: dns_linode_rm _acme-challenge.www.domain.com -dns_linode_rm() { - fulldomain="${1}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_rm() '${fulldomain}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id" - - if _rest GET "domain.resource.list" "$_parameters" && [ -n "$response" ]; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - - resource="$(echo "$response" | _egrep_o "{.*\"NAME\":\s*\"$_sub_domain\".*}")" - if [ "$resource" ]; then - _resource_id=$(printf "%s\n" "$resource" | _egrep_o "\"RESOURCEID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_resource_id" ]; then - _debug _resource_id "$_resource_id" - - _parameters="&DomainID=$_domain_id&ResourceID=$_resource_id" - - if _rest GET "domain.resource.delete" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error deleting the domain resource." - return 1 - fi - - _info "Domain resource successfully deleted." - return 0 - fi - fi - - return 1 - fi - - return 0 - fi - - return 1 -} - -#################### Private functions below ################################## - -_Linode_API() { - if [ -z "$LINODE_API_KEY" ]; then - LINODE_API_KEY="" - - _err "You didn't specify the Linode API key yet." - _err "Please create your key and try again." - - return 1 - fi - - _saveaccountconf LINODE_API_KEY "$LINODE_API_KEY" -} - -#################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=12345 -_get_root() { - domain=$1 - i=2 - p=1 - - if _rest GET "domain.list"; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - #not valid - return 1 - fi - - hostedzone="$(echo "$response" | _egrep_o "{.*\"DOMAIN\":\s*\"$h\".*}")" - if [ "$hostedzone" ]; then - _domain_id=$(printf "%s\n" "$hostedzone" | _egrep_o "\"DOMAINID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_domain_id" ]; then - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain=$h - return 0 - fi - return 1 - fi - p=$i - i=$(_math "$i" + 1) - done - fi - return 1 -} - -#method method action data -_rest() { - mtd="$1" - ep="$2" - data="$3" - - _debug mtd "$mtd" - _debug ep "$ep" - - export _H1="Accept: application/json" - export _H2="Content-Type: application/json" - - if [ "$mtd" != "GET" ]; then - # both POST and DELETE. - _debug data "$data" - response="$(_post "$data" "$LINODE_API_URL$ep" "" "$mtd")" - else - response="$(_get "$LINODE_API_URL$ep$data")" - fi - - if [ "$?" != "0" ]; then - _err "error $ep" - return 1 - fi - _debug2 response "$response" - return 0 -} From a89ba9c2e5ca269047adf585a371bd7bd3e25587 Mon Sep 17 00:00:00 2001 From: XuChao Date: Thu, 13 Aug 2026 14:52:48 +0800 Subject: [PATCH 2/3] add deploy hook support for ikuai (#6456) * add deploy hook support for ikuai * fix shellcheck warn and shfmt the code * 1.fix config load 2.use _secre_debug2 to log password 3.use fullchain to deploy 4.fix hardcode id 5.change shebang * fix miss ; after cookie * 1.fix shfmt ; 2.fix IKUAI_CERT_ID conf load; 3.correct IKUAI_CERT_ID description * fix some log msg * fix shfmt --- deploy/ikuai.sh | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 deploy/ikuai.sh diff --git a/deploy/ikuai.sh b/deploy/ikuai.sh new file mode 100644 index 00000000..fa0926dc --- /dev/null +++ b/deploy/ikuai.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to ikuai using curl +# +# it requires following environment variables: +# +# IKUAI_SCHEME="http" - http or https , defaults to "http" +# IKUAI_HOSTNAME="localhost" - host , defaults to "192.168.9.1" +# IKUAI_PORT="80" - port , defaults to "80" +# IKUAI_USERNAME="admin" - username , defaults to "admin" +# IKUAI_PASSWORD="yourPassword" - password +# IKUAI_CERT_ID=1 - ikuai cert id , defaults to 1, and only 1 is supported for now !!! +# +#returns 0 means success, otherwise error. +# +######## Public functions ##################### +# +#domain keyfile certfile cafile fullchain +ikuai_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" + + # Get deploy conf + _getdeployconf IKUAI_SCHEME + _getdeployconf IKUAI_HOSTNAME + _getdeployconf IKUAI_PORT + _getdeployconf IKUAI_USERNAME + _getdeployconf IKUAI_PASSWORD + _getdeployconf IKUAI_CERT_ID + + # Use default if not provided + [ -n "$IKUAI_SCHEME" ] || IKUAI_SCHEME="http" + [ -n "$IKUAI_HOSTNAME" ] || IKUAI_HOSTNAME="192.168.9.1" + [ -n "$IKUAI_PORT" ] || IKUAI_PORT=80 + [ -n "$IKUAI_USERNAME" ] || IKUAI_USERNAME="admin" + [ -n "$IKUAI_CERT_ID" ] || IKUAI_CERT_ID=1 + + if [ -z "$IKUAI_PASSWORD" ]; then + _err "please define IKUAI_PASSWORD." + return 1 + fi + + _debug2 IKUAI_SCHEME "$IKUAI_SCHEME" + _debug2 IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _debug2 IKUAI_PORT "$IKUAI_PORT" + _debug2 IKUAI_USERNAME "$IKUAI_USERNAME" + _secure_debug2 IKUAI_PASSWORD "$IKUAI_PASSWORD" + + _info "Login to ikuai ..." + _ikuai_url="$IKUAI_SCHEME://$IKUAI_HOSTNAME:$IKUAI_PORT" + _pass_md5="$(printf "%s" "$IKUAI_PASSWORD" | _digest md5 hex | _lower_case)" + _pass_salt="$(printf "salt_11%s" "$IKUAI_PASSWORD" | _base64)" + _debug2 _ikuai_url "$_ikuai_url" + + _login_req="{\"username\":\"$IKUAI_USERNAME\",\"passwd\":\"$_pass_md5\",\"pass\":\"$_pass_salt\",\"remember_password\":\"\"}" + _response=$(_post "$_login_req" "$_ikuai_url/Action/login" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to login to ikuai: $_err_msg" + return 1 + fi + # check cookie + _cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2 | sed 's/;.*//')" + if [ -z "$_cookie" ]; then + _err "Fail to get the cookie." + return 1 + fi + + # Set cookie header + _H1="Cookie: $_cookie; username=$IKUAI_USERNAME; login=1" + + _info "Deploy the cert to ikuai ... " + + # Should replace \n to @ ," " to # + _cert_content_single_line="$(tr <"$_cfullchain" '\n' '@' | tr ' ' '#')" + _key_content_single_line="$(tr <"$_ckey" '\n' '@' | tr ' ' '#')" + + _debug2 _cert_content_single_line "$_cert_content_single_line" + _secure_debug2 _key_content_single_line "$_key_content_single_line" + + _key_manager_req="{\"func_name\":\"key_manager\",\"action\":\"save\",\"param\":{\"ca\":\"$_cert_content_single_line\",\"key\":\"$_key_content_single_line\",\"id\":$IKUAI_CERT_ID,\"enabled\":\"yes\",\"comment\":\"\"}}" + _response=$(_post "$_key_manager_req" "$_ikuai_url/Action/call" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to deploy the cert to ikuai: $_err_msg" + return 1 + fi + + _info "Save the deploy config ... " + # Save the config + _savedeployconf IKUAI_SCHEME "$IKUAI_SCHEME" + _savedeployconf IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _savedeployconf IKUAI_PORT "$IKUAI_PORT" + _savedeployconf IKUAI_USERNAME "$IKUAI_USERNAME" + _savedeployconf IKUAI_PASSWORD "$IKUAI_PASSWORD" + _savedeployconf IKUAI_CERT_ID "$IKUAI_CERT_ID" + + _info "Successfully deployed certificate to ikuai. Enjoy! :>" + + return 0 +} From 41bdd4cd0e9bc908fa57dcfb9f8afc3c2658af40 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 12 Aug 2026 23:57:44 -0700 Subject: [PATCH 3/3] Add UniFi OS Server deploy hook (#7184) * Add UniFi OS Server deploy hook Uses UniFi OS Server's local REST API (login, list, upload, activate, remove superseded) since it stores certificates in its own Postgres database rather than flat config files, unlike the Cloud Key/UDM hardware covered by the existing unifi deploy hook. Tested against real instances on both macOS and Ubuntu 26.04 (self-hosted, remote). * Address review: portable sed/grep, scoped HTTPS_INSECURE, fingerprint matching - Replace GNU-only \n in sed replacement with a portable literal newline (matches dnsapi/dns_cpanel_uapi.sh, dnsapi/dns_glesys.sh); pipe the list response through _normalizeJson first for consistent formatting. - Use grep -F for the domain-name match instead of an unescaped BRE -- a wildcard cert name (*.example.com) broke the regex. - Drop \W (undocumented, GNU-only) from the cookie lookup in favor of an anchored `^Set-Cookie: *NAME=` match. - Scope HTTPS_INSECURE=1 inside the hook (matches deploy/proxmoxve.sh, deploy/fritzbox.sh) instead of requiring the caller to export it for the whole acme.sh run, which would also disable verification for the connection to the ACME CA. - On a duplicate-certificate response, match the existing entry by fingerprint instead of taking the first name match -- with more than one stale entry for a domain, the wrong one could get activated. - Check the list endpoint's response code before proceeding. - Save username/password with the "base64" flag (matches deploy/synology_dsm.sh) since _save_conf wraps values in unescaped single quotes. * Rework certificate handling: unique names per upload, drop cleanup Testing against a real UniFi OS Server showed the server enforces name uniqueness independently of fingerprint uniqueness, and that activation is exclusive server-wide regardless of name/domain. A unique name per upload avoids the name-collision path entirely (previously only handled as a retry-of-identical-content edge case), and removes the need for the post-hoc cleanup loop, which risked deleting the wrong entry. Co-Authored-By: Claude Sonnet 5 * Shorten generated certificate name to Unix epoch seconds Real-hardware testing showed the UniFi OS Server certificate list's name column is fixed-width and doesn't wrap, so a full human-readable timestamp overlaps the Expires column and makes both unreadable. Epoch seconds are still short enough to fit while remaining unique. Co-Authored-By: Claude Sonnet 5 * Add scoped cleanup of old certificate entries, use _time helper Per review: dropping cleanup entirely went further than the original bug required, and left old entries (each holding a private key) accumulating indefinitely. Since every upload now gets a name unique to its domain and run, cleanup can safely target only entries whose name starts with that domain -- entries this hook itself created -- excluding the one just activated. Also swaps date +%s for the core _time helper, and rewrote the design comments to make them clearer and match the current behavior instead of the pre-redesign one. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- deploy/unifios.sh | 307 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 deploy/unifios.sh diff --git a/deploy/unifios.sh b/deploy/unifios.sh new file mode 100644 index 00000000..82b65c69 --- /dev/null +++ b/deploy/unifios.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env sh +# Deploy hook for UniFi OS Server (self-hosted). +# +# Supports: +# - UniFi OS Server on macOS +# - UniFi OS Server on Linux +# - UniFi OS Server on Windows should also work (runs under WSL2), but +# has not been tested. +# +# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). +# +# This is a different product from the Cloud Key / UDM hardware and +# self-hosted Unifi Controller covered by the `unifi` deploy hook above +# (that hook already covers Cloud Key running UnifiOS v2.0.0+/Gen2/2+) -- +# this hook targets the separately-installed, self-hosted "UniFi OS Server" +# application instead, which stores certificates in its own Postgres +# database via a REST API rather than a Java keystore, so the `unifi` +# hook's approach does not apply here. +# +# UniFi OS Server exposes a REST API on its management port (default +# 11443) that its own web UI uses for certificate management: +# POST /api/auth/login - session login (cookie + JWT) +# GET /api/userCertificates - list uploaded certificates +# POST /api/userCertificates - upload a new certificate +# DELETE /api/userCertificates/{id} - remove a certificate +# PUT /api/userCertificates/{id}/status - activate/deactivate a certificate +# +# This was reverse-engineered from the browser's Network tab while using the +# real GUI upload/activate/delete flow -- it is undocumented but is the same +# code path the UI uses, so it's far more robust than editing settings.yaml, +# http/local-certs.conf, or the underlying Postgres user_certificates table +# directly (all of which are also touched by this API, but only as a result +# of the app's own internal logic, which handles cert parsing, active-cert +# bookkeeping, and nginx config regeneration correctly on its own). +# +# Auth: POST /api/auth/login returns a `TOKEN` cookie containing a JWT whose +# payload has a `csrfToken` claim. That value must be echoed back as the +# `x-csrf-token` header on every subsequent state-changing request (a classic +# double-submit CSRF pattern). No other cookies were found to be necessary. +# +# Uses core acme.sh helpers throughout (_post/_get, _json_encode, +# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or +# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all +# honored the same as every other hook. The management API's cert is +# self-signed (it's a management-only port, not meant for public exposure), +# so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see +# acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS +# verification for the rest of the acme.sh run, e.g. the connection to the +# ACME CA. +# +# Design: This hook does not save a certificate ID between renewals. Each +# upload gets a name unique to that run: the domain name plus a timestamp. +# This name never collides with an entry from a previous deploy. This is +# true even if that entry is still active. The hook uploads and activates +# the new certificate before it removes any old entries. If a failure +# occurs during this process, the server still has a valid, active +# certificate. The hook removes old entries only after activation is +# complete. It removes only entries whose name starts with the domain name, +# because this is the hook's own naming convention. As a result, this step +# can only affect entries that this hook created for this domain. It can +# never affect a certificate that a user uploaded manually, and it can +# never affect a self-signed certificate. +# +# Settings: +# DEPLOY_UNIFIOS_HOST - base URL of the management API +# (default: "https://localhost:11443") +# DEPLOY_UNIFIOS_USERNAME - UniFi OS Server admin username (required) +# DEPLOY_UNIFIOS_PASSWORD - UniFi OS Server admin password (required) +# +# Example: +# export DEPLOY_UNIFIOS_USERNAME="acmeuser" +# export DEPLOY_UNIFIOS_PASSWORD="xxxxx" +# acme.sh --deploy -d example.com --deploy-hook unifios +# +# Please report bugs to https://github.com/acmesh-official/acme.sh/issues/7182 + +_uos_response_code() { + # tr strips the trailing newline along with form feeds; re-terminate + # before the second _egrep_o, whose sed fallback (used wherever egrep -o + # is unavailable) drops an unterminated final line on some platforms. + _uos_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")" + printf '%s\n' "$_uos_code" | _egrep_o "^[0-9][0-9]*" +} + +_uos_response_cookie() { + # $1 = cookie name + grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1 +} + +unifios_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" + + # Scoped to this hook's own subshell -- does not affect the rest of the + # acme.sh run (e.g. the connection to the ACME CA). + export HTTPS_INSECURE=1 + + _getdeployconf DEPLOY_UNIFIOS_HOST + DEPLOY_UNIFIOS_HOST="${DEPLOY_UNIFIOS_HOST:-https://localhost:11443}" + _savedeployconf DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + _debug DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + + _getdeployconf DEPLOY_UNIFIOS_USERNAME + _getdeployconf DEPLOY_UNIFIOS_PASSWORD + + if [ -z "$DEPLOY_UNIFIOS_USERNAME" ] || [ -z "$DEPLOY_UNIFIOS_PASSWORD" ]; then + _err "DEPLOY_UNIFIOS_USERNAME and DEPLOY_UNIFIOS_PASSWORD must be set." + return 1 + fi + _debug DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" + _secure_debug DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" + + _info "Logging in to UniFi OS Server API at $DEPLOY_UNIFIOS_HOST..." + + # _json_encode always appends a trailing "\n" escape, even to input with + # no trailing newline (it normalizes via `echo`, unconditionally adding + # one). That's harmless for the key/cert file content below, which + # legitimately ends in a real newline anyway, but wrong for these plain + # strings -- strip the spurious escape it leaves behind. + _uos_user_json="$(printf '%s' "$DEPLOY_UNIFIOS_USERNAME" | _json_encode)" + _uos_user_json="${_uos_user_json%\\n}" + _uos_pass_json="$(printf '%s' "$DEPLOY_UNIFIOS_PASSWORD" | _json_encode)" + _uos_pass_json="${_uos_pass_json%\\n}" + _login_body="{\"username\":\"$_uos_user_json\",\"password\":\"$_uos_pass_json\",\"token\":\"\",\"rememberMe\":false}" + + _login_json="$(_post "$_login_body" "$DEPLOY_UNIFIOS_HOST/api/auth/login" "" "POST" "application/json")" + _login_code="$(_uos_response_code)" + + if [ "$_login_code" != "200" ]; then + _err "Login failed (HTTP $_login_code)." + _err "Response: $_login_json" + return 1 + fi + + # Credentials are proven correct now -- save them, rather than only at the + # very end, so a later step failing doesn't discard a working login. + # base64-encoded: _save_conf wraps values in single quotes with no + # escaping, so a literal "'" in the password would otherwise corrupt the + # domain conf (see deploy/synology_dsm.sh for the same pattern). + _savedeployconf DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" "base64" + _savedeployconf DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" "base64" + + _uos_token="$(_uos_response_cookie TOKEN)" + if [ -z "$_uos_token" ]; then + _err "Login succeeded but no TOKEN cookie was returned." + return 1 + fi + + _H1="Cookie: $_uos_token" + export _H1 + + _uos_jwt_payload="$(echo "$_uos_token" | cut -d '=' -f 2- | cut -d '.' -f 2)" + _uos_csrf="$(_durl_replace_base64 "$_uos_jwt_payload" | _dbase64 | _egrep_o '"csrfToken":"[^"]*"' | cut -d '"' -f 4)" + if [ -z "$_uos_csrf" ]; then + _err "Could not extract csrfToken from session token." + return 1 + fi + + _H2="x-csrf-token: $_uos_csrf" + export _H2 + + _info "Uploading new certificate..." + # "name" is a purely cosmetic label -- the server never validates it + # against the certificate's actual CN/SAN, and accepts arbitrary text + # including spaces (confirmed: a cert for example.com served correctly + # after being uploaded under the unrelated name "totally unrelated label"). + # The only constraint that matters here is uniqueness: the server rejects + # a second entry with a name it already has, so a bare domain name would + # collide with the previous deploy's entry on every renewal after the + # first. A full human-readable timestamp would make that obvious in the + # UI, but the certificate list's name column is fixed-width and doesn't + # wrap (confirmed against the real UI: a long name overlaps the Expires + # column and makes both unreadable), so keep the suffix short instead -- + # Unix epoch seconds are still unique enough for this purpose. + _uos_name="$_cdomain $(_time)" + _uos_key_json="$(_json_encode <"$_ckey")" + _uos_cert_json="$(_json_encode <"$_cfullchain")" + _create_body="{\"name\":\"$_uos_name\",\"key\":\"$_uos_key_json\",\"cert\":\"$_uos_cert_json\"}" + + _create_json="$(_post "$_create_body" "$DEPLOY_UNIFIOS_HOST/api/userCertificates" "" "POST" "application/json")" + _create_code="$(_uos_response_code)" + + if [ "$_create_code" = "201" ]; then + _new_id="$(echo "$_create_json" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Could not determine new certificate ID from upload response." + return 1 + fi + elif [ "$_create_code" = "400" ] && echo "$_create_json" | grep -q "USER_CERTIFICATE_DUPLICATE"; then + # HTTP 400 alone just means "bad request" -- it's the USER_CERTIFICATE_DUPLICATE + # code in the response body, checked above, that actually confirms this. + # The name above is unique to this run, so a duplicate here can only be + # the server's other uniqueness constraint: this exact certificate (by + # fingerprint) already exists as some other entry -- most likely a retry + # after a prior run already uploaded it (a real renewal always produces a + # new fingerprint, so this shouldn't happen in normal cron use). The + # response body doesn't include the existing entry's id, so look it up + # by fingerprint instead. + # The API's own fingerprint field is SHA-1 (20 bytes), not SHA-256 -- + # confirmed against a real response, e.g. + # "fingerprint":"FC:02:50:9C:3B:3F:B7:79:9D:CA:4D:7C:AC:92:E7:D5:EA:F1:3A:29" + # (20 colon-separated groups). _fingerprint (core helper) strips the + # colons that field has, so re-insert them rather than stripping the + # JSON's own colons, which would also remove the ones separating every + # key from its value. + _uos_fingerprint="$(_fingerprint "$_cfullchain" sha1)" + if [ -z "$_uos_fingerprint" ]; then + _err "Could not compute the certificate's fingerprint." + return 1 + fi + _uos_fingerprint="$(echo "$_uos_fingerprint" | sed 's/\(..\)/\1:/g; s/:$//')" + + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list existing certificates (HTTP $_list_code)." + _err "Response: $_list_json" + return 1 + fi + # _normalizeJson collapses the response to one predictable line (no stray + # whitespace around colons, no embedded CR/LF the server might emit) but + # also strips the trailing newline entirely -- re-terminate before the + # split below, since some sed implementations drop an unterminated final + # line rather than processing it. + _list_json="$(echo "$_list_json" | _normalizeJson)" + # A literal embedded newline (not the two-character "\n", which GNU sed + # treats as a newline in the replacement but POSIX doesn't define and BSD + # sed emits literally) splits it one JSON object per line so grep can + # match a single certificate entry at a time. + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + _new_id="$(echo "$_list_json" | grep -F "\"fingerprint\":\"$_uos_fingerprint\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Certificate upload rejected as a duplicate (server reported USER_CERTIFICATE_DUPLICATE), but no existing entry matching this fingerprint was found." + _err "Response: $_create_json" + return 1 + fi + # Reusing the existing entry rather than deleting it and re-uploading + # under today's name+timestamp: the served content is identical either + # way, so replacing it would only cost an extra delete+create round trip + # for no functional benefit. The tradeoff is cosmetic -- this entry keeps + # whatever name it was given whenever it was originally uploaded, so it + # won't reflect today's date in the UI. + _info "Certificate already present as entry $_new_id; reusing it." + else + _err "Certificate upload failed (HTTP $_create_code)." + _err "Response: $_create_json" + return 1 + fi + + _info "Activating certificate $_new_id..." + _activate_json="$(_post '{"active":true}' "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_new_id/status" "" "PUT" "application/json")" + _activate_code="$(_uos_response_code)" + + if [ "$_activate_code" != "200" ]; then + _err "Failed to activate new certificate (HTTP $_activate_code)." + _err "Response: $_activate_json" + return 1 + fi + + # UniFi OS Server activation is exclusive server-wide. Tests against the + # real API confirm this: activation of one entry deactivates whichever + # other entry was active before, no matter its name or domain. As a + # result, the server serves the certificate that this hook just activated. + # This certificate is already live. If the removal of old entries below + # fails, the hook logs the failure. The deploy does not fail because of + # this. + _info "Checking for old certificate entries to remove..." + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list certificates for cleanup (HTTP $_list_code) -- leaving old entries in place." + else + _list_json="$(echo "$_list_json" | _normalizeJson)" + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + # The pattern below matches the domain name followed by a space. If the + # space is missing, the pattern can also match a different domain that + # starts with the same text as this domain. + _old_ids="$(echo "$_list_json" | grep -F "\"name\":\"$_cdomain " | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4 | grep -v "^$_new_id$")" + for _old_id in $_old_ids; do + _info "Removing old certificate entry $_old_id..." + _del_json="$(_post "" "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_old_id" "" "DELETE")" + _del_code="$(_uos_response_code)" + if [ "$_del_code" != "204" ] && [ "$_del_code" != "200" ]; then + _err "Failed to delete old certificate $_old_id (HTTP $_del_code) -- leaving it in place." + _err "Response: $_del_json" + fi + done + fi + + _info "UniFi OS Server certificate deployed and activated successfully." + return 0 +}