Troubleshooting

A catalogue of the messages this product actually prints, what each one means, and what to do about it. Every quoted block below is the text as the gateway emits it, with … marking the parts filled in at run time — so the message on your screen is searchable against this page.

The messages fall into four groups by when you see them: the daemon refusing to start, a session being refused, a isg system check row, or an operational symptom with no message of its own. This page is the platform and SSH; the pages a browser gets from Web Access are on troubleshooting Web Access.

The gateway will not start

isg serve refuses rather than starting in a state that looks correct and is not. Every refusal below names what is wrong; none of them has an override switch, because a control that can be silently disabled is not a control.

An unrecognised key in a configuration file

unknown configuration key(s) — refusing to start:
  …
remove or correct the key(s) above; `isg system check` reports them too

Cause. A typo, or a key from a different file. The check runs across every registered configuration file before anything else is opened.

Fix. Correct or delete the key named. isg system check reports the same finding without starting the daemon, so it is the faster loop. The configuration reference is the authoritative list of what each file accepts.

A non-loopback bind without authentication

non-loopback bind (0.0.0.0:8765) requires LDAP authentication — ldap.yaml must be configured; refusing to serve without auth

Cause. --bind names an address other than loopback and there is no ldap.yaml, so the admin view would be reachable from the network with no login at all.

Fix. Configure ldap.yaml, or bind loopback until you have. This is the first systemctl start most installations hit, because the packaged unit binds 0.0.0.0 — configure the directory before enabling the unit.

An unencrypted connection to the directory

load identity resolver for web auth: configure LDAP resolver from /var/lib/isg/ldap.yaml: ldap: refusing to use the unencrypted url "ldap://dir.example.com:389": with scheme ldap:// and no start_tls, every operator's bind password and the service account's own password cross the network in the clear. Fix it with ONE of: url: "ldaps://dir.example.com:636"; or keep this url and add start_tls: true (same port, TLS before the first bind); or, if you accept plaintext passwords on this network, say so explicitly with allow_plaintext: true

Cause. ldap.yaml names an ldap:// url and sets neither start_tls: true nor allow_plaintext: true. The admin view verifies an operator's password by binding as that operator, so the connection carries operator passwords as well as the service account's own — and the refusal is deliberate: before this check existed, such a deployment ran with every one of those passwords readable on the network and isg system check all green.

Fix. One of the three the message names. ldaps:// (port 636 by default) or start_tls: true (the same port 389, upgraded before the first bind) are the two real answers; allow_plaintext: true is the written-down decision to accept plaintext on this network, and it turns the ldap.yaml row amber for as long as it stands.

A contradictory TLS configuration for the directory

ldap: url "ldaps://dir.example.com:636" is already TLS, but start_tls: true is also set — these are two different ways to encrypt the same connection and cannot be combined …

Cause. Two keys in ldap.yaml that state different intentions. The same refusal covers allow_plaintext: true next to ldaps:// or start_tls, and a tls_ca_file on a connection that carries no TLS.

Fix. Delete the key that is not the intention. Nothing is silently resolved here on purpose: whichever key lost would be the one nobody notices.

The directory's certificate is not trusted, or does not match

[FAIL] LDAP bind    failed to connect (dir.example.com:636): ldap dial ldaps://dir.example.com:636: LDAP Result Code 200 "Network Error": tls: failed to verify certificate: x509: certificate signed by unknown authority

Cause. The handshake completed on the wire and failed verification. Three usual shapes, and the Go message distinguishes them in words: signed by an unknown authority (the issuing CA is neither in tls_ca_file nor in the host's trust store), expired or not yet valid (dated — isg system check's LDAP bind row prints the expiry, and warns from 30 days out), or valid for a different name (note that a url holding an IP address needs an IP SAN; a DNS-only certificate fails here).

Fix. Point tls_ca_file at the PEM that issued the directory's certificate, renew the certificate, or reissue it with the name the url actually uses. A failed verification is a connection error, never a wrong password: it does not touch the lockout counters in mfa.yaml, and no attempt falls back to an unencrypted connection.

A non-loopback bind without TLS

