#!/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 }