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 +}