non-loopback bind (0.0.0.0:8765) requires TLS — run `isg ca init` first; refusing to serve plain HTTP on the network

Cause. The web UI's server certificate is derived from the X.509 CA, and the CA has not been initialised. Authentication and TLS are both required for a network bind; either one missing is a refusal.

Fix. isg ca init --permitted-dns <name> --permitted-ip <CIDR>. It is idempotent, and the SSH and X.509 halves are independent. The two flags are required for a NEW X.509 CA and become its nameConstraints: the names this CA may certify. With neither of them the command creates nothing and names both flags plus the deliberate way out, --no-name-constraints.

Another daemon owns the state root

refusing to take the state root lease /var/lib/isg/serve.lease: …

holder: host …, broker id …, pid …, isg …; acquired …, heartbeat …

two brokers writing one file-based state root silently lose each other's writes …

Cause. Exactly one isg serve may own a state root. Two would not corrupt each other's records — the second would silently discard the first one's sessions.

Fix. Read the holder block. If the holder is running on this machine, stop that process; the message says so explicitly and isg system lease release will refuse for the same reason. If the holder was on another machine or a previous boot and you have confirmed it is gone, clear it with isg system lease release — not rm, which defeats the guard instead of satisfying it and writes no audit entry. isg system lease status shows the holder without changing anything.

A record store that has not taken the migration

…/storage.yaml: records.backend is "postgres", but the aggregate document … is still here — that state root has never taken the per-record migration, and doing both in one start would mean guessing twice; run once with records.backend: "dir" first, or move it aside

Cause. Two migrations in one start — legacy aggregate to per-record files, and per-record files to PostgreSQL — would mean guessing twice about what is current.

Fix. Do them one at a time: start once with records.backend: dir, let that migration complete, then switch to postgres. Or move the aggregate document aside if you know it is stale.

Two record stores that both hold records

both the aggregate … and the per-record directory … exist; refusing to guess which is current — move one aside

Cause. Both layouts are present and non-empty. Picking one would silently discard the other.

Fix. Decide which is current and move the other aside. Nothing is deleted by the gateway.

A credentials file that is readable by more than its owner

…: records.postgres.dsn_file … is mode 0644 — it holds a database password that can read and rewrite every session record, so it must not be readable by group or other (chmod 0600)
…: recordings.s3.credentials_file … is mode 0644 — it holds an access key pair that can read every archived session recording, so it must not be readable by group or other (chmod 0600)
vault token_file … has mode 0644 — must not be group- or world-readable (chmod 0600)

Cause. These three secrets live in their own files, separate from the configuration that names them, precisely so they can carry a tighter mode. The mode is checked, not assumed, and a loose one refuses the start rather than warning: a warning about a group-readable credential is a line a running gateway scrolls past.

Fix. chmod 0600 the file named, and check its owner while you are there.

A configured back-end that does not answer

…: the record store postgres:… did not answer within …: …
…: the stream store's bucket "…" at … did not answer, so `isg serve` will not start with a stream store it cannot reach: …

Cause. Wrong host, wrong credentials, a database that is not listening, a firewall. Both back-ends are proved at startup on purpose — the alternative is discovering it on the first isg connect hours later, when the symptom is a session that will not start and nobody still connects it to a configuration change.

Fix. The wrapped error is the driver's own. Note that no error from the record store carries the password: it is redacted before the message is built, so a DSN problem shows as a connection failure, not a credential dump.

unknown mfa_mode "…" in …: want one of "email", "totp", "totp-with-email-fallback"
parse connect config …: unknown host_key_check "…" (must be "strict" or "insecure")
parse connect config …: human_auth: local_keys is no longer supported — the client-side connect path was removed; set human_auth: cert
parse connect config …: allow_password_auth: true is no longer supported — the client-side connect path that implemented the password fallback was removed (ADR 653328395, S3); remove the key or set it to false

Cause. There is no safe way to coerce an unknown value into a known one, and no permissive default to fall back to. The last two name a mechanism that was removed rather than quietly reinterpreting the file — a deployment still carrying those lines needs to notice.

