18 KiB
Provisioning failure codes
When ctrld hits a terminal failure during provisioning, it reports the same stable code on three surfaces:
- Result file —
provision_result.jsonin the ctrld home directory (next to the persisted internalctrld.log). JSON withstage,code,exit_code,message, and for listener failures a boundeddetail.attemptslist of{addr, proto, os_error}. Written atomically, removed on the next successful provisioning. Never contains provision tokens, resolver/device IDs, or configuration contents. - Output line — one fixed-format line on the CLI output:
provisioning failed: stage=<stage> code=<CODE> (exit <N>). Installer or MDM wrappers can extract exactly this line (fixed charset:stage=[a-z]* code=[A-Z_]* (exit [0-9]*)) into their own logs without risking token leakage from other output. - Exit code — stage-scoped: input 20–29, bootstrap 30–39,
listener 40–49, service 50–59. This does not change other existing
exit-code contracts:
ctrld-client statusstill exits 0–3, and an invalid deactivation pin still exits 126.
A customer or administrator only needs to report the code (or the whole
output line). The table below is the maintained support mapping; it must
stay in sync with cmd/cli/provision_result.go and changes in the same MR.
Codes
For every code below, first run sudo ctrld-client diag (sudo ctrld-client diag --json
gives a paste-safe, machine-readable copy). Get that output before you do
anything else. The command needs no running service. Without root, the
last-provisioning-result and service-state sections report permission
denied, so run it with sudo. It repeats the same stage, code, and message
the customer already reported.
The Next action / evidence column lists what to look at next.
| Code | Stage | Exit | Failure scenario | Next action / evidence |
|---|---|---|---|---|
PROVISION_TOKEN_MALFORMED |
input | 21 | The --cd-org value is not a provisioning code: it is empty, too short, too long, or it contains whitespace or control characters. ctrld checks this before any network call. A missing org-v1- prefix is not this code. That case only logs a warning, because legacy codes can lack the prefix. |
Make sure that the provisioning code was copied in full, with no extra whitespace. The result file never echoes the value. |
CUSTOM_HOSTNAME_INVALID |
input | 22 | The --custom-hostname value fails ctrld's own hostname rule (validHostname): it is not 3 to 64 characters, or it is not in RFC1123 hostname format. ctrld checks this before any network call. The API itself does not reject a bad hostname during provisioning. When ControlD registers the device name, it folds or strips characters like space, +, and .. Because of this, a value ctrld accepts can still register under an adjusted name. That case only logs a notice, not a failure. |
Fix --custom-hostname following the message: it names the offending characters and the allowed format. |
INTERCEPT_MODE_INVALID |
input | 23 | The --intercept-mode value is not one of off, dns, or hard. ctrld checks this before it installs the service. |
Re-run with a valid --intercept-mode value. |
INVALID_FLAG_COMBINATION |
input | 24 | --cd or --cd-org is used together with --nextdns, or --proto is set to anything other than doh/doh3 while --cd is in play. ctrld checks this before any network call or service install. |
Re-run without the conflicting flag, or fix the invalid value. The message names the exact flags involved. |
API_UNREACHABLE |
bootstrap | 30 | The Control D API could not be reached or answered with a retryable error (network failure, proxy interference, 5xx, timeout) and retries ran out. The service manager may retry the service later. | Make sure that the device has network access to api.controld.com (DNS, proxy, firewall, captive portal). ctrld-client diag's api_reachability block already ran this same probe. Ask whether other TLS traffic works. |
API_REJECTED |
bootstrap | 31 | The API answered and permanently rejected the configuration (4xx other than 408/429): bad or revoked token, malformed request. ctrld exits without burning service-manager restarts because retrying cannot change the answer. This is also the fallback for the four TOKEN_* codes below. Those codes need the API response to carry error.metadata.reason. An API deployment that does not yet populate that field reports every provisioning-code rejection as API_REJECTED instead. |
Verify the provision token / org configuration in the Control D dashboard. Re-push after fixing credentials. Evidence: HTTP status in the result file message. |
API_DEVICE_INVALID |
bootstrap | 32 | The API reports the device/resolver no longer exists (error code 40402). When the daemon's own bootstrap preflight discovers this, it self-uninstalls, because the identity is gone server-side. The direct --cd <uid> install path (ctrld-client start --cd) can also hit this code before the service exists. On that path there is nothing to uninstall yet. |
Confirm the device was deleted or re-provisioned in the dashboard; re-provision with a current token. No local evidence needed beyond the code. |
TOKEN_INVALID |
bootstrap | 33 | The API rejected the --cd-org provisioning code with reason token_invalid: the code is not recognized. This code needs the deployed API to send error.metadata.reason: "token_invalid". See API_REJECTED above for the fallback when it does not. |
Make sure that the code is correct. Re-enter it exactly as given. |
TOKEN_EXPIRED |
bootstrap | 34 | The API rejected the --cd-org provisioning code with reason token_expired. This has the same API-reason dependency as TOKEN_INVALID. |
Get a new provisioning code from your administrator. |
TOKEN_LIMIT_REACHED |
bootstrap | 35 | The API rejected the --cd-org provisioning code with reason token_limit_reached: it reached its device limit. This has the same API-reason dependency as TOKEN_INVALID. |
Free up a device slot or use a different provisioning code. |
TOKEN_DISABLED |
bootstrap | 36 | The API rejected the --cd-org provisioning code with reason token_disabled: the code was invalidated. This has the same API-reason dependency as TOKEN_INVALID. |
Download a profile from an active provisioning code. |
LISTENER_BIND_FAILED |
listener | 41 | No listen address could be bound after all fallbacks (configured address, 0.0.0.0:53, localhost:53, random) were exhausted. detail.attempts records each tried address with the UDP/TCP OS error, e.g. address already in use (another DNS service owns the port) or can't assign requested address (address not on any interface). |
Read detail.attempts: address already in use → find the process owning the port (sudo lsof -i :53 -nP); can't assign requested address → the configured IP is not present on the device. Then fix the conflict or the listener config. |
LISTENER_CONFIGURED_ADDR_UNAVAILABLE |
listener | 42 | An explicitly configured listener address could not be bound and configuration checks forbid falling back to another address, or (macOS intercept mode) the required explicit address is unavailable. | The configured ip:port in the listener config is wrong for this device or occupied. Verify the address exists on an interface and nothing else binds it; correct the config rather than expecting fallback. |
SERVICE_INSTALL_FAILED |
service | 51 | The OS service manager refused to install the service (launchd/systemd/SCM registration failed). | Check OS-level constraints: permissions/elevation, MDM policy blocking daemon installation, corrupted previous install. Evidence: result file message (service manager error), plus launchctl print system/ctrld-client / systemctl status ctrld-client / SCM state. |
SERVICE_START_FAILED |
service | 52 | The service installed but the service manager could not start it. | Check the service manager's own log for the start error, then the ctrld home dir ctrld.log. Often permissions or a binary quarantined by security tooling. |
SERVICE_SELFCHECK_FAILED |
service | 53 | The service started but never became healthy: no fresher failure was reported by the daemon, and the post-install DNS self-check failed. On a fresh install or an upgrade, the just-installed service is rolled back (uninstalled). A restart of an already installed service keeps that service installed. If the daemon itself recorded a more specific failure (e.g. a listener code), that code is reported instead of this one. | Ask for the drained service log printed by ctrld-client start and the result file. If the service was running but unreachable, check host firewall rules intercepting DNS to the listener. |
UNCLASSIFIED |
service | 59 | This code covers terminal failures on the provisioning boundary that predate a dedicated code. Examples include a config unmarshal, and a file-system or environment failure such as writing the config file, reading socketDir, or respawning as a daemon. Other examples are a service-argument update failing mid-upgrade, and a DNS-intercept failure the interface-DNS fallback cannot safely take over from. Every one of these used to be a bare crash with nothing to read. Now they all persist a result file. |
Read the result file message: it names the specific operation that failed and the underlying OS error. When you report it, treat this the same as any other stage-scoped failure. |
The macOS installer script (scripts/pkg/postinstall) emits the package rows
below, not ctrld itself, before ctrld ever runs. They only ever exit 1 (the
script's own exit code), and they never appear in provision_result.json.
The same script also reuses PROVISION_TOKEN_MALFORMED and
CUSTOM_HOSTNAME_INVALID from the table above, for its own pre-flight
checks on the managed-prefs ProvisionToken/CustomHostname values. It
uses the same rule, and also logs it as stage=package (exit 1). So those
two codes can appear with either stage, depending on which side rejected
the value.
| PROFILE_PREFS_MISSING | package | 1 | The MDM configuration profile (managed prefs domain com.controld.ctrld) has no ProvisionToken, or the value is empty. This shows up after the postinstall script's wait loop ends: 12 attempts, 10 seconds apart, about 2 minutes. | Scope the com.controld.ctrld configuration profile to the device. If the profile is already on the device, set its ProvisionToken value. Then reinstall the package, or run sudo ctrld-client start --cd-org=<token> by hand. |
| INTERCEPT_MODE_INVALID | package | 1 | On a fresh install, the profile's InterceptMode is set, but it is not one of the managed-prefs values (empty, standard, intercept-dns). A fresh install fails visibly, instead of silently falling back, because a managed deployment must not provision into an unintended mode. On an upgrade, the same bad value only logs a warning and keeps the existing service mode. So a bad profile edit cannot break an already-working fleet. | Fix InterceptMode in the profile to one of the allowed values. Then reinstall the package. |
| SERVICE_RELOAD_FAILED | package | 1 | An upgrade reused the existing service mode (no managed InterceptMode override), and launchctl load on the installed plist failed. | Run sudo launchctl load /Library/LaunchDaemons/ctrld-client.plist by hand, and read the error it gives. |
| TEMP_FILE_UNAVAILABLE | package | 1 | The script failed to create the private temp file it uses to capture ctrld's output (/tmp or TMPDIR is full or unwritable). Because ctrld never runs, the script has no diagnostics to add. | Free space in /tmp or TMPDIR, or fix its permissions. Then reinstall the package. |
Customer-visible message shape
Every failure shows the same two-part shape: the fixed identifier line, then one free-text line specific to the code. Neither part ever contains the provision token, the resolver/device ID, or config contents.
- Input stage — names the offending flag or value and the rule it
broke. For example:
--cd-org provisioning code is malformed: must be 6-64 characters with no whitespace or control characters. ForCUSTOM_HOSTNAME_INVALID, the message names the specific offending characters and the allowed format. - Bootstrap,
TOKEN_*— one fixed sentence per code (see the table above). For example:the provisioning code has expired; get a new provisioning code from your administrator. - Bootstrap,
API_UNREACHABLE/API_REJECTED/API_DEVICE_INVALID— When the failure surfaces during a running install's periodic resolver refresh, the message isControlD API rejected this configuration (HTTP status <n>). When it surfaces during the initial provisioning call, the message isprovision token exchange failed: <err>orfailed to fetch resolver config: <err>. Neither ever includes the request or response body. - Listener — names the address or addresses tried.
detail.attemptsin the result file carries the OS error for each address and protocol. - Service — For
SERVICE_INSTALL_FAILEDorSERVICE_START_FAILED, the message is<task> failed: <err>(<task>isInstallorStart). When the daemon itself reported no fresher failure,SERVICE_SELFCHECK_FAILEDshows one of three messages, depending on what the self-check saw. When there is no error and the service is not running, the message isctrld service did not pass its post-start self-check. When the test query itself errored, the message isAn error occurred while performing test query: <err>. When the service was running but the query silently failed, the message isctrld service was running, but a DNS query could not be sent to its listener; check firewall rules blocking/intercepting/redirecting DNS queries. - Package — the plain-language second line the installer script logs right after the identifier line. Each package row's Next action cell above quotes this line verbatim, because the script has no separate result file to carry it.
Where each code surfaces
- Every ctrld-native code (every row above except
package): the CLI output / persistedctrld.log,provision_result.json, andctrld-client diag(which reads that same file). - Package-stage codes (
PROFILE_PREFS_MISSING,SERVICE_RELOAD_FAILED,TEMP_FILE_UNAVAILABLE, the package's ownINTERCEPT_MODE_INVALIDcheck on the managed-prefs value, and the package pre-flight's reusedPROVISION_TOKEN_MALFORMEDandCUSTOM_HOSTNAME_INVALID): only/var/log/install.log. ctrld never ran, so no result file exists yet.sudo ctrld-client diagrun right after this reports "none recorded" for the last provisioning result. That is still useful: it shows that the package script's own gate rejected the profile before ctrld had a chance to run. - Any ctrld-native code can also reach
/var/log/install.log. The postinstall script invokes ctrld in two cases: to provision on a fresh install, or to reapply a managed intercept mode on an upgrade. Either time, it greps ctrld's own identifier line out of the captured output. It re-logs that line, and appends(ctrld exit <n>). So a bootstrap or listener code, not only the three package-only codes, can appear in the MDM install log. Right after any installer-script failure, it also appends a freshsudo ctrld-client diagrun, each line prefixeddiag:.
Reading the result file
macOS and Linux (default service home is /etc/controld):
sudo cat /etc/controld/provision_result.json
On Windows the file sits next to ctrld-client.exe in the install directory. A
custom homedir config moves it accordingly; routers and mobile use their
platform home directory.
The file sits in the same directory as the persisted internal log
(ctrld.log) for the user the service runs as. On a healthy install the
file is absent.
Support: ctrld-client diag
Run sudo ctrld-client diag (or sudo ctrld-client diag --json) to collect the facts
support needs for a provisioning ticket, in one copy-paste-safe command. It
collects the client version and the MDM-managed preferences (macOS only,
the token reported only as present or absent, an empty value counts as
absent). It also collects the last provisioning result, the service state,
and whether the Control D API is reachable. It needs no config. It also
runs without root, but then the last-provisioning-result and the
service-state sections report permission denied, and the text report names
the step that gets the rights it needs. Even when it finds a failure, it
still exits 0: that failure is part of the report, not a command failure.
It never asks for or prints the provisioning token itself.
The provision_result.status field in the JSON report is one of none
(no result file), recorded, untrusted (a file that fails the contract
check), unreadable (permission denied), or corrupt (a file that could
not be parsed).
Rules for maintainers
- Codes are append-only once released. Never rename, renumber, or reuse a code or exit number; add a new one and note the deprecation here.
- API rejection reasons are append-only too:
tokenFailureCodeForReasonmaps a fixed set oferror.metadata.reasonstrings to theirTOKEN_*code. An unrecognized or absent reason falls back toAPI_REJECTED(seeapiFailureCode). So a new reason string does nothing until both a matching code and a row here exist. - Every code added in
cmd/cli/provision_result.goneeds a row here in the same MR. Tests enforce the code/stage/exit maps and that this table has exactly onestage != packagerow per ctrld code. Thepackagerows belong toscripts/pkg/postinstalland are not part of that count. - Detail must stay bounded and free of secrets: the constructor strips the provision token and cd UID and caps sizes; do not bypass it.