Farrier

Configuration

Farrier is configured with plain files in its data directory and a few environment variables. There is no configuration in the database, so a deployment is reproducible from a directory and a secret.

Data directory

Set with --data or FARRIER_DATA. Defaults to ./farrier-data, or ./farrier-demo for farrier demo.

farrier-data/
├── farrier.db                     # SQLite: endpoints, observations, renewals, events
├── master.key                   # Fernet key sealing private keys (unless FARRIER_MASTER_KEY is set)
├── issuers.json                 # which CAs to use (optional)
├── settings.json                # planner tuning (optional)
├── acme-account-<hash>.key      # one ACME account key per directory URL
├── local-ca/                    # built-in private CA (ca.key, ca.pem)
└── packs/<renewal-id>/          # manual change packs, deleted once verified

Back up master.key

Without master.key (or FARRIER_MASTER_KEY), the sealed private keys in farrier.db can't be decrypted. Back up both together.

Issuers: issuers.json

Maps issuer names to CAs. Endpoints choose one with their issuer field. If an endpoint names an issuer that doesn't exist, default is used. Without the file, Farrier uses only the built-in private CA:

{ "default": { "type": "local_ca" } }

Any string value can be a secret reference (env:NAME or file:/path).

Let's Encrypt

{
  "default": { "type": "local_ca" },
  "le": {
    "type": "acme",
    "directory_url": "https://acme-v02.api.letsencrypt.org/directory",
    "email": "pki@example.com",
    "dns_hook": "/opt/farrier/hooks/route53.sh",
    "propagation_seconds": 60
  }
}

Commercial CA with External Account Binding

DigiCert, Sectigo, Entrust and others expose ACME with EAB credentials:

{
  "digicert": {
    "type": "acme",
    "directory_url": "https://acme.digicert.com/v2/acme/directory/",
    "eab_kid": "env:DIGICERT_EAB_KID",
    "eab_hmac_key": "env:DIGICERT_EAB_HMAC",
    "dns_hook": "/opt/farrier/hooks/infoblox.sh"
  }
}

acme options

KeyDefaultNotes
directory_urlLet's Encrypt stagingSet explicitly for production
emailnoneAccount contact
dns_hookrequiredShell command, see below
propagation_seconds60Wait after publishing TXT records
eab_kid, eab_hmac_keynoneExternal Account Binding
poll_interval2.0Seconds between order polls
timeout600Seconds before giving up on an order

Only the DNS-01 challenge is supported, by design: the device never needs to be reachable from the CA.

local_ca options

KeyDefaultNotes
nameFarrier Local CACA subject
lifetime_dayspublic maximum for todayOverride, e.g. 30 for a stress test
simulate_datenoneIssue as if on this date

The private CA follows the public lifetime schedule by default, so you can rehearse the 47-day world today. Use it for labs and internal-only names; clients must trust local-ca/ca.pem.

DNS-01 hook contract

dns_hook is run through the shell once per challenge, with these environment variables:

VariableValue
FARRIER_DNS_ACTIONpresent or cleanup
FARRIER_DNS_NAMEe.g. _acme-challenge.vpn.example.com
FARRIER_DNS_VALUEthe TXT value

present must add a TXT value, not replace the record: a certificate for example.com and *.example.com needs two values on the same name. cleanup removes only the given value. A non-zero exit fails the issuance attempt, which is retried on the next tick. Each call has a 300-second timeout.

Example for BIND or any RFC 2136 server (examples/dns-hook-nsupdate.sh):

#!/bin/sh
set -eu
SERVER="${NSUPDATE_SERVER:-ns1.example.com}"
KEY="${NSUPDATE_KEY:-/etc/farrier/tsig.key}"
case "$FARRIER_DNS_ACTION" in
  present) OP="update add $FARRIER_DNS_NAME. 60 IN TXT \"$FARRIER_DNS_VALUE\"" ;;
  cleanup) OP="update delete $FARRIER_DNS_NAME. IN TXT \"$FARRIER_DNS_VALUE\"" ;;
  *) echo "unknown action $FARRIER_DNS_ACTION" >&2; exit 2 ;;
esac
printf 'server %s\n%s\nsend\n' "$SERVER" "$OP" | nsupdate -k "$KEY"

