HA not working as expected with ext-jwt signer (without JIT enrollment)

That isn't an issue, that is Claude not understanding the whole enrollment process and how the PKI is managed. AI LLMs aren't good at system wide scope and context if you don't feed them properly via a prompt or pre-configured architecture or a persona with specific implementation knowledge.

Routers and SDKs that enroll receive a full chain up to but not including the root. The client supplied chain is used as potential links to a trust anchor. In that scenario, the root trust anchor chain will be returned even if the signing controller's intermediate isn't a trust anchor. So what it is saying isn't the issue.

We have a long standing issue to remove that behavior as well, the intermediate signer in the CA pool, but legacy systems and older enrolled clients will start to fail due to a old bug present in 1.3 (I think) that would carry forward for non-HA systems. We added facilities to OpenZiti for systems operators to identify which identities need to either re-enroll or upgrade to an SDK that will auto-refresh its certificates to avoid that situation. Going to HA requires identities to not be in that bugged state.

But saying that, are you using a really old identity? And if so can you send me the identity file w/o private key.

No, the test cluster I’m using here is a fresh installation with 2.0.0 and the identities I’m using to test are all newly created ones that have been created only after creating the cluster.

So I tried the same again and again, also with the latest stable 2.0.0 release and always see the same behavior. With OTT-enrolled identities, HA and all the failover scenarios work just fine.

But with ext-jwt identities from Keycloak, it falls apart as soon as the initial cluster node is not reachable anymore by the router. Also, adding the identity for any other controller URL than the first one (while all three are up and running) also leads to the router complaining about the invalid client certificate.

The only way I got it to work is my aforementioned "fix" (or workaround) by Claude so that the router adds every root certificate in self.env.GetRouterId().CaPool().Roots() to the rootPool in getControllerRootPool instead of using self.env.GetRouterId().CA() and x509.Verify() to build the rootPool.

So as it currently stands, we cannot use HA with the stable 2.0.0 release in our environment without applying this crude patch to the router code.

I will try recreating again in a lab environment and verify each piece's versions again either today or tomorrow.

