Target host preparation
A target host is not ready because the gateway can reach it. It is ready when
isg system check says it is — and that verdict is not a summary of this page,
it is the definition this page describes. Everything below exists to make one
row read OK:
Ready to broker: <host>
What "ready" is measured as
The readiness row is an aggregate of seven sub-checks. It never dials anything itself: each answer is a measurement one of the neighbouring checks in the same run already made. Any sub-check not met makes the row FAIL; otherwise any sub-check not measured makes it WARN; only all-met is OK. A not-met and a not-measured are different verdicts and are never folded together.
| # | Sub-check | Met when | Measured by |
|---|---|---|---|
| 1 | human_access rule |
Some human_access allow rule in ca.yaml names this host, by hosts: or by a matching host_labels: selector |
Always — a static read of ca.yaml |
| 2 | host_cert |
The gateway's own issuance record holds a certificate for this host that is neither expired nor revoked | Always — a static read of the gateway's ledger |
| 3 | trusted_ca |
The target accepted a certificate this CA signed | --probe-hosts, with isg serve running |
| 4 | break_glass |
At least one key read off the target's break-glass keyset was accepted by the isg-breakglass account |
--probe-keyset |
| 5 | break_glass_keyset |
The keyset file itself is what it should be: restrict on every line, the expected mode and owner, and a cert-authority line whose fingerprint matches breakglass_ca_fingerprint |
--probe-keyset |
| 6 | reachable |
DNS resolves, TCP 22 answers, and the target's host key passes the strict check | Always for DNS and TCP; the host-key verdict deepens under --probe-hosts |
| 7 | level |
Always met — it reports the host's lockdown level and where the level came from | Always |
Sub-check 7 is not a threshold to clear. The lockdown level is a direct input to the per-command policy gate and to four-eyes approval, so the row would be hiding one of its own inputs if it left the value out.
Note what follows from the table: a plain isg system check cannot produce an
OK readiness row. Sub-checks 3, 4 and 5 are gated behind probe flags, so
without them the row reads WARN with each unmeasured sub-check naming the flag
that would measure it. That is the intended reading — the row does not claim a
host is ready on evidence it did not gather.
sshd requirements
Two directives, and they are two different directions of trust. Neither replaces the other.
TrustedUserCAKeys — the target accepts us. This is what makes the
gateway's ephemeral session certificate an acceptable login. Install the CA's
public key and point sshd at it:
# on the gateway
isg ca pubkey > isg_ca.pub
# on the target
sudo install -m 644 isg_ca.pub /etc/ssh/ca/isg_ca.pub
printf '%s\n' 'TrustedUserCAKeys /etc/ssh/ca/isg_ca.pub' 'LogLevel VERBOSE' \
| sudo tee /etc/ssh/sshd_config.d/50-isg-userca.conf
sudo sshd -t && sudo systemctl reload sshd
The directive is additive: existing key-based logins keep working, which is why
this step is safe to apply before anything else. Check sshd_config for an
Include /etc/ssh/sshd_config.d/*.conf line first — without one, the directive
belongs in the main file. Run sshd -t before the reload, every time.
LogLevel VERBOSE goes into the same drop-in for the same reason it is in the
break-glass Match block below: it is what makes the target's own log attribute
a certificate login to the key fingerprint, and the drop-in is the place that
covers every brokered login, not only the emergency account.
Cert-only posture is a separate, opt-in step. Once certificate logins are
measured working on this host, PasswordAuthentication no can be added to the
same drop-in so the certificate is the only way in. Do not add it earlier: with
it, a host whose TrustedUserCAKeys line is wrong has no login left. The
isg_target_host Ansible role has this behind isg_target_host_cert_only,
off by default, for exactly that reason.
HostCertificate — the target proves itself to us. The gateway's default
host-key policy is strict, which means it accepts only a host certificate
signed by its own CA: not a bare host key, and not a key it has seen before.
Issue one over the target's existing host public key — the CA never sees the
private half — and install it:
# on the gateway
isg ca host-cert issue --host <host> --key ./<host>_host_ed25519.pub
# on the target
sudo install -m 644 <host>-cert.pub /etc/ssh/ssh_host_ed25519_key-cert.pub
echo 'HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub' \
| sudo tee /etc/ssh/sshd_config.d/60-isg-hostcert.conf
sudo sshd -t && sudo systemctl reload sshd
The file to copy to the target is the published mirror under pub/hosts/, not
the gateway's own copy under ca/. A reload (SIGHUP) is enough: sshd picks up
the HostCertificate on re-reading its config, open sessions are left alone,
and the gateway's strict host-key check passes right after — no restart is
needed, and the isg_target_host role reloads for the same reason.
The certificate's principals must match the name the gateway dials.
--principals defaults to --host, and the strict host-key check compares the
certificate's principals against the exact hostname used for the connection. A
certificate issued for host.example.com is rejected when the inventory calls
the machine host. List every alias: --principals host,host.example.com.
There is no principals file to maintain. The mechanism is the certificate's
own principal list: the gateway mints a certificate whose single principal is
the OS user of the session, and sshd admits that certificate as a login for
that user because the CA is trusted. Nothing in ISG writes, requires or reads
an AuthorizedPrincipalsFile.
Accounts and groups
| On the target | Needed for | Notes |
|---|---|---|
The OS account named by --as |
Every brokered session | It is the certificate's principal. It needs no authorized_keys entry and no password — the certificate is the credential |
Whatever sudo rights that account should have |
Privilege escalation inside a session | Escalation happens on the target, under the target's own sudo rules. The gateway does not hold a password for it; the target's prompt is passed through to the operator's terminal, and neither the broker nor the session recording captures what is typed. For a password sudo the person has to know the account's password — in practice a directory-backed account, see below |
isg-breakglass |
The emergency path | A dedicated account with its own sshd Match block reading /etc/ssh/breakglass_authorized_keys |
The gateway itself needs no account on the target.
The break-glass block is a small, self-contained drop-in:
Match User isg-breakglass
AuthorizedKeysFile /etc/ssh/breakglass_authorized_keys
PubkeyAuthentication yes
LogLevel VERBOSE
Match all
The closing Match all is required, not decoration: without it every global
directive that follows the Include in the main sshd_config would fall under
this Match block. LogLevel VERBOSE is what makes the target's own log
attribute a login to a key fingerprint. The keyset file is expected to be mode
0644 owned by root:root — the account must not be able to rewrite the list
of keys that admit it — and every line, including a cert-authority line, is
expected to carry the restrict option.
Named local accounts, one per person
On a target host the account name is not a convention to agree on — it is the
only name the login can have. On the gateway-login front the session is always
opened under the name of the person who authenticated; there is no --as to
redirect it, and there is no principal mapping on the target either: the role
installs TrustedUserCAKeys and no AuthorizedPrincipalsFile, so sshd admits
the certificate as a login for the principal the certificate carries, and that
principal is the person's own user name. A shared account would have nothing to
match against — the certificate names a person, the host offers one account for
everybody, and the connection is simply refused.
So everyone who should reach a target host has their own local account on it.
The isg_target_host Ansible role creates them from one list,
isg_target_host_users, and three properties of those accounts are deliberate
rather than incidental.
A shell, a home, and a locked password. The account exists in order to run
commands — that is the thing ISG brokers — so it gets a login shell
(isg_target_host_users_shell, /bin/bash) and a home directory. The password
field is locked: the certificate is the credential, and a password must not
become a second way in. One directive makes that combination work and is worth
knowing by name: UsePAM yes. Without PAM, sshd reads the locked shadow field
as a disabled account and refuses the login even though the certificate and
the trusted CA are both in order. Both distribution families ship yes; the
role does not set it, it measures it.
No sudo. The role writes no sudoers file and adds nobody to wheel or
sudo. An account it manages carries exactly two groups: its own primary group
and the managed group. Escalation on a target host stays what the table above
says it is — the target's own sudo rules, decided per host by whoever owns the
host, not a side effect of account creation. The verification measures both
directions: the group list with id -Gn, and what the account may actually run
with sudo -l -U.
The managed group is the scope — and an account that already exists is never
adopted. Every account the role creates is a member of one group
(isg_target_host_users_group, isg-testers), and that group is the role's
reach: a name on the list becomes an account, a member of the group that has
left the list is removed with its home, and an account outside the group is
neither read nor touched. That last part is what keeps the admin account the
automation itself connects with out of range.
It also fixes what happens when a listed name is already taken on the host by an account that is not in the managed group. The role refuses to run, and names the collision. Adopting the account instead would look harmless — it would simply join the group — but it would also move that account inside the role's scope, where a later, entirely innocent narrowing of the name list deletes it along with its home. The failure would arrive months after the change that caused it, which is why the answer is a stop rather than a warning: either take the name off the list, or rename the existing account by hand first.
An empty isg_target_host_users list has a stated meaning too. It does not mean
"leave accounts alone"; it means the managed group is empty, so its current
members are removed. There is no state in which the role silently manages
nothing.
Directory-backed accounts and password sudo
A local account created with a locked password is enough to log in — the
certificate is the credential — but it cannot use a password sudo: the target
asks for the account's password, and there is none that anyone knows. Escalation
needs an account whose password the person already has. In an enterprise that is
the directory account: the target host is joined to the same directory that ISG
authenticates against (SSSD, or the AD integration of the platform), sudo asks
for the person's own directory password, and the target checks it — the same
password the person signed in to ISG with, typed again. The gateway does not
reuse the sign-in password and does not store it; with single sign-on it never
sees one at all.
The isg_target_directory Ansible role sets up exactly that on a target host:
SSSD over ldaps with certificate verification required, a read-only bind
account of its own (isg_cfg_ldap_target_bind_dn — separate from the brokers'
and the IdP's, so each can be rotated on its own), home directories created at
first login, and one sudoers drop-in that names a directory group
(isg_target_directory_sudo_group). Who may escalate is therefore decided in
the directory, by group membership. When the role is enabled, the named local
accounts of the previous section are not used on that host: a local account with
the same name would shadow the directory one, and the role's verification stops
on such a collision. It also stops when one of the user's group IDs has no name
in the directory — typically the primary group (gidNumber) with no matching
posixGroup —, because id then fails on the target.
The two gates, in order. Every command line is decided by ISG first — the
policy rules, the approval requirements, the per-host level and human_access
— and ISG reads through sudo to the program it runs, so a rule that matches
reboot also matches sudo reboot. Only a command ISG let through reaches the
target, and only then does the target's sudo apply its own rules and ask for
the password. The target can narrow what ISG allowed; it cannot widen what ISG
refused. The effective right is the intersection of the two.
Where the target is the only control. ISG refuses interactive root shells
(sudo su, sudo -i, sudo -s, sudo bash) unconditionally. A program that
can start a shell of its own (sudo vim, then :!sh) is different: inside it,
ISG no longer sees the individual commands. Against that, the control is the
target's sudoers — NOEXEC, or not granting such programs at all. The session
recording still captures the output; preventing it is the target's job.
Network
One direction, one port.
| From | To | Port | When |
|---|---|---|---|
| Gateway | Target | TCP 22 | Every session, every probe |
Nothing on the target ever connects back to the gateway. There is no agent to install, no callback, and no second port.
One operational property of the probes is worth planning around: a single
--probe-hosts run spends 4 of each target's 9-connection PerSourcePenalties
budget, and the target scores that against the gateway's source address, not
against an operator. Two runs in quick succession is what trips a target's
lockout — and a lockout produces a false FAIL, which invites the retry that
trips it again. The gateway enforces a minimum interval between fleet-wide
probe runs for exactly this reason, and refuses the second one with the number
of seconds to wait. Do not retry to confirm a failure; wait it out.
That interval is enforced by each gateway on its own, so a target facing more than one of them sees the rate multiplied by however many there are. This is deliberate — the interval bounds load rather than protecting correctness, and coordinating it across gateways would put a shared write on the probe path — but it does mean the budget above is the one to size against the whole fleet, not against a single gateway.
Verification
The static half, which needs nothing but the gateway:
isg system check
It validates every configuration file and reports the readiness row for every
host in the inventory plus every host with an issued certificate — the
gateways' own front certificates excepted: a gateway is not a brokered target
unless the inventory lists it, and the same rule shapes the target set every
--probe-hosts probe below dials. Add --verbose to see the sub-checks behind
each row, and each row's caveat — what it did not measure.
The live half, which needs isg serve running:
isg system check --probe-hosts --probe-keyset --verbose
--probe-hosts refuses to run without the daemon, and says so, rather than
opening connections to production hosts that would appear in each target's
sshd log with no ISG audit entry to explain them. (--probe-without-audit
overrides that for an urgent measurement, and states in the output what it gave
up.) Every probe is audited separately: REACHABILITY_PROBE, BYPASS_PROBE,
HUMAN_CERT_PROBE, BREAK_GLASS_AUTH_METHOD_PROBE, and — for the keyset read,
which is a real login rather than a preauth close — BREAK_GLASS_KEYSET_READ.
The inventory's own view:
isg servers show
isg servers level <host>
What a green row does and does not prove
Ready to broker: <host> reading OK means: an access rule covers this host, a
live host certificate exists for it, the target accepted a certificate from
this CA, the emergency path is in place and its keyset is intact, the host
answers on TCP 22 with a trusted host key, and the lockdown level resolved.
It does not prove that sudo works for isg-breakglass once logged in, and it
does not prove any operator holds the private half of an accepted break-glass
key — the probe only ever offers a public key and observes the answer.
Confirming either needs a real break-glass login by a human holding a real key.
It also does not prove that a particular person may connect: readiness is a
host-level question, and whether a given principal is allowed is decided per
request by the access gates.
Common drift
Everything here is a state the checks actually name. The quoted text is what the check prints.
The host certificate was never installed, or sshd started without it. The
reachability check reports target offers a plain host key, not a certificate — sshd may have started without the host cert (fail-open); strict-mode connect would be rejected. This is the fail-open case: sshd came up, the port
answers, and a strict connect would be refused. It is caught by a plain
isg system check, without any probe flag.
The certificate expired. host cert expired on sub-check 2, and the host
becomes unreachable in strict mode. The host-cert row warns for 30 days before
that happens, so this state is always preceded by a warning somebody had time
to act on.
The certificate was revoked. host cert revoked. Revocation is
gateway-side: the target's configuration is untouched, and the serial is
consulted on every dial with no cache to go stale.
TrustedUserCAKeys was removed or points at the wrong file. Sub-check 3
reports certificate rejected (cert_rejected). This one is only visible under
--probe-hosts — nothing on the gateway can see the target's sshd config,
so it takes an actual offer to the target to find out.
The host is not covered by any access rule. no human_access rule names this host. A host can be reachable, certified and perfectly healthy, and still
be one nobody can connect to, because no allow rule mentions it. There is no
host wildcard to catch it — use a host_labels selector.
Two hosts share one host key. The host-cert row warns duplicate host key shared with <other host>. The normal cause is a golden image or a cloned VM
snapshot that shipped one /etc/ssh/ssh_host_*_key to every machine, and the
consequence is that either host can present the other's identity to the strict
check. Issuance refuses a second certificate over an already-certified key for
this reason; the fix is to regenerate the clone's host keys
(ssh-keygen -A) and re-issue.
The break-glass keyset drifted. The keyset row reports each finding rather
than stopping at the first: unexpected keyset mode/owner: … (want 0644 root:root), N of M key(s) missing the restrict option, no cert-authority line in the keyset — the break-glass CA is not trusted on this host, or —
the one that fails rather than warns — keyset trusts an unexpected break-glass CA. A host whose emergency door is not ours is not ready to broker.
The break-glass account is missing. isg-breakglass account does not exist on this host, or break-glass keyset is missing or unreadable (/etc/ssh/breakglass_authorized_keys).
The Match block is no longer in effect. break-glass Match block NOT in effect — no offered key was accepted by isg-breakglass: the keyset file was
read and every key in it was offered, and the target accepted none. Either the
Match block is not routing that account there, or sshd is not reading this
file for it.
For anything not on this list, Troubleshooting catalogues the messages by symptom.