mirror of
https://github.com/acmesh-official/acme.sh.git
synced 2026-08-18 15:13:29 +08:00
The certificate REST API this hook drives is UniFi OS's own, not specific to the self-hosted UniFi OS Server: user reports confirm it on a UDM Pro (UniFi OS 5.1.26) and a UCG Fiber (5.0.16). Reframe the scope around the endpoint rather than the product line, state that the choice between unifi and unifios is local/SSH file access vs remote REST API, and note that the management port is 11443 on UniFi OS Server but 443 on hardware, so DEPLOY_UNIFIOS_HOST must be set there.
316 lines
15 KiB
Bash
316 lines
15 KiB
Bash
#!/usr/bin/env sh
|
|
# Deploy hook for UniFi OS, via the certificate REST API.
|
|
#
|
|
# Works against any UniFi OS whose management UI exposes
|
|
# /api/userCertificates. Confirmed on:
|
|
# - UniFi OS Server (the separately-installed, self-hosted application)
|
|
# on macOS and on Linux. Windows should also work (it runs under
|
|
# WSL2), but has not been tested.
|
|
# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
|
|
# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on
|
|
# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916).
|
|
# No lower version bound is claimed -- if the UI has a certificate
|
|
# manager, this hook should work.
|
|
#
|
|
# `unifios` vs `unifi`: the split is the access method, not the product
|
|
# line. `unifi` writes files / a Java keystore and needs local or SSH
|
|
# access on the device; this hook drives the same REST API the web UI
|
|
# uses and works remotely. Use `unifi` where acme.sh runs on the device
|
|
# itself, this hook where it does not.
|
|
#
|
|
# The API is served on the management port, which differs per install:
|
|
# UniFi OS Server listens on 11443 (hence the default below), while
|
|
# UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to
|
|
# "https://<host>" there.
|
|
#
|
|
# Endpoints used, all as the web UI itself calls them:
|
|
# 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 may be
|
|
# self-signed -- it always is on a fresh install, and there is no reliable
|
|
# way to tell in advance whether an earlier run has already replaced it --
|
|
# 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", i.e. a UniFi OS Server on the
|
|
# same machine as acme.sh; set it to "https://<host>" for UniFi OS
|
|
# hardware or any remote target)
|
|
# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required)
|
|
# DEPLOY_UNIFIOS_PASSWORD - UniFi OS 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
|
|
}
|