Skip to content

Install the clients

The clients run on the machines where people and automation sign in, not on the server. There are three: the port49 command, the Python library, and the Ansible lookup. The Python library and the Ansible lookup both drive the port49 command, so install that first.

The two port49 builds

The release has two builds of port49. Both are /usr/bin/port49, so no archive and no package holds both.

Build Where you get it Hardware keys
Client the port49 package and archive, for Linux and macOS (amd64 and arm64) PKCS#11 keys (a YubiKey or a smartcard), the TPM (Linux) and file keys
Static the port49-server package and archive, and the container image the TPM (Linux) and file keys only

If your key lives on a YubiKey or a smartcard, run port49 login from the client build. The client build loads the system's PKCS#11 library at run time, so it needs the system's dynamic loader, which every desktop Linux and macOS has.

The static build has no such loader dependency, which suits a server or a container. It will not use a pkcs11: key: a key store set to pkcs11 is refused, and auto passes over the key and says so. Nothing else about it changes.

See Hardware keys for setting a key up.

Check which one you have, and its version:

port49 --version

Download a release

Each release on GitHub Releases carries the client archive for each system, port49_<version>_<os>_<arch>.tar.gz, and a SHA256SUMS file covering every asset. Download the archive and the sums, check the archive against them, then unpack port49 onto your PATH:

PORT49_VERSION=0.1.0
base="https://github.com/blanketfish/port49/releases/download/v${PORT49_VERSION}"
curl -fsSLO "${base}/port49_${PORT49_VERSION}_linux_amd64.tar.gz"
curl -fsSLO "${base}/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS
tar -xzf "port49_${PORT49_VERSION}_linux_amd64.tar.gz" port49
sudo install -m 0755 port49 /usr/local/bin/port49

On macOS, use darwin in the archive name, and shasum -a 256 --ignore-missing -c SHA256SUMS to check it. Each asset also has a build provenance attestation. With the GitHub CLI, gh attestation verify port49_${PORT49_VERSION}_linux_amd64.tar.gz --repo blanketfish/port49 checks that the release workflow built it.

PKCS#11 on Linux, macOS and Homebrew

A hardware key needs the YubiKey PKCS#11 module, libykcs11. port49 hwkey setup and port49 hwkey status find it in this order: the --module flag, then the module-path of a key already in your config, then a probe of the system paths where a package installs it.

  • Debian and Ubuntu. The ykcs11 package installs libykcs11.so under /usr/lib/<multiarch>/, which is probed.
  • Fedora and RHEL. yubico-piv-tool installs /usr/lib64/libykcs11.so.2, which is probed.
  • macOS and Homebrew. No path is probed. Install yubico-piv-tool, find its libykcs11.dylib, and pass it:
port49 hwkey status --module <path-to>/libykcs11.dylib

port49 refuses to load a PKCS#11 module that another user could replace. On macOS it accepts write access held by the admin group, because Homebrew's prefix is group-writable by that group by design, but no other group. Once the key is in your config, its module-path names the module and you do not pass --module again.

If no module is found, the command says so and exits with status 2: no libykcs11 found; install yubico-piv-tool or pass --module on Linux, and on macOS no libykcs11 is probed on macOS: pass --module PATH, the libykcs11.dylib of your yubico-piv-tool install.

The Python library

Device tokens from the port49 agent, for scripts, Nornir and Netmiko. The library drives the port49 CLI (not the API or the agent socket), so a hardware key, its PIN, a touch and per_login_mfa take the one path they take at a terminal. Python 3.10 or later; standard library only.

Install

pip install ./clients/python            # the library
pip install './clients/python[netmiko]' # and Netmiko, for port49.netmiko

port49 must be on PATH (or pass Agent(port49="/opt/port49/bin/port49")), and port49 login must have started an agent.

Use

from port49 import Agent, Denied, MFARequired

agent = Agent()
print(agent.identity()["principal"])

tok = agent.token("leaf1", enable=True, ttl=300)
# tok.address, tok.username, tok.login, tok.enable (login and enable are not in repr)

