Skip to main content
Version: 0.18

Agent configuration reference

Where the config lives​

The agent reads config.json from its state directory:

  • Linux installer: /opt/tokentimer-agent/state/config.json (mode 0600, owned by tokentimer-agent)
  • Windows installer: C:\ProgramData\TokenTimerAgent\state\config.json (ACL-restricted; see Install an agent (Windows))

The directory is resolved in this order:

  1. an explicit path passed to the agent,
  2. the TOKENTIMER_AGENT_CONFIG_DIR environment variable,
  3. the OS default.

Restart the agent after editing: sudo systemctl restart tokentimer-agent on Linux, or Restart-Service TokenTimerAgent on Windows.

Invalid config fails loudly

Every field is validated at load time and a malformed value aborts startup with a descriptive error naming the field. This is deliberate: a typo in an allowlist must never be silently ignored, because silently ignoring it is what would permit unsafe work.

For a complete first issuance, follow the Cloudflare worked example. The fields below are a reference; ACME challenge selection explains which workflow to use.

Connection​

FieldTypeDefaultDescription
serverUrlstringrequiredControl-plane base URL. Must be https: unless allowInsecureLocalHttp is set and the host is loopback.
agentIdstring | nullnullSet by the agent after registration. Do not hand-edit it, but keep it when you replace the file: a stored credential without an agentId stops the service.
protocolVersionstringcurrentAgent protocol version advertised at registration.
heartbeatIntervalMsinteger30000Heartbeat frequency. Also how quickly the agent notices retirement and signing-key rotation.
pollIntervalMsinteger15000How often the agent polls for claimable jobs.
caBundlePathstring | nullnullPEM CA bundle used to trust the control plane, for a private CA or a TLS-inspecting egress proxy. Overridden by TOKENTIMER_AGENT_CA_BUNDLE.
allowInsecureLocalHttpbooleanfalsePermits plain http: for loopback hosts only. Development use.

Registration and job dispatch succeed only when CERTOPS_ENABLED is true on both the API and the worker. See Enable CertOps.

Capability declaration​

These tell the control plane what this agent is willing and able to do, and are what job routing matches against:

FieldTypeDefaultDescription
declaredTargetSelectorsstring[][]Targets (hosts, domains) this agent handles.
declaredCommandProfileNamesstring[][]Command profiles this agent offers, for example a certbot or acme.sh profile.

Declaring a capability does not grant it. The local policy block still has to allow the concrete command, path, CA endpoint, and DNS zone.

Selectors and command profiles update on heartbeat

declaredCommandProfileNames is sent at registration and again on every heartbeat by a 0.18.0 or later agent, so adding or removing a profile and restarting the agent updates the control plane. An older agent sends it only at registration and needs a new registration for a new profile.

declaredTargetSelectors is sent again on every heartbeat when the list is not empty. Add a DNS name, restart the agent, and the next heartbeat replaces the stored list. An empty list does not clear the stored names. An older agent that does not send this field on heartbeat still publishes selectors only at registration. The server must accept the field before that newer agent is deployed. A heartbeat the server does not understand fails instead of being ignored.

declaredCapabilities is re-advertised on every heartbeat so an in-place agent upgrade can pick up new capabilities without re-enrollment. That includes build-level capabilities such as windows-cert-store-v1 and trust-anchor-deploy-v1. See Agents and the execution plane.

Policy (default-deny)​

The policy block is the agent's own authorization boundary. It always wins over control-plane intent: a job that exceeds it is rejected locally, and the rejection is reported back as evidence.

FieldTypeDescription
policy.allowedCommandsobjectMap of profile name to { argv: [...] }. argv is the allowlisted executable plus any fixed leading flags you want to require, not the whole command line: for the built-in certbot/acme.sh CSR flows the agent appends the subcommand and its own flags (certonly/--signcsr, --csr <path>, the DNS hook flags, --server <caEndpoint>, output paths) itself, so repeating them here duplicates them and the tool fails to parse.
policy.allowedPathsstring[]Absolute paths the agent may write certificates into.
policy.allowedCaEndpointsstring[]ACME CA endpoints the agent may talk to, including internal PKI.
policy.allowedDnsZonesstring[]DNS zones the agent may publish challenge records in.
policy.allowedDnsProvidersstring[]DNS provider ids, exact-match. See DNS-01 providers.
policy.allowedVerifyHostsstring[]Hosts/IPs the agent may open a TLS probe to for the post-reload verification step, beyond the job's own certificate target (which needs no entry here).
{
"policy": {
"allowedCommands": {
"certbot-csr": { "argv": ["/usr/bin/certbot", "--agree-tos", "--email", "ops@example.com"] }
},
"allowedPaths": ["/etc/nginx/certs", "/opt/tokentimer-agent/state/keys"],
"allowedCaEndpoints": ["https://acme-v02.api.letsencrypt.org/directory"],
"allowedDnsZones": ["example.com"],
"allowedDnsProviders": ["cloudflare"]
}
}

The argv template is only the allowlisted executable (and any fixed leading flags you want to require). For the built-in certbot/acme.sh CSR adapters, the agent itself appends certonly/--signcsr, --csr <path>, the DNS hook flags, the CA endpoint, and the cert output path. Do not repeat those in argv, doing so duplicates the flags and certbot/acme.sh will fail to parse the command line.

Trust-anchor operations need their own two profiles

distribute-trust/revoke-trust jobs (see Trust anchors) run the OS's own CA-trust refresh command on Debian/RHEL hosts, gated by two dedicated profile names distinct from the CSR profiles above: trust-store:update-ca-certificates and trust-store:update-ca-trust. Windows has no such gate (certutil argv is built entirely from validated agent-local inputs, never from policy). A missing profile for the agent's own platform fails the job locally, before anything is written to the trust store.

