Skip to content

acme.sh for an asrock rack BMC

secrets

i make my secrets from my own secret store, which only fits my setup. the docker secret create commands here are plain swarm: use them, or however you normally make secrets.

acme.sh keeps a real certificate on my ASRock Rack BMC (the AMI MegaRAC web UI, firmware 11.02). it renews over cloudflare DNS, and a deploy hook installs every renewal on the BMC. after the first run there is nothing to do by hand. it runs as one replica, anywhere on the swarm.

compose.yml, 38 lines

download compose.yml

services:
  acme-sh:
    image: neilpang/acme.sh:latest@sha256:e1a4ac9fddb260b7171dd0c269790ae4561b6af35aa305b23cfdd98419ea1baf
    volumes:
      - acme:/acme.sh
    command: daemon
    environment:
      TZ: America/Los_Angeles
      CF_Account_ID: <account-id>
      ASROCK_BMC_URL: https://192.168.1.92
      ASROCK_BMC_USER: acme-sh
      ASROCK_BMC_PASSWORD_FILE: /run/secrets/asrock_bmc_password_v2
    configs:
      - source: asrock_bmc_hook
        target: /acmebin/deploy/asrock_bmc.sh
    secrets:
      - asrock_bmc_password_v2

    deploy:
       mode: replicated
       replicas: 1

configs:
  asrock_bmc_hook:
    file: ./asrock_bmc.sh
    name: acme_asrock_bmc_hook_v1

secrets:
  asrock_bmc_password_v2:
    external: true

volumes:
  acme:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/acme_asrock_bmc_acme"
      o: bind

before you deploy

  1. give acme.sh an account on the BMC with Administrator privilege, and KVM and virtual media turned off

    • the BMC's SSL page disables every control for anything less
    • mine is a dedicated account, acme-sh, not the built in admin
  2. create the state directory on the cephfs mount:

    sudo mkdir -m 700 /mnt/docker-cephFS/acme_asrock_bmc_acme
    
    • acme.sh keeps its CA account, the certificate, its key and the renewal settings there, and the cloudflare token once it has used it
  3. create the password secret on a manager, type the password, then Ctrl-D:

    docker secret create asrock_bmc_password_v2 -
    
    • swarm secrets can't be changed, so a new password goes in a new secret, _v3. ASROCK_BMC_PASSWORD_FILE in the compose names the one in use
  4. set CF_Account_ID to your cloudflare account id. it's on the right of any zone's overview page, under API

    • with a token, acme.sh needs the account id or the zone id to find the zone, and the account id covers every zone in the account

state considerations

  • acme is a named bind of /mnt/docker-cephFS/acme_asrock_bmc_acme, mounted at /acme.sh, see stack conventions. it holds all of acme.sh's state, the keys and the cloudflare token included, so only root can read it
  • the BMC password is a swarm secret and the hook a swarm config, so neither is in the volume

network considerations

  • nothing is published, and it joins no overlay network besides the stack's default. the container only calls out: to the CA, cloudflare and the BMC
  • ASROCK_BMC_URL is the BMC's IPv4 address, because my overlay networks have no IPv6

the first certificate

do this once, after the first deploy:

  1. on the node running the task, open a shell in the container:

    docker exec -it $(docker ps -q -f name=acme_asrock_bmc_acme-sh) sh
    
  2. issue the certificate:

    export CF_Token="<cloudflare token>"
    /acmebin/acme.sh --issue --server letsencrypt --dns dns_cf -d asrock-bmc.mydomain.com --home /acmebin --config-home /acme.sh
    
    • the token needs DNS edit on the zone: Zone > DNS > Edit on a user token, DNS Write on an account-owned token
    • acme.sh saves the token in /acme.sh/account.conf, and renewals read it from there
    • --server letsencrypt is saved with the certificate, so renewals stay with let's encrypt. without it acme.sh uses zerossl
  3. register the hook. this also installs the certificate straight away:

    /acmebin/acme.sh --deploy -d asrock-bmc.mydomain.com --ecc --deploy-hook asrock_bmc --home /acmebin --config-home /acme.sh
    
    • --deploy-hook saves Le_DeployHook in the certificate's settings, and every renewal after that runs the hook by itself
    • a --deploy run by hand has to name the hook every time. renewals read the saved one

the hook

the hook uploads with the same call that the BMC web UI's SSL page makes. it doesn't use redfish: on this firmware redfish ReplaceCertificate takes a certificate and no private key, so it can't install a certificate whose key acme.sh generated.

the hook reads the password from the file that ASROCK_BMC_PASSWORD_FILE names, and hands it to curl as a file, never as an argument. see secrets.