Write equivalents for Route 53, Infoblox or Azure DNS with their CLIs.

Planner settings: settings.json

All keys are optional; missing keys use these defaults.

{
  "horizon_days": 14,
  "manual_daily_cap": 25,
  "auto_daily_cap": 1000,
  "verify_max_attempts": 6,
  "verify_retry_seconds": 2.0,
  "issue_max_attempts": 5,
  "safety_days": 2,
  "scan_timeout": 5.0,
  "crypto_scan_hours": 24,
  "ssh_scan_hours": 24,
  "ssh_collect_hours": 24,
  "export_key_retention_hours": 24
}
KeyMeaning
horizon_daysCreate renewals this many days ahead of their due date
manual_daily_capMax manual renewals scheduled per day; excess spills to the next window
auto_daily_capSame, for automated renewals
verify_max_attemptsTicks in verifying before an automated deploy is rolled back
verify_retry_secondsPause between the three handshakes within one verify attempt
issue_max_attemptsIssuance attempts before a renewal is marked failed
safety_daysA renewal's deadline is expiry minus this
scan_timeoutSeconds per TLS handshake during the tick scan
crypto_scan_hoursHours between TLS crypto / PQC re-probes of each reachable endpoint (default 24, 0 = off; PQC)
ssh_scan_hoursHours between unauthenticated SSH host-key scans (default 24, 0 = off; SSH)
ssh_collect_hoursHours between authorized_keys collections over SSH (default 24, 0 = off)
export_key_retention_hoursHours an exported private key (endpoint tag export-key) stays in the cloud secret backend before Farrier deletes it (default 24; Events)

See Concepts for how these interact.

Notifications

Add notifiers to settings.json to hear about renewals in Slack, Microsoft Teams or any webhook:

{
  "dashboard_url": "https://farrier.example.internal/dashboard",
  "notifiers": [
    { "type": "slack", "url": "env:FARRIER_SECRET_SLACK_WEBHOOK" },
    { "type": "teams", "url": "file:/run/secrets/farrier/teams-webhook", "events": ["failed", "rolled_back"] },
    { "type": "webhook", "url": "https://hooks.example.internal/farrier",
      "headers": { "Authorization": "env:FARRIER_SECRET_HOOK_TOKEN" },
      "events": ["done", "failed", "rolled_back", "awaiting_manual"] }
  ]
}
  • events defaults to failed, rolled_back and awaiting_manual (event-bus channels eventbridge, pubsub and eventgrid: issued, awaiting_manual, done, failed, rolled_back and cancelled; see Events). Others: planned, issued, verifying, done, superseded, cancelled.
  • webhook posts JSON: event, endpoint, host, port, connector, owner, renewal_id, error, ticket, new_not_after, at, dashboard_url. slack and teams post a one-paragraph text message.
  • URLs and header values go through the secret guardrails, are resolved at send time and are never logged. Delivery runs in the background with a 5 second timeout; a failure is written to the event log and never blocks a renewal.
  • Check the setup with POST /api/notifiers/test.

Inventory: farrier import

Declare endpoints and their connectors in a JSON array and import them. Importing is an upsert by name, and each connector config is validated first.

[
  {
    "name": "vpn-fgt",
    "host": "vpn.example.com",
    "port": 443,
    "issuer": "le",
    "connector": "fortigate",
    "connector_config": {
      "url": "https://fgt-mgmt.example.com",
      "api_token": "env:FGT_TOKEN",
      "bind": "sslvpn"
    },
    "window": { "days": ["sat"], "start": "02:00", "hours": 4 },
    "owner": "network"
  }
]
FieldDefaultNotes
namerequiredUnique
hostrequiredHostname or IP to connect to
port443
sninoneServer name to send; defaults to host when it is a hostname
common_namefrom the observed cert, else hostCN of the new certificate
sansfrom the observed certAdditional names
key_typeec256ec256, ec384, rsa2048, rsa3072, rsa4096
connectormanualSee Connectors
connector_config{}Connector-specific
issuerdefaultKey in issuers.json
window{} (any time)UTC change window
lead_days0Minimum days before expiry to start
owner, tagsnoneFor filtering and reporting
enabledtrueDisabled endpoints aren't scanned or renewed