res = agent.tokens(["leaf1", "leaf2", "core1"])   # one mint per device, in turn
res.tokens      # {device: Token}
res.refused     # {device: Refusal}: denies, and devices that want a second factor
res.raise_for_refused()

A machine's tokens: Workload

For a service with a workload certificate (Oxidized, a backup job), Workload drives port49 workload token, which reads the certificate and key at every run:

from port49 import Workload

w = Workload(broker="https://broker.example.com:8443", broker_ca="/etc/port49/ca.pem",
             cert="/etc/port49/w.pem", key="/etc/port49/w.key")
print(w.identity()["principal"])
res = w.tokens(scope="backup")                       # every device the scope selects that you hold a grant on
res = w.tokens(["leaf1", "leaf2"], purpose="nightly backup")
res.tokens      # {device: Token}
res.refused     # {device: Refusal}: denies, and devices that want a second factor

Pass touch_key= (a pkcs11: reference with a pin-source), broker_crl= and deadline= (seconds) to reach the CLI's flags of those names.

  • A scope that selects nothing returns empty tokens and refused. So does a run where every device was denied (the CLI exits 77 for that): the denies are in refused, and nothing raises.
  • A run that stopped before it finished never returns: the CLI says so (stopped), and the library raises the class of that code (broker_unavailable and connection_changed are Temporary, broker_refused is BrokerRefused, agent_error is Port49Error). The tokens minted so far are on .results, and every device the run left is in .results.refused under the stopping code. No answer at all is Temporary for exit 75 and Final for exit 77 (the certificate expired: nothing can mint until someone acts).
  • The library never reruns a workload run, not even on Temporary. The CLI already retries each page within its --deadline under one Idempotency-Key; when that runs out, the page in flight may already be minted at the broker with its answer lost, and a new run has a new key, so it would mint that page's devices again. Deciding to run again is yours: .results.refused names the devices a stopped run left. A rerun of a scope sweep, which cannot name what it left, mints every page again, the ones already minted included.
  • The answer holds a token for every device and is read whole, up to 64 MiB (some 200,000 devices). Larger raises Port49Error with code answer_too_large (the run may have minted): split the sweep, or have the CLI write the tokens with --out.
  • A device name may not be empty, padded with whitespace, start with # or hold a line break (ValueError): the CLI would trim or skip it.

Errors

Every exception is a Port49Error with .code, .detail and .device.