it's mounted as a swarm config at /acmebin/deploy/asrock_bmc.sh, which is where acme.sh looks for deploy hooks. swarm configs can't be changed, so bump the config name whenever the script changes.

asrock_bmc.sh: the deploy hook acme.sh runs after each renewal, 62 lines, 2 notes

each in the code opens a note on that line. download asrock_bmc.sh

#!/usr/bin/env sh

asrock_bmc_deploy() {
  _cdomain="$1"
  _ckey="$2"
  _cfullchain="$5"

  if [ -z "$ASROCK_BMC_URL" ] || [ -z "$ASROCK_BMC_USER" ]; then
    _err "ASROCK_BMC_URL and ASROCK_BMC_USER must be set"
    return 1
  fi
  _asrock_pwfile="${ASROCK_BMC_PASSWORD_FILE:-/run/secrets/asrock_bmc_password}"
  if [ ! -s "$_asrock_pwfile" ]; then
    _err "BMC password file $_asrock_pwfile is missing or empty"
    return 1
  fi

  _asrock_tmp="$(mktemp -d)"
  printf '%s' "$(cat "$_asrock_pwfile")" >"$_asrock_tmp/pw"

  _asrock_curl() {  # (1)!
    curl --silent --show-error --insecure --max-time 60 \
      --cookie "$_asrock_tmp/jar" --cookie-jar "$_asrock_tmp/jar" \
      --output "$_asrock_tmp/out" --write-out '%{http_code}' "$@"
  }

  _code="$(_asrock_curl --data-urlencode "username=$ASROCK_BMC_USER" \
    --data-urlencode "password@$_asrock_tmp/pw" "$ASROCK_BMC_URL/api/session")"
  rm -f "$_asrock_tmp/pw"
  if [ "$_code" != "200" ]; then
    _err "BMC login failed: HTTP $_code"
    rm -rf "$_asrock_tmp"
    return 1
  fi
  _csrf="$(jq -r .CSRFToken "$_asrock_tmp/out")"

  _code="$(_asrock_curl --header "X-CSRFTOKEN: $_csrf" \
    --form "new_certificate=@$_cfullchain" --form "new_private_key=@$_ckey" \
    "$ASROCK_BMC_URL/api/settings/ssl/certificate")"
  if [ "$_code" != "200" ] || [ "$(jq -r .cc "$_asrock_tmp/out" 2>/dev/null)" != "0" ]; then
    _err "BMC rejected the certificate: HTTP $_code $(head -c 200 "$_asrock_tmp/out")"
    _asrock_curl --header "X-CSRFTOKEN: $_csrf" --request DELETE "$ASROCK_BMC_URL/api/session" >/dev/null
    rm -rf "$_asrock_tmp"
    return 1
  fi

  _code="$(_asrock_curl --header "X-CSRFTOKEN: $_csrf" "$ASROCK_BMC_URL/api/settings/ssl/certificate-info")"
  if [ "$_code" = "200" ]; then  # (2)!
    cp "$_asrock_tmp/out" "$_asrock_tmp/info"
    _code="$(_asrock_curl --header "X-CSRFTOKEN: $_csrf" --header 'Content-Type: application/json' \
      --request PUT --data "@$_asrock_tmp/info" "$ASROCK_BMC_URL/api/settings/ssl/certificate-info")"
  fi
  if [ "$_code" != "200" ]; then
    _err "certificate uploaded, but saving certificate-info failed: HTTP $_code"
  fi

  _asrock_curl --header "X-CSRFTOKEN: $_csrf" --request DELETE "$ASROCK_BMC_URL/api/session" >/dev/null
  rm -rf "$_asrock_tmp"
  [ "$_code" = "200" ] || return 1
  _info "Certificate for $_cdomain installed on $ASROCK_BMC_URL"
  return 0
}
  1. every call to the BMC goes through _asrock_curl, which passes --insecure so the hook still works while the BMC is serving its self-signed factory certificate.
  2. the hook reads certificate-info in the line above and writes it back unchanged in the PUT below, which is what the BMC's SSL page does straight after an upload.

how it runs

  • command: daemon runs supercronic, which runs acme.sh --cron four times a day
  • the image is pinned by digest, renovate opens a PR when it changes

checking it

the BMC should serve the new certificate, issued by let's encrypt:

openssl s_client -connect asrock-bmc.mydomain.com:443 -servername asrock-bmc.mydomain.com </dev/null 2>/dev/null | openssl x509 -noout -issuer -dates

gatus checks the same certificate every hour, with verification on, and goes red with less than 21 days left. acme.sh renews with 30 days left, so red means about nine days of failed renewals.