{
"policy": {
"allowedCommands": {
"trust-store:update-ca-certificates": { "argv": ["/usr/sbin/update-ca-certificates"] }
}
}
}
acme.sh must not live under a home directory

The hardened unit runs with ProtectHome=true, which makes /home, /root, and /run/user invisible to the process. acme.sh's own installer defaults to ~/.acme.sh, which the agent then cannot execute at all. Install the acme.sh script itself somewhere outside any home directory (for example /opt/acme.sh) and point policy.allowedCommands.acmesh-signcsr.argv at that path. This only affects the executable's own install location; the agent already manages acme.sh's separate --home/--config-home working state under its own state directory regardless of where the executable lives. See the acme.sh setup steps for a full walkthrough.

Key export cannot be enabled

Requests to export private key material are rejected unconditionally. There is no policy field, config flag, or control-plane setting that permits it. That is the zero-custody invariant, not a default you can relax.

policy.allowedPaths and the systemd ReadWritePaths list are two independent gates, and a deploy needs both. The installer's --write-path manages the systemd side; this field manages the agent side. Deploys read the private key from execution.keysDir, so that folder must also be allowed (on the Linux installer layout /opt/tokentimer-agent/state/keys). With only the certificate folder allowed, the deploy is rejected.

Verifying the certificate you just renewed needs no extra config

policy.allowedVerifyHosts only matters when verifyHost in the job payload points somewhere other than the certificate's own common name (a load balancer VIP, an internal alias, localhost for a manual check). Loopback and link-local/metadata-endpoint destinations are treated specially: loopback is allowed only with an explicit entry, and metadata-endpoint-class destinations can never be probed regardless of what is allowlisted. See Troubleshoot local policy and verification failures if a job is rejected here.

Execution​

FieldTypeDefaultDescription
execution.enabledbooleanfalseMaster switch for claiming jobs, including dry-runs. While false, the agent still registers, heartbeats, and reports configured discovery, but never polls for jobs.
execution.dryRunbooleantrueRefuses real mutating jobs; read-only AD CS preflight is handled separately. Planning requires a signed job with mode: "dry_run" and execution.enabled: true; this setting does not convert real jobs into plans.
execution.keysDirstring<configDir>/keysWhere locally generated private keys are written, mode 0600. Agent-local keys stay on that host; explicitly configured Vault publication sends the bundle to your own Vault, not to TokenTimer.
execution.replayStorePathstring<configDir>/replay-store.jsonPersisted nonce cache that makes replayed jobs unexecutable across restarts.
execution.outboxDirstring<configDir>/outboxDurable queue for results that could not be delivered yet.
execution.clockDriftToleranceMsinteger30000Accepted skew when validating a signed job's validity window.
Both switches must be flipped to execute

A fresh install has enabled: false and dryRun: true. Prepare local policy before enabling job claims. For planning, set enabled: true, keep dryRun: true, restart, and request a signed job with mode: "dry_run". Check its plan and dry_run_complete result. A real mutating job is refused while dryRun is true; the AD CS preflight is a separate real read-only check. Real mutating execution requires enabled: true and dryRun: false; changing local settings cannot override the signed job mode or local policy.

Trust pinning​

FieldTypeDefaultDescription
pinnedSigningKeyobject | nullnull{ signingKeyId, publicKeyPem } of the control-plane job-signing key the agent trusts.

The agent pins the signing key on first contact and then refuses jobs signed by anything else. It is written by the agent, not by you. The agent re-pins automatically after learning about the new key on heartbeat. See Rotate the job-signing key.

Discovery​

FieldTypeDefaultDescription
discovery.directoriesstring[]noneDirectories scanned for existing certificates to report into inventory.
discovery.intervalMsinteger3600000Scan interval.
windowsDiscovery.enabledbooleantrueWindows only. Reports the certificates in the Windows certificate stores to inventory. It does nothing on other platforms. Omit the block to use the defaults.
windowsDiscovery.intervalMsinteger1800000Scan interval (30 minutes).
windowsDiscovery.storesstring[]["My"]Certificate stores to scan, for example My for the local computer's personal store.

Discovery is read-only: it reports what it finds and never modifies or moves certificates. /etc/ssl/certs on Ubuntu is mostly symlinks, which the agent skips.

DNS and ACME​

FieldTypeDefaultDescription
dnsProvidersobject | nullnullMap of provider id to { credentialsFile } plus the reserved zoneProviderMap.
dnsPropagationobjectsee Propagation tuningPropagation wait behaviour after publishing and cleanup.
acmeAccountsobject | nullnullMap of account reference to { credentialsFile }.

Credentials are always referenced by absolute path to a 0600 file, never inlined into config.json. The agent refuses a credentials file that is group- or other-readable on POSIX hosts.

Full per-provider fields, zone routing, and propagation tuning: DNS-01 providers.

Minimal working example​

{
"serverUrl": "https://tokentimer.example.com",
"declaredTargetSelectors": ["web01.example.com"],
"declaredCommandProfileNames": ["certbot-csr"],
"execution": {
"enabled": true,
"dryRun": false
},
"policy": {
"allowedCommands": {
"certbot-csr": { "argv": ["/usr/bin/certbot", "--agree-tos", "--email", "ops@example.com"] }
},
"allowedPaths": ["/etc/nginx/certs", "/opt/tokentimer-agent/state/keys"],
"allowedCaEndpoints": ["https://acme-v02.api.letsencrypt.org/directory"],
"allowedDnsZones": ["example.com"],
"allowedDnsProviders": ["cloudflare"]
},
"dnsProviders": {
"cloudflare": { "credentialsFile": "/etc/tokentimer-agent/cloudflare.json" }
}
}