Fix. Use one of the values the message lists. For human_auth, cert is the only legal value.

A CMDB mock file that was named but is not there

B398: cmdb.yaml names mock_file …, but that file does not exist — refusing to start. A static resolver with no mock file resolves EVERY host to an empty label set, so none of the … label_rules can ever match and every unpinned host falls back to default_level …, while the CLI still reports "CMDB: active" and "source: cmdb". …

Cause. cmdb.yaml sets resolver: static and names a mock_file, and the file is not at that path. Before this refusal existed the gateway came up anyway, with a resolver that answered every host with no labels at all — so no label_rules entry could match, every unpinned host silently took default_level, and isg servers show still reported CMDB: active. The lockdown levels the deployment was configured to enforce simply were not in effect, and nothing said so. Measured on a live broker on 2026-10-07, where it had survived a full environment build.

Fix. One of three, and which one depends on what you meant. Put the file where mock_file says it is — note that the mock is JSON, so a deployment pipeline that ships *.yaml configuration files will not carry it unless it is listed separately. Or point mock_file at the file that is already there. Or remove the mock_file key: that is the explicit way to ask for an empty static CMDB, and it is supported — the cmdb.yaml check row then reads amber and names the consequence rather than refusing the start.

A relative mock_file resolves against cmdb.yaml's own directory, not the daemon's working directory, so mock_file: cmdb_mock.json means the file next to cmdb.yaml in the state root. A value starting with ~ is refused outright: nothing in the daemon expands ~ — it runs as a system account under systemd, never through a shell — so such a path would be opened literally.

A self-contradictory access policy

human_access.requested_by_enforce is true but human_access.enforce is false — enable human_access.enforce first; otherwise a request no allow rule covers falls through to a no_matching_rule denial enforced by requested_by_enforce alone, silently reintroducing the access gate's own enforcement

Cause. Turning on the narrower switch without the broader one would enforce the access gate through a back door the operator explicitly left open.

Fix. Set human_access.enforce: true first, or turn requested_by_enforce back off.

A retention sweep interval below the floor

…: sweep.interval "…" (…) is below the … floor — one pass walks the state root and stats every closed session's artifacts, so its cost grows with the gateway's HISTORY rather than with how much there is to delete …

Cause. One sweep pass costs proportionally to how much history the gateway has, not to how much there is to delete. This is refused even with sweep.enabled: false, so a value that would break the gateway cannot sit on disk waiting to be switched on.

Fix. Raise the interval. Omit the key entirely for the default.

The audit log cannot be opened

open audit log …: …

Cause. A permission problem, a missing parent directory, or a full disk. The audit log is the record of what happened, so a gateway that cannot write it does not run.

Fix. The wrapped error names the reason. Check the ownership of the state root against the account the unit runs as.

A session or a command is refused

A command matched a policy rule

DENIED by policy: block_interactive_root_shell (interactive root shell is not allowed)

Cause. The strictest rule that matched decided. The rule's name and its reason are both in the message and both in the COMMAND_DENIED audit entry.

Fix. If the rule is one of the built-in denials, the command has to change — no policy.yaml can weaken them. If it is your own rule, isg policy show prints the whole effective rule set, each rule labelled by origin.

A rule matched part of the line, not the whole of it

DENIED by policy: block_passwd_changes (password changes must go through the IdP) — part 2 of 2: "passwd -S peter"

Cause. A line can run more than one command, and every one of them is evaluated. The part 2 of 2 tail names which one matched — here the second command on the line, not the id that preceded it. A tail that also says (behind wrappers) means the rule matched after a leading sudo or a command prefix such as env or timeout was stepped over.

Fix. Change that part, or put the commands on separate lines so each one gets its own decision. The audit entry still records the whole line as typed.

The line uses something the policy cannot evaluate

DENIED by policy: block_dynamic_shell_construct (command substitution ($(...)) cannot be evaluated by the policy before it runs — split it into separate lines)

Cause. The line contains a construct whose effect is only decided while it runs: a command substitution, a process substitution, a here-document, a function definition, eval, source, exec, alias, an interpreter given its program as a string (bash -c), or a command name that only exists after expansion. The default is to refuse these rather than guess at them.

