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
ykcs11package installslibykcs11.sounder/usr/lib/<multiarch>/, which is probed. - Fedora and RHEL.
yubico-piv-toolinstalls/usr/lib64/libykcs11.so.2, which is probed. - macOS and Homebrew. No path is probed. Install
yubico-piv-tool, find itslibykcs11.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
tokensandrefused. So does a run where every device was denied (the CLI exits 77 for that): the denies are inrefused, 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_unavailableandconnection_changedareTemporary,broker_refusedisBrokerRefused,agent_errorisPort49Error). The tokens minted so far are on.results, and every device the run left is in.results.refusedunder the stopping code. No answer at all isTemporaryfor exit 75 andFinalfor 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--deadlineunder 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.refusednames 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
Port49Errorwith codeanswer_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=runsport49 workload tokenonce 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.resultsnames the device and holds no token.agent=andworkload=together are aValueError. AWorkloadasagent=, or anything but aWorkloadasworkload=, is aTypeError. All three are raised before anything is minted. With neither,connectusesAgent(), and with no agent running raisesNoAgent, namingworkload=.- 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 givesssh-keygen -R NAME. - Your
StrictHostKeyChecking yesstands: 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'svarsor in inventory. Undernetwork_cli, ansible-core templates all three for every task, the mint'sset_factincluded, before the fact exists. Without the default, the mint itself fails withError 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. Theomitlasts only until theset_fact: a host whose mint fails stops there, so no task connects without the token. - Keep
gather_facts: false, and make the mint the firstpre_tasksentry. 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: falsestopsnetwork_clioffering 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 inansible_passwordandansible_user, a ten-task play minted 10 tokens for its first 2 tasks. The ninth unspent token was refusedcandidate_cap_reached(port49'stoken.candidate_cap, 8 by default), and the play failed at task 2. Mint once in aset_factand pointansible_passwordat the fact: the same play then minted once and logged in once for all ten tasks. - Never
cacheable: trueon 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_clicloses an idle persistent connection (persistent_connect_timeout, 30 s by default), andmeta: reset_connectioncloses 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 areset_connectionled to a second login, which port49 answered PASSreceipt_replayedfrom the stored decision. That holds withinreceipt_idle_ttl(15 m by default) of the last use andreceipt_max_age(2 h) of the first. Past either, the login failstoken_spent, and the device reports an authentication error that points nowhere near port49. Mint again (a secondset_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.-vvvand-vvvvoutput held no token. no_log: trueon the mint. The fact holds the token. Withno_log,-v,-vvvand-vvvvvprintcensoredfor 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_ageneeds a new mint: the token is single-use and expires, so a long play re-mints (a secondset_fact) rather than reusing one. Keep each mint close to the work that uses it. - A device that asks for
hardware_key_touchwants a touch per mint, and forks mint at once. So a person runsport49 ssh <one such device>once before the play, and the agent'sreuse_forcovers the forks. That command logs in to the device; exit at once. fieldislogin(the default),enable(needsenable=true),username,address,platformorall, a dict of those plusexpires_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 DEVICEat 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_logtask and show onlycensored. 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_clikeys a host byansible_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_filenaming an ssh_config withHostKeyAliasis 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)): withhost_key_auto_add, bothlibsshandparamikoconnected 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_cliwhenansible_connectionisnetwork_clioransible.netcommon.network_cli, or when none is visible andansible_network_osis set. The address isansible_host, else the inventory name, and[address]:portwhenansible_portis 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
libsshandparamikobefore any mint. - At a new address with no entry, it copies the device's key there.
libsshwithhost_key_auto_addstill accepted the other host's key and presented the token (paramikorefused), so do not enablehost_key_auto_addwithssh_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. Bothlibsshandparamikorefuse another host's key at a moved address without presenting the token.
With the lookup, run
network_cliwithouthost_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 withport49 ssh, or file a key fromssh-keyscanafter checking it on the console.What the lookup does for each case:
- Name has keys, address has none: it files them under the address, then mints.
- 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 examplessh-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. - 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.
- Neither has a key: a first contact, as before.
- A key of the address that
@revokedorRevokedHostKeysrevokes fails withhost_key_revoked.
The details of the check:
known_hosts_fileis the file to check, ifansible_ssh_common_argsor 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, elseansible_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 withhost_key_changed. AKnownHostsCommandfails withhost_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_cliis not checked. Use theHostKeyAliassetting below there. Which settings decide that:- For a device other than the current host, only its own
hostvarscount. Alocalhostplay'sansible_connection: localdoes not turn off the check for it. - For the current host, its inventory
ansible_connectionand its play'svars:both count, and either one namingnetwork_cliruns the check. So an inventorysshunder a play'snetwork_cliis checked, as Ansible connects withnetwork_cli. - An
ansible_connectionthat reaches the lookup still templated ("{{ conn }}") counts asnetwork_cli.
- For a device other than the current host, only its own
- 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
l66amintinglookup('port49.client.token', p49_name)forsrx1. Minted on a host that connects withnetwork_cli, it fails withhost_key_uncheckableand mints nothing: name the inventory host by its port49 name and mint forinventory_hostname, as the play above does. On any other connection it mints, unchecked, as for any device not onnetwork_cli.
- With a different key already filed under the new address, it refused for both
-
Never set
host_key_auto_addwithansible_network_cli_ssh_type: libssh. libssh appended the first contact and kept the file's comments and@revokedand@cert-authoritylines. But given a changed key for a host it knew, it accepted the new key, logged in and presented the token (L66 (c), (e3b)). Withouthost_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)). -
paramikowithhost_key_auto_addrefused 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
@revokedor@cert-authorityline (InvalidHostKey), so the play fails before connecting. That fails loudly and closed, but it meansport49 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_clipaths, 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
paramikomeets a changed key and refuses it (L66 (e3b), (e3d)). It stays a first contact for a device whose name holds no key. - It refuses a known_hosts file that holds an
-
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 sshand 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 isHost key verification failed.Do not set
host_key_checking = False. Ansible then passesStrictHostKeyChecking=noahead of your extra args, which overridesaccept-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.sshkeys 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 ownHostKeyAliaswas never looked up. - For
rawagainst Junos, also setansible_ssh_use_tty: false. The plugin runsrawwithssh -tt. The Junos CLI ran the command at once, then held the session open until the play was killed.
- One inventory name per address.
-
A re-keyed device has two entries: the name (
port49 ssh, Python,ansible.builtin.sshwith the alias) and the address (network_cli). Remove both withssh-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.