Table of Contents
- Integration overview
- Target topology
- Artifacts to exchange
- The test container
- Building the CMS keystore
- The pymqi test script
- Deploying as a Cloud Run Job
- Reading MQRC codes
- Stage 2 and stage 3 rationale
- Putting multiple queues on one connection
- Cost
- Decommissioning the test
Integration overview
A backend application running on GCP needs to PUT XML messages onto IBM MQ queues hosted by an external operator. The operator owns the qmgr; we connect as a client over a single SVRCONN channel and write to one or more destination queues. The operator publishes its configuration on a one-page form that lists:
- Queue manager name, host, and port.
- SVRCONN channel name.
- The MCAUSER they will map us to.
- The TLS cipher their channel mandates via
SSLCIPH. - Our expected source CIDR, for their CHLAUTH
ADDRESSMAPrule. - One or more destination queue names.
Before the backend application is wired in, we want an isolated job that proves the connection works end-to-end. This is what an infrastructure-side smoke test looks like:
- TCP reachable from our VPC to the qmgr's listener.
- TLS handshake completes with mutual cert validation.
- Channel start passes the operator's CHLAUTH rules.
- MQPUT to each destination queue succeeds.
If any of those four steps fails, we want a single MQRC code that pins the failure to one of them. The rest of this post is the smallest piece of infrastructure that returns that signal cleanly.
Target topology
Cloud Run Job (linux/amd64)
├── pymqi + IBM MQ redistributable client 9.4
├── /opt/mqssl/key.kdb + key.sth CMS keystore with client cert + trust CAs
└── mq_test.py
↓ Direct VPC egress: --network=vpc --subnet=subnet-app
↓ 10.0.X.0/24 NAT'd source seen by remote
↓
Remote operator
├── IBM MQ listener 100.72.X.X:1489
├── Queue manager QM_TEST
├── SVRCONN channel TENANT.SVRCONN SSLCIPH=ECDHE_RSA_AES_128_CBC_SHA256
├── CHLAUTH rules SSLPEERMAP + ADDRESSMAP → MCAUSER=tenantuser
└── Destination queues DEST.QUEUE.1, DEST.QUEUE.2
Three GCP primitives carry the request path:
- Cloud Run Job runs the test image on demand. Per-execution billing, no idle cost.
- Direct VPC egress via
--networkand--subnetroutes the job's outbound traffic through a regional subnet. The destination sees the subnet's NAT IP, which is what the operator allowlists. No Serverless VPC Connector is required; Direct VPC egress is the newer, cheaper option for jobs whose outbound traffic is light. - Artifact Registry in the same project holds the test image.
The keystore and the test script are baked into the image at build time. There is no Secret Manager dependency for the smoke test; the keystore password is hardcoded in the bake script because the same password is encoded into the key.sth stash file anyway. Secrecy lives at the keystore-file level, not the password level.
Artifacts to exchange
There are two distinct authentication models in common use. They look almost identical on the wire (TLS in both cases), but the artifact exchange between you and the operator is different.
Mutual TLS, operator validates your client cert
The operator's channel runs SSLCAUTH(REQUIRED) plus a CHLAUTH SSLPEERMAP rule keyed on your client cert's DN. Both sides present certs during the handshake, and both sides validate.
Operator gives you:
| Item | Why |
|---|---|
| Server CA chain in PEM or DER, root plus intermediates | Without it your client cannot verify the qmgr's server cert at the TLS layer. |
| Channel name, qmgr name, host, port | Required to populate the MQCD struct on the client. |
| MCAUSER they will map you to | Determines the default cert-label lookup, and the MQ-level identity for queue authorisation. |
| Confirmation of CHLAUTH model | Tells you whether to send an MQCSP block. Operators using SSLPEERMAP + CHCKCLNT(NONE) expect no MQCSP. |
You give the operator:
| Item | Why |
|---|---|
| Your client cert's DN and SHA-256 fingerprint | They register it in their CHLAUTH TYPE(SSLPEERMAP) SSLPEER('CN=...') rule. |
| Your outbound CIDR | They register it in their CHLAUTH TYPE(ADDRESSMAP) ADDRESS('10.0.x.0/24') rule. |
| The full chain that signed your client cert, if it is not from a public CA they already trust | Their qmgr-side truststore must validate your cert during the handshake. |
Server-only TLS, operator authenticates by source IP
The operator's channel runs SSLCAUTH(OPTIONAL) plus a CHLAUTH ADDRESSMAP rule keyed on your outbound CIDR. The TLS handshake still happens (SSLCIPH enforces encryption), but the operator's qmgr does not validate the client cert against any allowlist. Authentication collapses to "you came from the allowed network."
Operator gives you:
| Item | Why |
|---|---|
| Server CA chain in PEM or DER | Same as above: needed to verify the qmgr's server cert. |
| Channel name, qmgr name, host, port | Same as above. |
| MCAUSER they will map you to | Same as above. |
Explicit confirmation that SSLCAUTH(OPTIONAL) is set | So you know not to ship them a client cert. |
You give the operator:
| Item | Why |
|---|---|
| Your outbound CIDR | They register it in their CHLAUTH TYPE(ADDRESSMAP) ADDRESS('10.0.x.0/24') rule. |
You do not need to send them your client cert, your DN, your fingerprint, or your CA chain. You may still need a client cert on your side structurally (the C client looks for one when SSLCIPH is configured), but the operator will not validate what it sees.
The script in this article assumes server-only TLS
The pymqi script below configures SSLCipherSpec, points the keyring at a CMS file with our cert and the operator's trust chain, then connects. It does not assume the operator has registered our DN. If the operator instead runs mutual TLS and rejects you, you will see MQRC 2035 after a successful handshake, and the remediation is to send them your DN. Everything else in the script is identical between the two models.
The test container
A Python 3.12 base, with the IBM MQ 9.4 redistributable client materialised at build time. Only the SDK and TLS portions of the redist tarball are needed; the rest can be stripped with GENMQPKG_INC*=0 env vars to slim the image.
FROM python:3.12-slim-bookworm
ENV MQ_INSTALLATION_PATH=/opt/mqm \
LD_LIBRARY_PATH=/opt/mqm/lib64:/opt/mqm/lib \
PATH=/opt/mqm/bin:$PATH \
GENMQPKG_INCSDK=1 \
GENMQPKG_INCTLS=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gcc libc6-dev \
&& mkdir -p ${MQ_INSTALLATION_PATH} \
&& curl -fsSL https://public.dhe.ibm.com/ibmdl/export/pub/software/websphere/messaging/mqdev/redist/9.4.0.0-IBM-MQC-Redist-LinuxX64.tar.gz \
| tar -xz -C ${MQ_INSTALLATION_PATH} \
&& ${MQ_INSTALLATION_PATH}/bin/genmqpkg.sh -b ${MQ_INSTALLATION_PATH} \
&& pip install --no-cache-dir pymqi==1.12.11 \
&& apt-get purge -y gcc libc6-dev && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY mqssl/key.kdb mqssl/key.sth /opt/mqssl/
COPY mq_test.py /app/mq_test.py
WORKDIR /app
ENV MQ_HOST=100.72.0.0 \
MQ_PORT=1489 \
MQ_QMGR=QM_TEST \
MQ_CHANNEL=TENANT.SVRCONN \
MQ_QUEUE=DEST.QUEUE.1,DEST.QUEUE.2 \
MQ_USER=tenantuser \
MQ_PASSWORD= \
MQ_SSL_CIPHER=ECDHE_RSA_AES_128_CBC_SHA256 \
MQ_SSL_KEYR=/opt/mqssl/key \
MQ_SSL_CERT_LABEL=client.example.com \
MQ_MESSAGE="test"
ENTRYPOINT ["python", "/app/mq_test.py"]
MQ_SSL_KEYR is a path prefix; the MQ C client appends .kdb and .sth itself. MQ_SSL_CERT_LABEL must match the alias inside the CMS keystore: see the next section.
Building the CMS keystore
The IBM MQ C client cannot read PKCS12 keystores directly. If your application stack already produces PKCS12 (keystore.p12 plus truststore.p12) for a JVM-side consumer, convert them to a single CMS keystore at image-build time. The conversion uses runmqakm, which only ships with the MQ install. Run it inside a throwaway container of the official MQ image rather than expecting the host to have it.
#!/usr/bin/env bash
set -euo pipefail
KS_PW='<keystore password>'
TS_PW='<truststore password>'
cd "$(dirname "$0")"
rm -rf mqssl && mkdir mqssl
docker run --rm \
-v "$PWD/ssl:/in:ro" \
-v "$PWD/from_operator:/extra:ro" \
-v "$PWD/mqssl:/out" \
-e "KS_PW=$KS_PW" -e "TS_PW=$TS_PW" \
--user "$(id -u):$(id -g)" \
--entrypoint bash \
icr.io/ibm-messaging/mq:latest -c '
set -e
cd /out
runmqakm -keydb -create -db key.kdb -pw "$KS_PW" -type cms -stash
runmqakm -cert -import -file /in/keystore.p12 -pw "$KS_PW" -type pkcs12 \
-target key.kdb -target_pw "$KS_PW" -target_type cms
runmqakm -cert -import -file /in/truststore.p12 -pw "$TS_PW" -type pkcs12 \
-target key.kdb -target_pw "$KS_PW" -target_type cms
for f in /extra/*; do
[ -f "$f" ] || continue
runmqakm -cert -add -db key.kdb -pw "$KS_PW" \
-label "$(basename "${f%.*}")" -file "$f" -format ascii
done
runmqakm -cert -list -db key.kdb -pw "$KS_PW"
'
ls -la mqssl/
Three subtleties:
runmqakm -keydb -create -stashis meant to produce bothkey.kdbandkey.sthin one call. Some builds of the tool quietly skip the stash step; ifkey.sthis missing afterwards, follow up withrunmqakm -keydb -stashpw -db key.kdb -pw "$KS_PW" -type cms.--user "$(id -u):$(id -g)"keeps output files owned by the host user. Without it, the container's defaultmqmUID owns them and laterrm -rffails on the host.- The MQ client picks the personal cert by label, not by usage. The default convention is
ibmwebspheremq<lowercase-mcauser>; for MCAUSERTENANTUSERthe default lookup label isibmwebspheremqtenantuser. If your PKCS12 keystore was built for an HTTPS web server, the cert's friendly-name will be something likeexample.comrather thanibmwebspheremq.... Either rename the alias at import time withrunmqakm -cert -rename, or setMQ_SSL_CERT_LABELto the existing friendly-name and let pymqi pass it through viapymqi.CD.CertificateLabel.
The pymqi test script
Three stages, all timestamped and clearly labelled in the log output. TCP first because if it fails everything else is noise. TLS last because it is the only stage that proves the full stack works.
import os, socket, sys, time, traceback
def log(msg):
print(f"[{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}] {msg}", flush=True)
def tcp_check(host, port):
log(f"[1/3] TCP check -> {host}:{port}")
with socket.create_connection((host, port), timeout=5.0) as s:
log(f" TCP OK -> {s.getsockname()} -> {s.getpeername()}")
def _build_cd(channel, conn_info, ssl_cipher="", cert_label=""):
import pymqi
from pymqi import CMQC, CMQXC
cd = pymqi.CD()
cd.ChannelName = channel.encode()
cd.ConnectionName = conn_info.encode()
cd.ChannelType = CMQC.MQCHT_CLNTCONN
cd.TransportType = CMQC.MQXPT_TCP
cd.MsgCompList[0] = CMQXC.MQCOMPRESS_ZLIBHIGH
if ssl_cipher:
cd.SSLCipherSpec = ssl_cipher.encode()
if cert_label:
cd.CertificateLabel = cert_label.encode()
return cd
def _connect_put_disconnect(qmgr, cd, queues, payload, user, password, sco=None):
import pymqi
kwargs = {"cd": cd}
if password:
kwargs["user"] = user.encode()
kwargs["password"] = password.encode()
if sco is not None:
kwargs["sco"] = sco
qm = pymqi.QueueManager(None)
qm.connect_with_options(qmgr, **kwargs)
log(" MQCONNX OK")
for queue in queues:
q = pymqi.Queue(qm, queue)
try:
q.put(payload.encode())
log(f" MQPUT OK ({len(payload)} bytes -> {queue})")
finally:
q.close()
qm.disconnect()
log(" MQDISC OK")
def mq_userpw_check(host, port, qmgr, channel, queues, user, password, payload):
log(f"[2/3] MQ user+password -> qmgr={qmgr} channel={channel} queues={queues}")
cd = _build_cd(channel, f"{host}({port})")
_connect_put_disconnect(qmgr, cd, queues, payload, user, password)
def mq_tls_check(host, port, qmgr, channel, queues, user, password, payload,
cipher, keyr, peer, cert_label):
import pymqi
log(f"[3/3] MQ TLS -> cipher={cipher} keyr={keyr} cert_label={cert_label or 'default'}")
cd = _build_cd(channel, f"{host}({port})", ssl_cipher=cipher, cert_label=cert_label)
if peer:
cd.SSLPeerName = peer.encode()
sco = pymqi.SCO()
sco.KeyRepository = keyr.encode()
_connect_put_disconnect(qmgr, cd, queues, payload, user, password, sco=sco)
The main() glue:
def main():
host = os.environ["MQ_HOST"]
port = int(os.environ["MQ_PORT"])
qmgr = os.environ["MQ_QMGR"]
channel = os.environ["MQ_CHANNEL"]
queues = [q.strip() for q in os.environ["MQ_QUEUE"].split(",") if q.strip()]
user = os.environ.get("MQ_USER", "")
password = os.environ.get("MQ_PASSWORD", "")
payload = os.environ.get("MQ_MESSAGE", "ping")
ssl_cipher = os.environ.get("MQ_SSL_CIPHER", "")
ssl_keyr = os.environ.get("MQ_SSL_KEYR", "")
ssl_peer = os.environ.get("MQ_SSL_PEER_NAME", "")
ssl_label = os.environ.get("MQ_SSL_CERT_LABEL", "")
try:
tcp_check(host, port)
except Exception:
log("[1/3] FAILED"); traceback.print_exc(); return 2
try:
mq_userpw_check(host, port, qmgr, channel, queues, user, password, payload)
except Exception:
log("[2/3] WARNING: non-TLS attempt failed, expected if channel requires TLS")
traceback.print_exc()
try:
mq_tls_check(host, port, qmgr, channel, queues, user, password, payload,
ssl_cipher, ssl_keyr, ssl_peer, ssl_label)
except Exception:
log("[3/3] FAILED"); traceback.print_exc(); return 3
log("DONE")
return 0
if __name__ == "__main__":
sys.exit(main())
Three deliberate design choices:
- Stage 1 TCP is fatal. If the network path is broken, every later stage produces misleading errors. Exit 2 is reserved for this.
- Stage 2 no-TLS MQ connect is non-fatal. Against an
SSLCIPH-enforced channel, stage 2 always fails: the server signals "TLS required" and the C client surfaces it asMQRC 2393. Stage 2 is kept as a diagnostic so its absence is visible if you later point this test at a non-TLS channel; then stage 2 would actually succeed and the lack ofWARNINGconfirms the channel is not TLS-only. - Stage 3 TLS MQ connect+put is fatal. Exit 3 is reserved for this. Stage 3 is the only one whose success means the full setup works.
If MQ_PASSWORD is empty, the MQCSP block is omitted entirely from MQCONNX. Operators with CHCKCLNT(NONE) reject a connection that sends an MQCSP with an empty password; sending none at all is the correct behavior for SSLPEERMAP- or ADDRESSMAP-only auth.
Deploying as a Cloud Run Job
Direct VPC egress puts the job's outbound traffic on a real subnet. The destination sees the subnet's NAT IP, which is what the operator allowlists.
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ID="<project>"
REGION=europe-west3
JOB_NAME=ibmmq-test
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/infra-repo/ibmmq-test:latest"
gcloud run jobs delete "${JOB_NAME}" \
--project="${PROJECT_ID}" --region="${REGION}" --quiet 2>/dev/null || true
gcloud run jobs deploy "${JOB_NAME}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--image="${IMAGE}" \
--network=vpc \
--subnet=subnet-app \
--vpc-egress=all-traffic \
--max-retries=1 \
--task-timeout=120s
gcloud run jobs execute "${JOB_NAME}" \
--project="${PROJECT_ID}" --region="${REGION}" --wait
--vpc-egress=all-traffic is mandatory when the destination is RFC 6598 carrier-NAT space. The default egress mode only sends private-RFC-1918 traffic through the VPC; RFC 6598 (100.64.0.0/10) falls back to public egress, which dead-ends.
The script is intentionally "delete then create" rather than "update if exists." The diff between two job revisions can include flag handling on --clear-env-vars, --clear-secrets, and similar. Recreating is shorter and idempotent.
Reading MQRC codes
Four codes cover almost every failure mode you will see during a TLS smoke test. Each one is a specific instruction; do not generalise across them.
MQRC 2393, MQRC_SSL_INITIALIZATION_ERROR
pymqi.MQMIError: MQI Error. Comp: 2, Reason 2393: FAILED: MQRC_SSL_INITIALIZATION_ERROR
Two distinct causes share this code, which is the main thing that makes it confusing:
-
TLS is configured on the client and the keystore is broken. Missing
key.sth, wrongMQ_SSL_KEYRprefix, cert label not present in the kdb, or kdb password mismatch. Reproduce withrunmqakm -cert -list -db key.kdb -stashed. If that command fails, the keystore is the problem. -
TLS is not configured on the client and the server channel requires TLS. Stage 2 of this test always produces this case. The server rejects the channel start because
SSLCIPHis set; the client surfaces the rejection asMQRC 2393. The reason name is misleading: there is no SSL "initialization" happening on the client at all. Confirm with the server side:DISPLAY CHANNEL(...) SSLCIPH SSLCAUTH.
MQRC 2059, MQRC_Q_MGR_NOT_AVAILABLE
pymqi.MQMIError: MQI Error. Comp: 2, Reason 2059: FAILED: MQRC_Q_MGR_NOT_AVAILABLE
The TLS handshake is failing. The server cert chain does not validate against your truststore. The qmgr never gets to evaluate CHLAUTH because the client aborts the handshake first.
Standard diagnostic:
openssl s_client -connect <host>:<port> -showcerts < /dev/null
Inspect the chain in the output. Its leaf is the qmgr's server cert; the chain entries above it must all be present in your truststore, or be issued by something in your truststore. Most often the missing piece is the issuing intermediate, not the root. Operators routinely send only the leaf cert and trust the client to have the intermediate.
Add the missing intermediate:
runmqakm -cert -add -db key.kdb -stashed \
-label operator-intermediate -file operator-intermediate.pem -format ascii
Rebuild the image. Retry.
MQRC 2035, MQRC_NOT_AUTHORIZED
pymqi.MQMIError: MQI Error. Comp: 2, Reason 2035: FAILED: MQRC_NOT_AUTHORIZED
TLS handshake completed. The server is rejecting the connection at MQ application layer: your channel start request reached the qmgr, but no CHLAUTH rule matched, or a BLOCKUSER rule matched.
In server-only TLS mode, the only filter is your source CIDR. Confirm with the operator that the IP your job actually egresses from matches their ADDRESSMAP rule. The most common cause is that your job is now egressing from a different subnet than the one quoted on the form.
If the operator runs mutual TLS, the same code can also mean your client cert's DN is not in their SSLPEERMAP allowlist. The fastest diagnostic is to email the operator the exact value of three things:
- The DN of your client cert, from
openssl x509 -in client.pem -noout -subject. - Its SHA-256 fingerprint.
- Your outbound source CIDR.
The fix is on their side: either an existing rule has the wrong filter, or they have not yet added a rule for your identity at all.
MQRC 2009, MQRC_CONNECTION_BROKEN
pymqi.MQMIError: MQI Error. Comp: 2, Reason 2009: FAILED: MQRC_CONNECTION_BROKEN
The TCP connection dropped mid-handshake or mid-exchange. Re-run the job. If it persists, ask the operator to check their qmgr's AMQERR01.LOG at the timestamp of your attempt; the server-side log will name the channel and the specific reason it terminated the conversation. Common causes are firewall idle timers killing the connection, MQ heartbeat misconfiguration, or a deliberate STOP CHANNEL on the operator side that ended up matching your channel name.
Stage 2 and stage 3 rationale
The TCP probe rules out network problems unambiguously. But there is a class of failure where TCP succeeds and MQRC 2393 follows: namely, a misconfigured client-side TLS setup against a TLS-required channel. To distinguish that from "you accidentally pointed the test at a non-TLS channel," it helps to run a no-TLS attempt explicitly. If stage 2 succeeds, the channel does not require TLS at all and your stage 3 setup is over-specified. If stage 2 fails with MQRC 2393, the channel correctly requires TLS and you should investigate stage 3.
Either way, stage 2's outcome is informational, not load-bearing, which is why its failure is logged as a warning and the script continues.
Putting multiple queues on one connection
Once the connection is up, MQPUTs are cheap. Both queue authorisations are checked at PUT time against the MCAUSER the channel mapped you to. If PUT to queue 1 succeeds and PUT to queue 2 fails with MQRC 2035, the qmgr granted you channel access but not queue access on queue 2. That is a separate authorization grant the operator has to add (SET AUTHREC PROFILE(DEST.QUEUE.2) PRINCIPAL(tenantuser) AUTHADD(PUT)), and it is worth flagging explicitly to them when reporting.
The implementation, given a comma-separated MQ_QUEUE env var, is one MQCONNX, N MQPUTs, one MQDISC:
queues = [q.strip() for q in os.environ["MQ_QUEUE"].split(",") if q.strip()]
qm = pymqi.QueueManager(None)
qm.connect_with_options(qmgr, **kwargs)
for queue in queues:
q = pymqi.Queue(qm, queue)
try:
q.put(payload.encode())
finally:
q.close()
qm.disconnect()
Cost
Cloud Run Jobs bill per task-second, with no idle cost. A single execution of this image (TCP probe, TLS handshake, two MQPUTs, disconnect) takes around six seconds of CPU and memory time. Even running it hourly against pre-prod and prod, monthly cost is well under USD 1.
Direct VPC egress on Cloud Run Jobs is free for the VPC attachment itself; you pay only for the outbound bytes leaving the subnet, charged at the VPC's regional egress rates. For a smoke-test workload sending kilobyte payloads, this is also a rounding error.
The IBM MQ redistributable client is free to download and embed in your own image. There is no per-seat licence cost for the client side of an SVRCONN connection.
Decommissioning the test
The job exists only to validate the connection. Once the production application is wired up, delete the job and its image:
gcloud run jobs delete ibmmq-test --project=<project> --region=europe-west3
gcloud artifacts docker images delete \
europe-west3-docker.pkg.dev/<project>/infra-repo/ibmmq-test --delete-tags
Keep the keystore baking script and the mq_test.py source in the repo. They are cheap to re-run and useful the next time an operator changes their channel config, rotates their server cert, or moves a queue to a new MCAUSER.