Fix. Split the line into separate lines, each naming the program it runs. Where that is genuinely not possible, policy.yaml's dynamic_construct_action can be set to require_approval so a second person decides instead; allow runs such lines unevaluated and is a lab setting, not a posture to deploy. block_unparseable_line is the neighbouring case — the line is not a shell line at all — and that one is not configurable.

exit was refused in the middle of a line

DENIED by policy: block_exit_in_compound_line (exit/quit/logout ends the session only as a line of its own — run it on its own line) — part 2 of 2: "exit"

Cause. exit, quit and logout close the session only when they are the whole line. Inside id; exit the word is a command sent to the target, and it would do nothing to your session — so the line is refused instead of quietly doing the other thing.

Fix. Run the other commands, then exit on a line of its own.

Working out what a line will do before running it

isg policy check -- 'id; passwd -S peter'

Prints how the line decomposes, every candidate text the engine matches rules against, and the rule that decided. It runs nothing, contacts no daemon and writes nothing. Exit status is the answer: 0 allowed, 1 denied, 2 approval required. Use --file to evaluate against a policy file other than the gateway's own, and --level / --role to evaluate as a different host level or user role.

An approval request ran out of time

approval request APPR-2026-000042 expired without a decision

Cause. No approver decided within the rule's timeout_seconds (300 by default).

Fix. Re-issue the command. Check that approvers are actually being reached — notify.yaml configures the push, and a gateway with neither a webhook nor an outbox notifies nobody.

A session's audit log in the web UI: the approval request and its notification, then, at the same moment, COMMAND_APPROVAL_EXPIRED and COMMAND_DENIED with the reason (timeout)

Screenshot: reference environment (env1), 2026-10-17.

An approver said no

DENIED by …

Cause. A second person declined the request. This is a decision, not a fault.

Nobody could approve it

self-approval is not allowed: … requested this command and cannot decide it

Cause. The four-eyes rule. It holds unconditionally: the requester is never an eligible decider, regardless of role or group.

Fix. Somebody else in approver_group decides. If nobody else is in it, that is the finding.

The access gate refused the connection

access denied: you are not authorized to open a session on <host> as <user> (no_matching_rule) — if you should have this access, contact your ISG administrator

The reason in the parentheses is from a closed set, and each one means something different:

Reason Meaning
not_configured ca.yaml has no human_access section at all
no_matching_rule No allow rule covers this principal on this host
group_unverifiable A rule requires a group, but the groups came from users.json, not a directory — so the requirement cannot be checked
labels_stale A host_labels rule could have matched, but the CMDB labels are from a stale cache
labels_unavailable Same, with no usable labels at all

Fix. For no_matching_rule, add the host to a rule's hosts: list or give it a label an existing rule selects — there is no host wildcard. For group_unverifiable, configure ldap.yaml: the fallback file cannot make a group claim verifiable.

The requester was not allowed to act as that principal

access denied: you are not authorized to open a session on <host> as <user> (requester_mismatch) — if you should have this access, contact your ISG administrator

Cause. requester_mismatch means the matching rule has no requested_by list, and the default is self-only: you may connect as yourself, not as someone else. requester_not_listed means the rule has a list and you are not on it. requester_unknown means the caller's own identity could not be established at all, which is refused rather than guessed.

Fix. Name the caller in the rule's requested_by list. There is no wildcard there — impersonation is spelled out or it does not happen.

A per-host group requirement was not met

access denied: you are not authorized to open a session on <host> as <user> (missing_required_group) — if you should have this access, contact your ISG administrator

Cause. hostgroup.yaml requires directory group membership for hosts with these labels, and this user is not in the required group(s). The user's line names only the reason; the required groups are in the hostgroup.yaml rule for that label.

Fix. Add the membership in the directory, or change the requirement. With hostgroup.yaml's enforce off, the same situation is audited as a would-deny and does not block.

Multi-factor authentication did not complete