CLI code Class What to do
not_installed NotInstalled Install port49 or pass port49=.
no_agent, agent_too_old NoAgent Run port49 login.
session_over, credential_revoked SessionOver Run port49 login again.
a deny (verdict: deny) Denied (.reason is the broker's reason, .verdict) Ask for the grant; not retried.
per_login_mfa_required MFARequired (.requirement) A person runs port49 ssh DEVICE at a terminal; one proof covers their agent's further logins for the requirement's reuse_for_seconds. An agent stops and asks a person.
touch_timeout, key_locked, key_absent KeyUnavailable Touch the key, unlock it, or plug it in; not retried.
broker_refused BrokerRefused The broker refused a proof, or its certificate was refused; not retried.
revocation_running, connection_changed Temporary Retried by Agent; see below. Workload never reruns a run: run it again later.
broker_unavailable Temporary Not retried: the request may have reached the broker and minted. Run it again when the broker is back.
device_unconfigured, attribute_too_long, attribute_unsafe, broker_error, no_token DeviceFault The broker could not decide this one device (or minted it no token). Fix its inventory entry (device_unconfigured, attribute_*), or read the broker's log (broker_error, no_token). Other devices are unaffected.
agent_error, exit_N, timeout Port49Error Read .detail.
answer_too_large Port49Error The CLI's answer passed the library's 64 MiB cap: the tokens may be minted but are not read. Split the sweep, or use port49 workload token --out.

An error the runner raised before reading an answer (not_installed, timeout, answer_too_large) has no .results: what was minted is unknown, not empty.

tokens() collects what belongs to one device (a deny, a second factor, a DeviceFault) into refused, so one device never stops a sweep. Test a refusal with r.per_login_mfa is not None or isinstance(r.exception(), MFARequired), not r.error: the same second-factor condition comes from the CLI's code (error) or from a broker deny (reason). Anything else stops the sweep, and the exception's .results holds the tokens minted so far, so a rerun need not mint them again.

.results holds live tokens: use it in the process that caught the error, and do not log or serialize it. str, repr, pickle and copy of the exception leave the tokens out (a rebuilt exception has the device names and None for each token), but vars(e) and e.results itself are the real thing.

Netmiko and host keys

from port49 import Workload
from port49.netmiko import connect

conn = connect("leaf1", enable=True)    # a person's token, through the agent `port49 login` started
conn.disconnect()
w = Workload(broker="https://broker.example.com:8443", broker_ca="/etc/port49/ca.pem",
             cert="/etc/port49/w.pem", key="/etc/port49/w.key")
conn = connect("leaf1", workload=w)     # a machine's token, through `port49 workload token`
conn.disconnect()
# a Netmiko connection either way; extra keyword arguments pass to Netmiko
  • workload= runs port49 workload token once for the one device, and never again (a workload run is never rerun). A refusal raises what the agent path raises: Denied, MFARequired (a device that asks for a second factor the run did not prove), DeviceFault. A stopped run raises its code's class, and its .results names the device and holds no token.
  • agent= and workload= together are a ValueError. A Workload as agent=, or anything but a Workload as workload=, is a TypeError. All three are raised before anything is minted. With neither, connect uses Agent(), and with no agent running raises NoAgent, naming workload=.
  • Host keys are the same on both paths, below.

port49 stores no host key. connect keeps the device's key in your OpenSSH known_hosts, under the device's inventory name (your HostKeyAlias if you set one), the entry port49 ssh uses. It reads your ssh_config through ssh -G and the key files through ssh-keygen -F, and parses neither itself.

  • No key filed: the key the device presents is filed before the token is sent (accept-new). Many workers reaching a new device at once each write one whole line.
  • A key filed: the only key accepted. A changed key, a key of another type, or another host at the address is HostKeyError (host_key_changed) before the token is sent; the message gives ssh-keygen -R NAME.
  • Your StrictHostKeyChecking yes stands: nothing is filed, and a device with no key filed is refused (host_key_unknown).

Refused, with the setting named, and never filed past:

Code When
device_name A name known_hosts would read as a pattern or a marker (leaf*, a,b, @x, ...). Checked before the mint.
ssh_config_unread ssh -G failed on your ssh_config.
known_hosts_unread ssh-keygen -F failed on a known_hosts file.
host_key_uncheckable A KnownHostsCommand (use port49 ssh); a RevokedHostKeys that ssh-keygen -Q cannot read (a plain key list, which ssh honours and this client does not: make a KRL with ssh-keygen -k); a path in GlobalKnownHostsFile or UserKnownHostsFile that cannot be resolved; a host covered only by @cert-authority, which Paramiko cannot check; a ProxyJump or ProxyCommand (Paramiko dials the address itself, so a key filed or checked could be another host's: use port49 ssh); a Port that is not the one connect dials (port=); a BindAddress or BindInterface (Paramiko takes the default route); keys filed only in types Paramiko cannot load (ssh-dss, sk-*); a device with nothing filed that offers only types Paramiko cannot check; in the Ansible lookup, a port49 name that is not an inventory host, minted on a host that connects with network_cli (mint for inventory_hostname).
host_keys_not_kept Under accept-new, the first UserKnownHostsFile is /dev/null, none, a link to either, or not a file you can write (or create). Every connection would be a first contact.
host_key_revoked An @revoked line or RevokedHostKeys names the key, filed beside your entry or presented at first contact.
host_key_unknown, host_key_changed As above.

Only the host-key types filed under the name are negotiated, on the first connect and on every rebuild (so OpenSSH 8.8 and later, which do not offer ssh-rsa, still present the filed RSA key).

The returned connection keeps the key its connect accepted, in memory. A reconnect (establish_connection()) or an SCP transfer on it loads that key into the new Paramiko client, looks the files up again (a key revoked or removed since is not loaded, and with none left nothing connects), and refuses any other key before authenticating. Every such refusal is HostKeyError with code host_key_changed (or host_key_revoked), never Netmiko's NetmikoTimeoutException and never Paramiko's BadHostKeyException. Initially Paramiko compares against the first key of the presented type, so after a rotation that appended a second key of one type, connect can refuse with host_key_changed until the old line is removed.

A refusal after the mint leaves that token unused; it expires. That Paramiko refuses a wrong key before it authenticates was measured on 2026-10-14 (tests/lab/results/L65-<platform>.md).

What is retried, and why

token runs again, at most attempts times (default 3) with a 1 s, 2 s, 4 s ... wait between, only for a code that means nothing was sent to mint with: revocation_running (a revocation sweep is on) and connection_changed. A connection_changed retry asks for a second touch, since the touch is bound to the connection that changed.

Never retried: broker_unavailable (the request may have reached the broker before it died, and a rerun would mint again and hold another candidate slot until the first token expires; it is Temporary, so the caller decides), a key or PIN failure (a locked key would burn its tries), touch_timeout (the person did not touch; running again would only prompt them again unasked), a deny, broker_refused, a lost session.

Secrets

The token is never in argv, the environment, a log or a repr. It is read from the CLI's stdout and handed to Netmiko (or Ansible) only. connect's connection holds it as Netmiko's password and secret, which Netmiko logs in again with, and Netmiko's own log filter holds it to mask it, until disconnect(): call disconnect(), or use the connection as a context manager, when done. An exception connect raises has its frames' locals cleared, in its traceback and in the tracebacks it chains, except a generator's or coroutine's frame or one still running, which hold no token on this path, so a traceback printed with its locals shows no token. Netmiko masks the token in its DEBUG log and session log; everything else the session sends is logged in full, so keep DEBUG off in production. No exception, repr or failed-parse error carries the CLI's stdout. Once a call returns or raises, the library keeps no copy of the CLI's stdout, even if a program the CLI started (a wrapper's background job) holds that stdout open: the library reads and drops whatever that program prints until it closes it, and never closes the pipe under it. The CLI's stderr (a touch prompt, a sign-in URL) is passed to your stderr as it comes; pass Agent(stderr=file) to send it elsewhere.

The Ansible collection

Device tokens from port49 for Ansible: the lookup port49.client.token. It drives the port49 CLI through the port49 Python library (clients/python), so a hardware key, its PIN, a touch and per_login_mfa take the one path they take at a terminal.

Install

pip install ./clients/python                                   # on the controller: the library
ansible-galaxy collection install ./clients/ansible/ansible_collections/port49/client

Install the library and collection from the tree, as above, to match a checkout. From the first release (v0.1.0) on, this project also publishes them as port49 on PyPI and port49.client on Galaxy: install the version port49 --version prints (pip install port49==<version>, ansible-galaxy collection install port49.client:==<version>). No other package name is port49's. Where ansible-core lives in a pipx venv, pipx inject ansible-core ./clients/python.

port49 must be on the controller's PATH (or pass port49=/opt/port49/bin/port49), and port49 login must have started an agent. Needs ansible-core 2.16 or later.

Use: mint once per host per play

- hosts: leaves
  gather_facts: false
  pre_tasks:
    - name: mint this host's token
      ansible.builtin.set_fact:
        port49: "{{ lookup('port49.client.token', inventory_hostname, field='all', enable=true) }}"
      no_log: true
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: arista.eos.eos
    ansible_user: "{{ port49.username | default(omit) }}"
    ansible_password: "{{ port49.login | default(omit) }}"
    ansible_become_password: "{{ port49.enable | default(omit) }}"
    ansible_look_for_keys: false
  tasks:
    - arista.eos.eos_facts:

inventory_hostname must be the name port49's inventory knows. A token is single-use and short lived; each call of the lookup is a mint.

  • Keep | default(omit) on the connection variables, in the play's vars or in inventory. Under network_cli, ansible-core templates all three for every task, the mint's set_fact included, before the fact exists. Without the default, the mint itself fails with Error processing keyword 'remote_user': 'port49' is undefined (or 'password', 'become_pass'). Measured on 2.21.4, and the collection's real-ansible test runs this play. The omit lasts only until the set_fact: a host whose mint fails stops there, so no task connects without the token.
  • Keep gather_facts: false, and make the mint the first pre_tasks entry. Anything that connects before the mint connects without the token: fact gathering would log in as you, with your own credentials. ansible_look_for_keys: false stops network_cli offering your own ssh keys ahead of the token.
  • Never put the lookup itself in ansible_password. Ansible templates connection variables many times per task. Measured on EOS, NX-OS, IOS-XE, vJunos-router and vSRX (L66 (a), tests/lab/results/L66-<platform>.md): with the lookup in ansible_password and ansible_user, a ten-task play minted 10 tokens for its first 2 tasks. The ninth unspent token was refused candidate_cap_reached (port49's token.candidate_cap, 8 by default), and the play failed at task 2. Mint once in a set_fact and point ansible_password at the fact: the same play then minted once and logged in once for all ten tasks.
  • Never cacheable: true on the mint. It writes the token to the fact-cache backend (a jsonfile, or Redis in a real deployment).
  • A reconnect reuses the spent token, and that works only inside the receipt's window. network_cli closes an idle persistent connection (persistent_connect_timeout, 30 s by default), and meta: reset_connection closes it too. The next task logs in again with the same token. Measured on all five lab platforms (L66 (b2)): both an idle gap and a reset_connection led to a second login, which port49 answered PASS receipt_replayed from the stored decision. That holds within receipt_idle_ttl (15 m by default) of the last use and receipt_max_age (2 h) of the first. Past either, the login fails token_spent, and the device reports an authentication error that points nowhere near port49. Mint again (a second set_fact) before a gap that long.
  • Never enable persistent_log_messages (ANSIBLE_PERSISTENT_LOG_MESSAGES). The persistent socket carries the connection's options, password included, to the connection process, and with this setting the log file records them. Measured on all five lab platforms (L66 (d)): the token is in that log in the clear. Ansible prints a warning saying so; believe it. -vvv and -vvvv output held no token.
  • no_log: true on the mint. The fact holds the token. With no_log, -v, -vvv and -vvvvv print censored for the task and nothing else (the collection's real-ansible test runs all four and searches the output for the token).
  • A play longer than receipt_max_age needs a new mint: the token is single-use and expires, so a long play re-mints (a second set_fact) rather than reusing one. Keep each mint close to the work that uses it.
  • A device that asks for hardware_key_touch wants a touch per mint, and forks mint at once. So a person runs port49 ssh <one such device> once before the play, and the agent's reuse_for covers the forks. That command logs in to the device; exit at once.
  • field is login (the default), enable (needs enable=true), username, address, platform or all, a dict of those plus expires_at.

A machine: workload mode

Give all of broker, broker_ca, cert and key and the lookup drives port49 workload token instead of your agent. It reads the certificate and key at every run.

lookup('port49.client.token', inventory_hostname, field='all',
       broker='https://broker.example.com:8443', broker_ca='/etc/port49/ca.pem',
       cert='/etc/port49/w.pem', key='/etc/port49/w.key')

touch_key (a pkcs11: reference with a pin-source) and deadline (seconds) reach the CLI's flags of those names. A workload run is never rerun by the library or the lookup, even when the broker was unreachable: the page in flight may already be minted. Running the play again is yours to decide.

Errors

A failed lookup raises an Ansible error that names the device and port49's code, for example port49: leaf2: deny no_grants_matched: no grant. It never carries a token or the CLI's output. The library's README lists the codes and what to do for each.

  • A refusal fails that host only. A deny, a second factor the lookup cannot give (a person runs port49 ssh DEVICE at a terminal first) or a device the broker cannot decide fails that host only. The play goes on for the others.
  • The message always ends in port49's code. The code is in brackets when the detail does not already hold it: port49: leaf2: the device has no platform [device_unconfigured].
  • On ansible-core 2.16 to 2.18, read the code in the warning. Those versions censor the message of a failed no_log task and show only censored. So the lookup also raises the message as an Ansible warning, and the code appears in the [WARNING] line beside it. Measured on 2.16, 2.17 and 2.18; 2.19 and later print the error itself, measured to 2.21.
  • A workload option given without the other three, or given empty (an unset variable), is an error. The lookup never falls back to your agent.
  • The lookup retries nothing beyond what the library does for an agent: a revocation sweep or a changed connection, three tries. A broker outage, a deny, a locked key and a lost session are never retried.

Secrets

The token travels from the CLI's stdout to the lookup's return value and nowhere else: not argv, not the environment, not a log, not an exception. The lookup prints nothing. What Ansible does with the value is its own: it is in the task result of the set_fact (censored by no_log) and in any later task that templates it. A debug of the fact, or a task without no_log that receives ansible_password, prints or logs it at its own verbosity; keep no_log on those. Ansible has no way to mark one value secret, so no_log on every task that templates the token is the control.

Host keys

port49 stores no host key. Ansible keeps them its own way.

What follows was measured with ansible-core 2.21.4, ansible.netcommon 8.7.1, ansible-pylibssh 1.4.0 (libssh 0.12.0) and paramiko 5.0.0. Each platform gave the same reading: EOS, NX-OS, IOS-XE, vJunos-router and vSRX (lab entry L66, tests/lab/results/L66-<platform>.md). It is the client's behaviour, not the device's.

  • network_cli keys a host by ansible_host, the address in Ansible's inventory, not by the port49 name. Both ssh types filed the first contact under the address. ansible_libssh_config_file naming an ssh_config with HostKeyAlias is not honoured: the key still went under the address. So an address changed for a device in the source of truth (a dynamic inventory built from it, the usual case) is a first contact for Ansible. Measured (L66 (e)): with host_key_auto_add, both libssh and paramiko connected to another host at the new address and presented it the token.
  • The lookup closes that gap before it mints. For a device on network_cli, it compares the keys filed under the port49 name with the keys filed under the address in ~/.ssh/known_hosts.

    A device is on network_cli when ansible_connection is network_cli or ansible.netcommon.network_cli, or when none is visible and ansible_network_os is set. The address is ansible_host, else the inventory name, and [address]:port when ansible_port is not 22.

    Measured on L66 (e3), 2026-10-16 (tests/lab/results/L66-arista_eos.md):

    • With a different key already filed under the new address, it refused for both libssh and paramiko before any mint.
    • At a new address with no entry, it copies the device's key there. libssh with host_key_auto_add still accepted the other host's key and presented the token (paramiko refused), so do not enable host_key_auto_add with ssh_type: libssh.
    • Measured also without host_key_auto_add (L66 (e3d)): the lookup copies the name's key to a new address, and the play logs in at the device's own address. Both libssh and paramiko refuse another host's key at a moved address without presenting the token.

    With the lookup, run network_cli without host_key_auto_add, for either ssh type. Only a device's very first contact (case 4 below) needs its key filed some other way: connect once with port49 ssh, or file a key from ssh-keyscan after checking it on the console.

    What the lookup does for each case:

    1. Name has keys, address has none: it files them under the address, then mints.
    2. Both have keys, and a key type in both differs: it mints nothing and fails with host_key_changed. The message gives both fingerprints and the command that clears the stale address, for example ssh-keygen -R '[10.0.0.9]:2222' -f /home/<user>/.ssh/known_hosts. Check the device's key on its console first. Run the command only if the address now belongs to this device.
    3. Name has none, address has keys: it copies them under the name, then mints. If no user known_hosts file is writable, it warns and mints anyway.
    4. Neither has a key: a first contact, as before.
    5. A key of the address that @revoked or RevokedHostKeys revokes fails with host_key_revoked.

    The details of the check:

    • known_hosts_file is the file to check, if ansible_ssh_common_args or the ssh type points Ansible elsewhere. It defaults to ~/.ssh/known_hosts.
    • In workload mode, every device is checked before the one mint.
    • The port is ansible_port, else ansible_ssh_port.
    • If your ssh_config gives the device a HostKeyAlias, the keys filed under the alias count as the name's. A key type where the name and the alias disagree fails with host_key_changed. A KnownHostsCommand fails with host_key_uncheckable: the lookup cannot run it as ssh does.
    • The check runs before the connection. It cannot see a key that changes later, and libssh accepts one (next bullet).
    • A device not on network_cli is not checked. Use the HostKeyAlias setting below there. Which settings decide that:
      • For a device other than the current host, only its own hostvars count. A localhost play's ansible_connection: local does not turn off the check for it.
      • For the current host, its inventory ansible_connection and its play's vars: both count, and either one naming network_cli runs the check. So an inventory ssh under a play's network_cli is checked, as Ansible connects with network_cli.
      • An ansible_connection that reaches the lookup still templated ("{{ conn }}") counts as network_cli.
    • A port49 name that is not a host in Ansible's inventory has no address the lookup can check. An example is an inventory name l66a minting lookup('port49.client.token', p49_name) for srx1. Minted on a host that connects with network_cli, it fails with host_key_uncheckable and mints nothing: name the inventory host by its port49 name and mint for inventory_hostname, as the play above does. On any other connection it mints, unchecked, as for any device not on network_cli.
  • Never set host_key_auto_add with ansible_network_cli_ssh_type: libssh. libssh appended the first contact and kept the file's comments and @revoked and @cert-authority lines. But given a changed key for a host it knew, it accepted the new key, logged in and presented the token (L66 (c), (e3b)). Without host_key_auto_add, and with the lookup's copy of the name's key at the address, libssh refused another host's key and presented nothing (L66 (e3d)).

  • paramiko with host_key_auto_add refused a changed key before authentication. No TACACS+ exchange happened and the token was never presented. It has two costs:

    • It refuses a known_hosts file that holds an @revoked or @cert-authority line (InvalidHostKey), so the play fails before connecting. That fails loudly and closed, but it means port49 ssh's revocation lines and Paramiko cannot share a file.
    • On a first contact it rewrites the file and drops every comment.

    Of the two network_cli paths, this is the one that refuses a changed key. A first contact is still accepted.

    With the lookup (above), an address changed in the inventory is not a first contact when the device's name holds a key. The lookup copies the name's key to the new address first, so paramiko meets a changed key and refuses it (L66 (e3b), (e3d)). It stays a first contact for a device whose name holds no key.

  • Where the connection runs OpenSSH (ansible.builtin.ssh), set:

    ansible_ssh_extra_args: "-o HostKeyAlias={{ inventory_hostname }} -o StrictHostKeyChecking=accept-new"
    

    This files keys under the port49 name, as port49 ssh and the Python client do (the name is lowercased, as they lowercase it). This is the measured path that closes the gap (L66 (e)): with the alias, an address moved to another host was refused, and the token was never presented to it.

    Without accept-new, a first contact is Host key verification failed.

    Do not set host_key_checking = False. Ansible then passes StrictHostKeyChecking=no ahead of your extra args, which overrides accept-new. OpenSSH still withholds a password from a host whose key changed, but a first contact is accepted unchecked.

    • One inventory name per address. ansible.builtin.ssh keys its ControlPath on address, port and user. Measured (L66 (e2)): a second inventory name at the same address reused the first name's open master, and its own HostKeyAlias was never looked up.
    • For raw against Junos, also set ansible_ssh_use_tty: false. The plugin runs raw with ssh -tt. The Junos CLI ran the command at once, then held the session open until the play was killed.
  • A re-keyed device has two entries: the name (port49 ssh, Python, ansible.builtin.ssh with the alias) and the address (network_cli). Remove both with ssh-keygen -R:

    ssh-keygen -R leaf1
    ssh-keygen -R 10.0.0.11
    

Tests

make test-python                               # stdlib unittest; Ansible is stubbed, none needed
PORT49_TEST_ANSIBLE_PLAYBOOK=/path/to/venv/bin/ansible-playbook make test-python   # and real ansible-core

The real-ansible run includes the play above under network_cli, against a closed local port; it needs ansible.netcommon and arista.eos (in that venv, or on a collections path named by PORT49_TEST_ANSIBLE_COLLECTIONS) and is skipped without them.