Hi Andrew, I saw that you could reproduce the issue in the meanwhile ( Edge router rejects valid first-party client certificates when the edge signing CA differs from the ctrl-channel CA · Issue #4094 · openziti/ziti · GitHub ). This seems to be the exact issue that we're seeing, as we use the Helm chart to install OpenZiti and that basically uses this divergent identity config you mention there.

The issue with the Helm chart is that it uses cert-manager to build the certificate files instead of the ziti pki command and this only builds the file that is then referenced in server.identity to be chained to the individual controller's Edge Signer intermediate cert instead of the Root CA from which the Edge Signer intermediate was issued. This is just the way how cert-manager creates the certificate file.

Whereas when you stand up the PKI with ziti pki, it creates server.chain.pem which includes also the Root CA in the chain to be used for server.identity.

This would explain why you couldn't reproduce this issue on your side.

Can you post your helm values in the edgeSignerPki block for one of your subsequent controllers? Subsequent controllers should point to the primary controller's root issuer.

edgeSignerPki:
  alternativeIssuer:
    # Reference the first node's edge-root-issuer
    name: ziti-controller1-edge-root-issuer
    kind: Issuer
    group: cert-manager.io

Yes, they do. Just like it's documented in the Helm chart, the alternativeIssuer on the joining nodes is configured with the root CA of the initial controller. And as you can see above, the bug has been confirmed in the meanwhile: Edge router rejects valid first-party client certificates when the edge signing CA differs from the ctrl-channel CA · Issue #4094 · openziti/ziti · GitHub

Hello,

We are seeing the same symptoms with a three-controller OpenZiti 2.0.1 cluster deployed using the official ziti-controller Helm chart 3.2.1. We use cert-manager 1.19.2 and trust-manager 0.24.0, following the PKI setup documented by the chart.

We use an Entra ID external JWT signer with JIT enrollment enabled for both certificate and token modes. We reproduce the issue when clients use User Session mode: authentication succeeds and the clients show their services as available, but no traffic goes through.
The edge router logs:

invalid client certificate for api session

We followed the HA PKI layout documented by the official v3 Helm chart. Controller 1 creates the shared edge root, while each controller has its own intermediate edge signer issued by that root.

Controllers 2 and 3 explicitly reference controller 1’s root issuer:

edgeSignerPki:
  alternativeIssuer:
    name: ziti-controller1-edge-root-issuer
    kind: Issuer
    group: cert-manager.io

To be fully transparent, I did not diagnose this issue or write the workaround myself. Codex identified the likely certificate-chain problem, proposed rebuilding the chains at controller startup, and generated the wrapper below.
I applied it to our environment and validated the results, but I only understand the general principle and not every internal detail of the router’s certificate validation.

So please correct the analysis if anything is wrong.

As I understand the cert-manager tls.crt files used by identity.cert and identity.server_cert contain the controller leaf and its intermediate, but not the shared root. The shared root is available separately in the trust-manager bundle.

Cert-manager omits the root intentionally ( Certificate resource - cert-manager Documentation ), but in this particular OpenZiti path it seems that the router can end up validating against the intermediate associated with one controller instead of the common root.

That would explain why an API Session Certificate issued by one controller works, while a new one issued by another controller after reconnection or failover is rejected.

The generated workaround builds the following chain when each controller starts:

controller leaf → controller intermediate → shared root

It then starts the controller with a temporary configuration referencing the completed chains.

image:
  command:
    - /bin/bash
    - -ceu
  args:
    - |
      runtime_dir=/tmp/ziti-controller-runtime
      source_config=/etc/ziti/ziti-controller.yaml
      ca_bundle=/etc/ziti/ctrl-plane-cas/ctrl-plane-cas.crt
      client_cert=/etc/ziti/ctrl-plane-client-identity/tls.crt
      server_cert=/etc/ziti/ctrl-plane-identity/tls.crt

      for required_file in "${source_config}" "${ca_bundle}" "${client_cert}" "${server_cert}"; do
        if [[ ! -s "${required_file}" ]]; then
          echo "required controller PKI file is missing or empty: ${required_file}" >&2
          exit 1
        fi
      done

      mkdir -p "${runtime_dir}"

      # The trust-manager bundle is ordered from issuer to trust anchor. Keep
      # its final certificate, which is the shared self-signed root.
      awk '
        /-----BEGIN CERTIFICATE-----/ { certificate = ""; in_certificate = 1 }
        in_certificate { certificate = certificate $0 ORS }
        /-----END CERTIFICATE-----/ { last_certificate = certificate; in_certificate = 0 }
        END { printf "%s", last_certificate }
      ' "${ca_bundle}" > "${runtime_dir}/shared-root.crt"

      root_subject="$(openssl x509 -in "${runtime_dir}/shared-root.crt" -noout -subject -nameopt RFC2253)"
      root_issuer="$(openssl x509 -in "${runtime_dir}/shared-root.crt" -noout -issuer -nameopt RFC2253)"

      if [[ "${root_subject#subject=}" != "${root_issuer#issuer=}" ]]; then
        echo "the last certificate in ${ca_bundle} is not a self-signed root" >&2
        exit 1
      fi

      cat "${client_cert}" "${runtime_dir}/shared-root.crt" > "${runtime_dir}/client-chain.crt"
      cat "${server_cert}" "${runtime_dir}/shared-root.crt" > "${runtime_dir}/server-chain.crt"

      sed \
        -e "s|${client_cert}|${runtime_dir}/client-chain.crt|" \
        -e "s|${server_cert}|${runtime_dir}/server-chain.crt|" \
        "${source_config}" > "${runtime_dir}/ziti-controller.yaml"

      exec ziti controller run "${runtime_dir}/ziti-controller.yaml" "$@"

    # bash uses the first argument after the script as $0. The controller
    # chart appends the cluster agent flags after this placeholder.
    - ziti-controller-chain-wrapper

Since applying it, Windows and macOS User Sessions work fine, repeated tunnel reconnections continue to work, and connectivity still works after transferring leadership to another controller.

So, Does this look like the Helm chart manifestation of #4094 ( Edge router rejects valid first-party client certificates when the edge signing CA differs from the ctrl-channel CA · Issue #4094 · openziti/ziti · GitHub ), or is it still related to the shared-root case in #3857 ( Router rejects API session certificate after HA failover for ext-jwt identities without enrollment · Issue #3857 · openziti/ziti · GitHub )?

Is a fix planned, and would it be useful to open a separate issue in openziti/ziti or openziti/helm-charts?

Thanks

Hi there,

We're facing the exact same issue. We are currently in a POC phase to evaluate whether to roll this out as the primary VPN across our entire corporation (GO/NO-GO decision).

While the core functionality works, the current developer experience isn't quite where we need it to be.

I noticed two PRs that seem to address this issue:

  • PR #4140 (accept first-party certs issued by a separate CA)

  • PR #4153 (backport to release-v2.0.x)

Is there any update or estimated timeline on when these fixes will be merged ?

I'm working on getting them merged now.

Merged to latest and 2.0.x LTS.

Took longer than expected. An issue with another security patch would also have prevented multi-root-CA networks from working. Both PRs added integration tests for that setup.

Thanks a lot for you hard work, I built locally the images/the binary, well try this ASAP, keep you informed.

Indeed it works, thank you @andrew.martinez , I guess we'll have to wait for next LTS release (late september according to the release policy document).