no MFA enrolled for "…"
MFA attempts exhausted
MFA challenge timed out waiting for a response
no email address for "…"
MFA is not configured on this daemon

Cause, in order. The user has never run isg mfa enroll and mfa.yaml sets a TOTP mode. Too many wrong codes — repeated failures also lock the user and the source address out for the periods mfa.yaml sets. No answer in time. No mail attribute on the directory entry, in e-mail mode. No multi-factor configuration at all.

Fix. For enrolment: isg mfa enroll, self-service, with isg serve running. For a locked-out user, the lockout expires on its own; isg mfa reset clears an enrolment and is itself gated by mfa_reset_group.

The web UI's e-mail code step after a wrong code: the error Invalid code. above the code field

Screenshot: reference environment (env1), 2026-10-17.

The identity could not be resolved

could not resolve identity for …: …

Cause. A directory lookup that failed for an infrastructure reason — not "no such user", which is survivable. The daemon denies the session rather than continuing with empty group memberships and hoping a rule lets it through.

Fix. isg system check reports the directory's own row; the LDAP bind check does a bind and a search and reports which of the two failed.

The target rejected the certificate

ssh: certificate authentication rejected by target

Cause. In certificate mode the gateway offers exactly one credential, so a public-key failure unambiguously means that credential was refused. The usual reason is that the target does not have TrustedUserCAKeys pointing at this CA. There is no password fallback, deliberately: a prompt here would swallow exactly the finding that matters.

Fix. Prepare the target host, then confirm with isg system check --probe-hosts.

The target's own identity was rejected

ssh: target host key/certificate rejected

Cause. The target presented a host key the strict check would not accept: signed by a different CA, revoked, expired, the wrong principal for the name being dialled, or a plain host key rather than a certificate.

Fix. isg system check names which. The most common is a certificate issued for a different name than the inventory dials — the principals must match the dial name exactly.

isg system check rows

no human_access rule names this host

No allow rule mentions this target, so nobody can connect to it regardless of who asks. Add it to a rule's hosts: list or to a host_labels selector.

host cert expired / host cert revoked / no host cert issued for this host

The gateway's own issuance ledger for this host. An expired certificate makes the host unreachable in strict mode; the row warns for 30 days beforehand. Re-issue with isg ca host-cert issue.

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

sshd came up without the host certificate. The port answers and the host looks healthy, and a strict connect would be refused. Check that HostCertificate is set and that the file it names exists, then restart sshd.

certificate rejected (cert_rejected)

The target refused a certificate this CA signed — TrustedUserCAKeys is missing, points elsewhere, or was removed. Only visible under --probe-hosts, because nothing on the gateway can read the target's sshd configuration.

duplicate host key shared with …

Two hosts hold the same SSH host key, normally from a golden image or a cloned snapshot, so either can present the other's identity. Regenerate the clone's host keys (ssh-keygen -A) and re-issue its certificate. Issuance refuses a second certificate over an already-certified key for this reason.

breakglass_group_required is true but no breakglass_group is set — every break-glass attempt fails as a config error

The fail-closed switch is on with nothing to check against. Set breakglass_group in ldap.yaml, or turn the switch off. Note that break-glass is denied to everyone when no group is configured, switch or no switch.

required but no group/directory (misconfig)

The same shape for one of the other gates: approver_group_required, mfa_reset_group_required or ca_admin_group_required is on, and either the group name or the identity source is missing.

ldap.yaml exists, but failed to build a resolver from it

The file is present and a directory connection was attempted and produced nothing. A ldap.yaml with no url is a legitimate shape — it then carries only the authorization gates' group names — and reads as "not configured" instead.

resolver: static without mock_file — every host is unlabelled; label_rules never match

cmdb.yaml selects the static CMDB resolver and names no mock_file, so the resolver has no data: every host resolves to an empty label set, no label_rules entry can match, and every unpinned host takes default_level. This is amber rather than red because it is a supported, deliberate shape — a deployment wiring up its rules before the CMDB export exists. It is the counterpart of the refusal above: naming a mock_file that is not there stops the daemon, leaving the key out does not.

A green row names the resolved path and what the file carried, so the number is checkable against what you expect:

