Ansible mesh execution node: signed jobs over outbound mTLS, SSH/WinRM targets, no running sshd.
9.9K
Project overview · Contact · Support terms
The mesh peer that runs playbooks inside networks your controller can't reach. Place one in each closed network — a DMZ, an OT segment, an isolated VLAN, a remote site. It dials out to your control host over mutually-authenticated TLS, receives signed jobs from the orchestrator, runs them locally against reachable SSH or configured Windows/WinRM targets, and streams the output back. No inbound firewall holes, no VPN, no agent on the targets.
It is the Ansible Controller's full Ansible runtime plus receptor. The inherited SSH server is not started; mesh connections originate from the node.
| Image on Docker Hub | Purpose and where it runs |
|---|---|
| ansible-controller | Standalone control host. Runs playbooks directly against reachable SSH or WinRM targets. |
| ansible-orchestrator | Mesh control host. Includes the controller runtime and dispatches signed jobs through Receptor ingress sidecars to execution nodes. |
| ansible-execution-node | Inside each target network. Runs mesh jobs against local targets and connects outbound to the control host; no running SSH server. |
All three support linux/amd64 and linux/arm64. Controller and mesh use the same release versions; choose the image for its role. GitLab integration is optional.
The controller/orchestrator handles the optional GitLab workflow: reviewed exact-commit sync, snapshot verification and manual execution approval. Nodes receive signed job payloads; they do not clone repositories or hold Git fetch credentials. GitLab is not required for native mesh jobs.
Results return to the orchestrator for collection. The GitLab integration produces HTML, JSON and JUnit reports with provenance, distinct execution/transfer outcomes and available final Ansible recap totals. Unknown outcomes remain unresolved; collection retrieves an existing job without submitting it again.
The integration scripts and CI templates need a separate upgrade on the control side; pulling a node image alone does not install them. See sync, approvals, reports and upgrades. For 100+ systems, use reviewed canaries and Ansible batches, and size each node for its target connectivity and resource capacity. Inventory host counts in reports are not license or billing metrics.
Interactive Bash shells in mesh images show Learn more, Sponsor, and
Continue free links. The notice never waits for input, stays silent in CI and
command sessions, and does not enter worker protocol output. Set
ANSIBLE_CONTROLLER_SUPPORT_NOTICE=0 or create ~/.hushlogin to hide it;
controller-support displays it on demand in a terminal.
Contributions and paid assistance are optional. Both controller and mesh remain open source with no host limit or purchase requirement, including fleets larger than 100 systems. The standalone controller has no automatic notice.
Versions are cut automatically on every merge to main
(Conventional Commits: feat: bumps minor, fix: bumps patch). The execution node,
orchestrator, and controller always share the same version number, and each release of
this image is built FROM the exact controller digest published by the same run.
| Tag | Meaning | Use it when |
|---|---|---|
x.y.z (e.g. 0.29.0) | Versioned release | Production — use a tested version; pin its digest for fixed content |
x.y, x | Rolling within minor / major | You want patch/minor updates automatically |
latest | Last successful build of main | Trying things out |
main | Same as latest | — |
sha-<shortsha> | Exact commit build | Audits, reproducible pipelines, rollback |
See the Tags tab for the current version list. Commands below use 0.29.0
as a published example; choose your tested release and keep the three images aligned.
docker/mesh/Dockerfile, target execution-node)ghcr.io/allamiro/ansible-execution-nodelinux/amd64, linux/arm64 — both built on native runnersansible), receptor runs as PID 1 · Listening ports: none — the
image inherits the controller's EXPOSE 22 metadata, but no sshd is started and nothing
listens on it (so skip docker run -P)receptorctl status on the node's local control socket, so
docker compose up --wait returns only once the node is actually upansible-core, ansible.posix,
community.general, pywinrm + NTLM, and the controller-side Python libraries their
plugins need — with per-job content and node-local dependencies provisioned for your playbooks.receptor 1.6.7 — the mesh agent, a static binary rebuilt from the exact upstream
release commit with its Go module dependencies (x/crypto, x/net, x/text) bumped to
CVE-fixed versions, because the upstream binary fails this project's scanner gate.ansible-runner 2.4.3 — the worker that executes each streamed job.receptorctl — for the health check and on-node debugging.A node needs two things from your control plane's PKI (the mesh guide walks the request → sign → install sequence):
tls.crt + tls.key + ca.crt, issued by your offline CA
for this node's exact name. The private key is generated on the node host and never
leaves it.work-public.pem, the public half only. The private
half stays on the control host.Node names become the addresses you dispatch to, so name nodes after their network:
exec-dmz-a, exec-ot-b, and so on.
Copy mesh/compose.node.yml
and mesh/node.env.example
to the node host — no checkout needed — and put the bundle beside them:
.
├── compose.node.yml
├── .env # copied from node.env.example
└── secrets/receptor/
├── issued/exec-dmz-a/{tls.crt,tls.key,ca.crt}
└── work-signing/work-public.pem
# .env
RECEPTOR_NODE_ID=exec-dmz-a
RECEPTOR_PEERS=ctrl.example.com:27199,ctrl.example.com:27200
MESH_NODE_IMAGE=allamiro1/ansible-execution-node:0.29.0
chown -R 1000:1000 secrets/receptor/issued/exec-dmz-a # the container reads the bundle as uid 1000
docker compose -f compose.node.yml up -d --wait # returns once the node is HEALTHY
The node restarts with its host (restart: unless-stopped) and appears in
make mesh-status on the control host.
docker run -d --name mesh-node-exec-dmz-a --restart unless-stopped \
--tmpfs /run/receptor:uid=1000,gid=1000,mode=0750 \
-v "$PWD/secrets/receptor/issued/exec-dmz-a":/etc/receptor/tls:ro \
-v "$PWD/secrets/receptor/work-signing/work-public.pem":/etc/receptor/signing/work-public.pem:ro \
-e RECEPTOR_NODE_ID=exec-dmz-a \
-e RECEPTOR_PEERS=ctrl.example.com:27199,ctrl.example.com:27200 \
-e RECEPTOR_TLS_CERT=/etc/receptor/tls/tls.crt \
-e RECEPTOR_TLS_KEY=/etc/receptor/tls/tls.key \
-e RECEPTOR_TLS_CA=/etc/receptor/tls/ca.crt \
-e RECEPTOR_WORK_PUBKEY=/etc/receptor/signing/work-public.pem \
allamiro1/ansible-execution-node:0.29.0
No -p flags: nothing listens in a node (the inherited EXPOSE 22 is metadata only). It
needs just outbound TCP to the control host on 27199 (ingress A) and 27200 (ingress
B), and SSH reachability to its targets.
Add the node to mesh/config/pools.yml (and a zone in zones.yml) and dispatch:
make mesh-ping NODE=exec-dmz-a
make mesh-run NODE=exec-dmz-a PLAYBOOK=site.yml INVENTORY=inventory/dmz.ini
| Variable | Required | Purpose |
|---|---|---|
RECEPTOR_NODE_ID | yes | This node's identity — must match the name in its certificate; the ingress rejects a mismatch. Treat as permanent |
RECEPTOR_PEERS | yes | Comma-separated host:port list of ingresses to dial. Give both (A and B) so one can fail |
RECEPTOR_TLS_CERT, RECEPTOR_TLS_KEY, RECEPTOR_TLS_CA | yes | Paths to the node's certificate, private key, and the mesh CA certificate |
RECEPTOR_WORK_PUBKEY | yes | Path to the work-signing public key; every incoming job's signature is verified against it |
RECEPTOR_LOG_LEVEL | no | debug | info | warning | error (default info) |
RECEPTOR_INSECURE_DEV | never in production | 1 runs with no TLS and no signature check — exists solely for throwaway wiring experiments and warns loudly at startup |
All values are validated against a strict character set before being rendered into receptor's configuration, so an environment value cannot inject extra receptor actions.
| Container path | Mount | Purpose |
|---|---|---|
/etc/receptor/tls | bind, ro | The issued bundle (tls.crt, tls.key, ca.crt) |
/etc/receptor/signing/work-public.pem | bind, ro | The work-signing public key |
/run/receptor | tmpfs (uid=1000,gid=1000,mode=0750) | Rendered config and the owner-only control socket — never the writable layer |
Plugins that need Python packages on the machine running Ansible — cloud inventory SDKs
like boto3 are the classic case — need them on the node, since that is where the
playbook runs. Extend the image once, exactly as the controller does:
FROM allamiro1/ansible-execution-node:0.29.0
USER root
COPY node-requirements.txt /tmp/node-requirements.txt
RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/node-requirements.txt \
&& rm /tmp/node-requirements.txt
USER ansible
Pin the versions in node-requirements.txt, build it as your site tag, and set
MESH_NODE_IMAGE to it.
| Symptom | Likely cause | What to do |
|---|---|---|
Node missing from make mesh-status | It can't reach ports 27199/27200 on the control host, or its TLS bundle is wrong | Test outbound reachability from the node host; check docker logs — a refused TLS handshake means a certificate problem |
| Connection refused by the ingress | Expired certificate, wrong identity in the certificate, or a bundle from a different CA | Re-issue (new request on the node, sign offline, install, restart). This is deliberate — an unauthenticated node must never join |
| Container exits immediately | A required variable or file is missing, or a value failed validation | The entrypoint says exactly which one on stderr |
| Playbook failed | The playbook itself failed — the mesh reports honestly | Read the job's artifacts on the control host: logs/runner/<uuid>/ |
RECEPTOR_INSECURE_DEV escape hatch).cosign verify \
--certificate-identity-regexp 'https://github\.com/allamiro/ansible-controller/\.github/workflows/docker-publish\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
docker.io/allamiro1/ansible-execution-node:latest
Apache License 2.0 — see the GitHub repository for source, CI definitions, and contribution guidelines.
Content type
Image
Digest
sha256:dedcff665…
Size
143.2 MB
Last updated
5 days ago
docker pull allamiro1/ansible-execution-node