examples/inventory.json has a complete example covering F5, FortiGate, PAN-OS, a Java keystore and a manual ASA with a ServiceNow ticket.

Environment variables

VariableUsed byPurpose
FARRIER_DATACLIData directory (overridden by --data)
FARRIER_API_TOKENfarrier serveAdmin bearer token: read and change. Mandatory for a non-loopback bind
FARRIER_API_READ_TOKENfarrier serveOptional read-only token: every GET, including /metrics; anything else gets 403
FARRIER_MASTER_KEYengineFernet key for sealing private keys; overrides master.key
FARRIER_ALLOW_API_COMMANDSAPI1 lets POST /api/endpoints accept test_command, reload_command, ssh and dns_hook. Off by default
FARRIER_SECRET_ENV_PREFIXconnectors, issuers, notifiersIf set, env: references must start with it (e.g. FARRIER_SECRET_)
FARRIER_SECRET_DIRSconnectors, issuers, notifiersIf set, file: references must sit under one of these directories (:-separated, ; on Windows)
FARRIER_DASHBOARD_URLnotifiersLink added to notifications (or dashboard_url in settings.json)
FARRIER_SECRET_BACKEND and FARRIER_SECRET_*engineWhere dashboard-entered secrets live: local (default), aws, gcp, azure, hashicorp; per-backend settings in Enterprise deployment
FARRIER_SECRET_EXTERNAL_PREFIXESconnectors, issuers, notifiersComma-separated prefixes aws-sm:, gcp-sm:, azure-kv:, hcvault: references must start with. Unset: only the configured backend's own location and prefix
FARRIER_MASTER_KEY_KMSengineFARRIER_MASTER_KEY is wrapped; unwrap it at boot with this key (aws-kms:, gcp-kms:, azure-kv:)
FARRIER_STORAGE_URL and FARRIER_STORAGE_*engineObject store for change packs, chains, backups and audit exports (default: objects/ in the data directory); see Enterprise deployment
FARRIER_INSTANCE_IDeventsCloudEvent source becomes farrier://<id> (A-Z a-z 0-9 . _ ~ -). Default: farrier- plus a hash of the master key, the same on every replica
FARRIER_TRUSTED_PROXY_HOPSwebProxies in front of the dashboard that append to X-Forwarded-For; the client address is that many entries from the right (default 1, 0 = ignore the header). See Security
FARRIER_DOCS_URLwebWhere the dashboard's documentation links go, read at runtime, e.g. your own copy of these docs. Default: the public docs site the image was built for, else the docs' source on GitHub
any NAME referenced as env:NAMEconnectors, issuers, notifiersSecrets. FARRIER_MASTER_KEY and the API tokens can never be referenced

The web dashboard has its own variables; see Self-hosting.

CLI

Global option: --data PATH.

CommandWhat it does
farrier serve [--host 127.0.0.1] [--port 8740] [--tick 300]REST API (OpenAPI docs at /docs) and background renewal loop
farrier demo [--port 8740] [--tick 20] [--simulate-date YYYY-MM-DD]Simulated estate on localhost
farrier scan TARGETS… [--register] [--timeout 3] [-v]Report what certificates targets serve; --register adds unknown ones as manual endpoints
farrier import FILEUpsert endpoints from a JSON inventory
farrier tickRun one scan/plan/advance cycle and print the result
farrier forecast [--certs 1000] [--manual 0.4] [--minutes 45] [--lead 0]Renewal workload under the CA/B Forum schedule
farrier backup --to URLExport the database, HMAC-signed with a key derived from the master key, to backups/ under an object-storage URL
farrier restore --from URL [--to DB] [--allow-unsigned]Load a backup into an empty database. Refuses a missing or wrong signature; --allow-unsigned only for trusted backups made before signing

Target syntax

Used by farrier scan and the Discovery screen. Items are separated by spaces or commas.

FormExample
host (port 443)vpn.example.com
host:portjira.example.com:8443
CIDR with several ports (separated by a pipe)10.20.0.0/22:443|8443|4443
connect to an IP, send an SNIwww.example.com@10.20.1.10:443

Hostnames, ports and ranges are validated, and malformed targets are rejected rather than scanned. A single range may not exceed 65,536 addresses, and the API accepts at most 4,096 targets per request.