[OK  ] cmdb.yaml                    resolver: static, 4 label rules, mock_file: /var/lib/isg/cmdb_mock.json, 10 hosts

The gateway login account or its group is missing

"isg-login" does not exist — run `isg system provision`

Cause. The account sshd's ForceCommand admits a human as, or the group the gateway socket is bound to, has not been created.

Fix. isg system provision, as root. It is idempotent and creates exactly those two things — it never writes /etc/pam.d/ or /etc/ssh/sshd_config.d/ and never restarts a service. The sibling messages group "isg-gateway" does not exist on this host and not a member of isg-gateway point at the same command.

… — this daemon cannot issue certificates until it can read the CA private key again; every human_auth: cert session will fail

The CA signing key is unreadable by the account the daemon runs as. Every session needs a freshly signed certificate, so this is total. Check the ownership of the CA directory against the unit's User=.

no scrape stamp at … / scrape stamp not refreshed for … — the daemon is probably not running

The diagnostic listener is enabled but the daemon's own stamp file is stale or absent. Read it as a statement about the daemon, not about your monitoring: the stamp is refreshed on a timer regardless of whether anything scrapes.

Measured locally as user …

The report's first line says the daemon was not asked, and why: the socket was not reachable (the daemon is down), permission was denied on it (this account is not in the isg-operators group — the next line says to add it, or run as isg), the daemon is too old for the request, or --local was given. Read the rest of the report as what THIS account can measure: the rows behind the record store's credential say not measurable from this account and read WARN rather than failing — or passing. When the first line reads Measured by the daemon (pid N, user isg) over …, the report is the daemon's own — the same one the /system page shows — and none of this applies.

--probe-hosts requires the daemon-mediated audit channel, which is unavailable

The probes open real connections to production hosts, which appear in each target's own sshd log; without the daemon there would be no ISG audit entry to explain them. Start isg serve, or pass --probe-without-audit and accept that trade explicitly.

--probe-hosts refused: another gateway-wide probe run happened too recently — wait …s before running again

Each run spends 4 of every target's 9-connection PerSourcePenalties budget, scored against the gateway's address rather than an operator's. Running again too soon trips the target's lockout, and the lockout produces a false FAIL that invites another retry. Wait out the interval; do not retry to confirm a failure.

Operational symptoms

The admin view is unreachable

failed to connect (…): …
healthz unreachable (https://…/healthz): …

The check dials the address and then asks /healthz over TLS. Work through three things in order: the daemon is running (systemctl status isg-ui.service and journalctl -u isg-ui.service); it bound the address you are dialling (--bind defaults to 127.0.0.1, so a remote browser needs it widened — and widening it requires authentication and TLS, see above); and the host firewall permits the port (8765 by default).

A daemon that starts and then exits immediately is almost always one of the startup refusals in the first section — the reason is in the journal.

Approvals are not reaching anybody

Notification is best-effort and never blocks a session, so a misconfigured notify.yaml produces silence rather than an error. Check that it names a webhook or an outbox; with neither, nothing is delivered and the request waits its full timeout.

An archived recording cannot be found

isg storage verify

It checks that every recording a session record calls archived is in the stream store, at the size it was archived at. --deep additionally reads each one back and compares its SHA-256 against the hash recorded at archival time. Run the plain form routinely; the deep form is a full download of every archived recording on an object store.

Audit files have drifted from their expected ownership

The Audit artifact isolation row reports each file family it measured, and names a drift as owned by a login account, mode 0644, or both. The audit log and the session artifacts belong to the daemon's account and are not group- or world-readable. This is a WARN rather than a FAIL: it is a fact about files already written, and nothing about it stops the gateway.

The audit log itself is never rewritten, never swept and never moved — it is append-only by contract. Its shape, mode and rotation behaviour are set out in the audit log schema, which is what a log shipper parses against.

Break-glass evidence is missing from a host

The per-host Break-glass command-audit chain row reports what arrived from a target's own forwarding. When it reads not measurable: …, the gateway process could not read the collector file — a path or permission problem on the gateway, not on the target.