Cheti means "certificate" in Swahili.
ACME DNS-01 challenge library for Rust, with pluggable DNS providers.
- DNS-01 challenges only — works for wildcards and for domains behind a firewall, no HTTP-01 server needed
- Built-in providers: Bunny DNS, Cloudflare, deSEC, DigitalOcean, Gandi, Google Cloud DNS, Hetzner, Infomaniak, OVH, Porkbun, PowerDNS (HTTP API), Route53, Scaleway, and RFC 2136 (TSIG-signed dynamic updates) for self-hosted servers such as BIND, Knot DNS or PowerDNS
- Bring-your-own provider: implement the
DnsProvidertrait for anything else - Persisted ACME accounts via
AccountStoreso you don't burn through your CA's account-creation rate limit - Renewal helper that reads a leaf certificate's expiry and tells you when to re-issue
- Concurrent-safe per-FQDN, so two parallel issuances on the same record can't clobber each other
- End-to-end tested against Pebble, the official ACME test server
Pre-1.0: minor releases (0.x) may change the API; see the changelog.
Add to Cargo.toml:
[dependencies]
cheti = "0.1"
instant-acme = "0.8"
tokio = { version = "1", features = ["full"] }Issue a certificate for example.com against Let's Encrypt staging:
use cheti::{Dns01Solver, GandiConfig, GandiProvider, FileAccountStore, AccountStore};
use instant_acme::{Account, Identifier, NewAccount, NewOrder};
const LE_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Load (or create + persist) an ACME account.
let store = FileAccountStore::new("/var/lib/cheti/account.json");
let account = match store.load()? {
Some(creds) => Account::builder()?.from_credentials(creds).await?,
None => {
let (account, creds) = Account::builder()?
.create(
&NewAccount {
contact: &["mailto:admin@example.com"],
terms_of_service_agreed: true,
only_return_existing: false,
},
LE_STAGING.to_string(),
None,
)
.await?;
store.save(&creds)?;
account
}
};
// 2. Place an order for the identifiers you want.
let identifiers = vec![Identifier::Dns("example.com".to_string())];
let order = account.new_order(&NewOrder::new(&identifiers)).await?;
// 3. Wire up your DNS provider.
let provider = GandiProvider::new(GandiConfig::new(std::env::var("GANDI_TOKEN")?))?;
// 4. Solve the DNS-01 challenge and grab the certificate.
let solver = Dns01Solver::new(provider);
let (cert_pem, key_pem) = solver.solve_and_finalize(order).await?;
std::fs::write("/etc/ssl/example.com.crt", cert_pem)?;
std::fs::write("/etc/ssl/example.com.key", key_pem)?;
Ok(())
}A runnable version, using Cloudflare, is in examples/issue_certificate.rs.
Each provider has the same shape: a *Config builder, then a *Provider constructed from it.
HTTP providers retry a throttled request (429), and an idempotent request the server reports as unavailable (503), up to 3 attempts in total. They honour Retry-After up to 10s; a server asking for a longer wait gets no retry, and its error is returned with the delay. Route53, which reports throttling as a 400 with a Throttling or PriorRequestNotComplete code, retries those the same way.
Bunny DNS, DigitalOcean, Google Cloud DNS, Hetzner, Infomaniak, Porkbun, PowerDNS and Route53 are tested against mocks of their documented APIs but have not yet been exercised against the live services. Reports from real accounts are welcome.
Account API key from the bunny.net dashboard (Account settings → API), sent in the AccessKey header. The zone is resolved through the API: the account zone whose domain is the longest suffix of the FQDN wins, so delegated sub-zones are supported and no SOA lookup is needed. with_zone selects a zone by its domain name instead. Each challenge value is stored as its own TXT record (TTL 60s), so a wildcard and an apex challenge on the same name don't interfere, and cleanup only deletes the record holding its own value.
use cheti::{BunnyConfig, BunnyProvider};
let config = BunnyConfig::new(std::env::var("BUNNY_API_KEY").unwrap());
let provider = BunnyProvider::new(config).unwrap();BunnyProvider::from_env() reads the key from BUNNY_API_KEY.
Bearer token with Zone:DNS:Edit scope. Create it at https://dash.cloudflare.com/profile/api-tokens.
use cheti::{CloudflareConfig, CloudflareProvider};
let config = CloudflareConfig::new(std::env::var("CLOUDFLARE_API_TOKEN").unwrap());
let provider = CloudflareProvider::new(config).unwrap();If you already know the Cloudflare zone id (saves one API call):
use cheti::CloudflareConfig;
let config = CloudflareConfig::new("token")
.with_zone_id("example.com", "abc123zoneid").unwrap();API token from https://desec.io/tokens. The zone is resolved through the deSEC API (owns_qname), so no SOA lookup is needed. Records are written with a TTL of 3600s (or the domain's minimum TTL if higher), as deSEC rejects lower values for most accounts. deSEC rate-limits RRset writes per domain; a throttled request fails with an error carrying the Retry-After delay.
use cheti::{DesecConfig, DesecProvider};
let config = DesecConfig::new(std::env::var("DESEC_TOKEN").unwrap());
let provider = DesecProvider::new(config).unwrap();Personal access token from https://cloud.digitalocean.com/account/api/tokens, with read and write access to domains. Each challenge value is stored as its own TXT record (TTL 30s, the minimum DigitalOcean accepts), so a wildcard and an apex challenge on the same name don't interfere, and cleanup only deletes the record holding its own value. The zone must be a domain managed in the DigitalOcean account.
use cheti::{DigitalOceanConfig, DigitalOceanProvider};
let config = DigitalOceanConfig::new(std::env::var("DIGITALOCEAN_TOKEN").unwrap());
let provider = DigitalOceanProvider::new(config).unwrap();DigitalOceanProvider::from_env() reads the token from DIGITALOCEAN_TOKEN.
Personal access token from https://account.gandi.net/en/users/_/security. Needs DNS scope on the relevant domains.
use cheti::{GandiConfig, GandiProvider};
let config = GandiConfig::new(std::env::var("GANDIV5_PERSONAL_ACCESS_TOKEN").unwrap());
let provider = GandiProvider::new(config).unwrap();JSON key of a service account with the roles/dns.admin role on the project that holds the zone, or a narrower custom role granting dns.managedZones.list, dns.resourceRecordSets.list, dns.resourceRecordSets.create, dns.resourceRecordSets.update, dns.resourceRecordSets.delete, dns.changes.create and dns.changes.get. Create the key in the Google Cloud console under Keys → Add key. The provider signs a JWT with the key and exchanges it for an OAuth2 access token, which is cached until shortly before it expires. The zone is resolved through the API (the public managed zone with the longest matching DNS name; private zones are skipped), so no SOA lookup is needed. The challenge value is merged into the existing TXT RRSet (a new one gets a TTL of 60s), and cleanup removes only its own value. Changes are applied atomically, and the provider waits for them (up to 60s) before returning; a change racing with another writer fails with a conflict error.
use cheti::{GoogleCloudDnsConfig, GoogleCloudDnsProvider};
let config = GoogleCloudDnsConfig::from_service_account_file("/path/to/service-account.json")
.unwrap();
let provider = GoogleCloudDnsProvider::new(config).unwrap();GoogleCloudDnsProvider::from_env() reads the key file named by GOOGLE_APPLICATION_CREDENTIALS. The project defaults to the key's project_id; use with_project to manage zones of another project, and with_managed_zone to skip the zone lookup:
use cheti::GoogleCloudDnsConfig;
let json = std::fs::read_to_string("/path/to/service-account.json").unwrap();
let config = GoogleCloudDnsConfig::from_service_account_json(&json)
.unwrap()
.with_project("dns-project")
.unwrap()
.with_managed_zone("example-com")
.unwrap();API token of the Hetzner Console project that holds the zone, with read & write permission. Create it in the Hetzner Console under Security → API Tokens. Records are managed through the zones API of the Hetzner Cloud API; the legacy DNS Console API (dns.hetzner.com) is not supported, as it has been shut down. The zone is resolved through the API, so no SOA lookup is needed. A new TXT RRSet is created with a TTL of 60s; an existing one keeps its TTL. Record changes are asynchronous actions, which the provider waits for (up to 60s) before returning.
use cheti::{HetznerConfig, HetznerProvider};
let config = HetznerConfig::new(std::env::var("HETZNER_API_TOKEN").unwrap());
let provider = HetznerProvider::new(config).unwrap();API token created in the Infomaniak Manager (profile, API tokens) with the dns:read and dns:write scopes. Uses the v2 zone API (/2/zones/{zone}/records). The zone is resolved through the API by trying each suffix of the FQDN (longest first) against /2/zones/{zone}, so delegated sub-zones are supported and no SOA lookup is needed. Each TXT value is its own record, written with a TTL of 300s.
use cheti::{InfomaniakConfig, InfomaniakProvider};
let config = InfomaniakConfig::new(std::env::var("INFOMANIAK_ACCESS_TOKEN").unwrap());
let provider = InfomaniakProvider::new(config).unwrap();Three credentials: application key, application secret, consumer key. Create them at https://api.ovh.com/createToken/.
use cheti::{OvhConfig, OvhProvider};
let config = OvhConfig::new(
std::env::var("OVH_APPLICATION_KEY").unwrap(),
std::env::var("OVH_APPLICATION_SECRET").unwrap(),
std::env::var("OVH_CONSUMER_KEY").unwrap(),
);
let provider = OvhProvider::new(config).unwrap();API key and secret API key from https://porkbun.com/account/api. API access must also be turned on for each domain (domain management page, "API Access"); otherwise every call fails with an authentication error naming the domain. Porkbun has no endpoint telling which domain owns a name, so the zone is found with the SOA lookup unless given through with_zone. Each challenge value is its own TXT record, written with a TTL of 600s (the Porkbun minimum), so a wildcard and its apex can be validated at the same time. Porkbun accepts writes even for a domain delegated to other nameservers; present fails (and removes the record) when Porkbun reports that the record will not resolve.
use cheti::{PorkbunConfig, PorkbunProvider};
let config = PorkbunConfig::new(
std::env::var("PORKBUN_API_KEY").unwrap(),
std::env::var("PORKBUN_SECRET_API_KEY").unwrap(),
);
let provider = PorkbunProvider::new(config).unwrap();PorkbunProvider::from_env() reads the same two variables.
API key of a self-hosted PowerDNS Authoritative server (4.6.2 or later), as set by api-key in pdns.conf (with api=yes and the webserver enabled). Records are managed through the HTTP API under /api/v1. This is the HTTP alternative to the RFC 2136 provider below, which also works with PowerDNS when dynamic updates are enabled. Since the API key travels with every request, the base URL must use https:// (plain http:// is only accepted for localhost). The zone is resolved through the API by trying each suffix of the FQDN (longest first), so delegated sub-zones are supported and no SOA lookup is needed. Each challenge value is added to the TXT RRSet alongside the existing ones, so a wildcard and its apex can be validated at the same time; cleanup removes only its own value and deletes the RRSet once it is empty. A new RRSet gets a TTL of 60s; an existing one keeps its TTL.
use cheti::{PowerDnsConfig, PowerDnsProvider};
let config = PowerDnsConfig::new(
"https://pdns.example.com",
std::env::var("POWERDNS_API_KEY").unwrap(),
)
.unwrap();
let provider = PowerDnsProvider::new(config).unwrap();PowerDnsProvider::from_env() reads POWERDNS_API_URL, POWERDNS_API_KEY and the optional POWERDNS_SERVER_ID (default localhost, also settable with with_server_id).
Sends TSIG-signed dynamic updates (RFC 2136) over TCP to the zone's primary server. Supported TSIG algorithms are hmac-sha256 (default), hmac-sha384 and hmac-sha512; hmac-sha1 and hmac-md5 are not. Each present adds a single TXT record and each cleanup deletes only that record, so other values on the same name (e.g. wildcard + apex issuance) are left alone.
use cheti::{Rfc2136Config, Rfc2136Provider, TsigAlgorithm};
let config = Rfc2136Config::new(
"ns1.example.com:53", // host, ip, host:port or [ipv6]:port; port defaults to 53
"acme-update", // TSIG key name, as declared on the server
std::env::var("RFC2136_TSIG_SECRET").unwrap(), // base64 secret
)
.with_algorithm(TsigAlgorithm::HmacSha512);
let provider = Rfc2136Provider::new(config).unwrap();Rfc2136Provider::from_env() reads RFC2136_NAMESERVER, RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET and the optional RFC2136_TSIG_ALGORITHM. Records are written with a 60s TTL (with_ttl to change it), and each update exchange times out after 10s (with_timeout). The zone is found with an SOA lookup through the system resolvers; for internal or split-horizon zones, set it with with_zone.
The key needs permission to update TXT records under the zone. With BIND:
key "acme-update" {
algorithm hmac-sha256;
secret "<base64 secret, e.g. from `tsig-keygen acme-update`>";
};
zone "example.com" {
type primary;
file "/var/lib/bind/example.com.zone";
update-policy {
grant acme-update zonesub TXT;
};
};
AWS access key id and secret access key, plus a session token for temporary credentials (STS, assumed role). Requests are signed with AWS Signature Version 4; no AWS SDK is involved. The IAM policy needs route53:ListHostedZonesByName, route53:ListResourceRecordSets, route53:ChangeResourceRecordSets and route53:GetChange; the last three can be scoped to the hosted zone (arn:aws:route53:::hostedzone/<id>, and arn:aws:route53:::change/* for GetChange).
The hosted zone is resolved through the API: the longest suffix of the FQDN that names a public hosted zone wins, so delegated sub-zones are supported and no SOA lookup is needed. Private hosted zones are skipped, as the CA validates against public DNS; several public zones with the same name are an error. Either case can be settled with with_hosted_zone_id. A wildcard and its apex share one TXT record set: present writes it back with every existing value plus its own, and cleanup removes only its own value (deleting the set when it was the last one). A new set gets a TTL of 60s; an existing one keeps its TTL. Each change is polled until Route53 reports it INSYNC (up to 120s) before returning.
use cheti::{Route53Config, Route53Provider};
let config = Route53Config::new(
std::env::var("AWS_ACCESS_KEY_ID").unwrap(),
std::env::var("AWS_SECRET_ACCESS_KEY").unwrap(),
)
// Optional: skip the zone lookup (also the way to target a private zone).
.with_hosted_zone_id("Z0123456789ABCDEFGHIJ").unwrap();
let provider = Route53Provider::new(config).unwrap();Route53Provider::from_env() reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and the optional AWS_SESSION_TOKEN. Other credential sources of the AWS SDKs (profiles, instance metadata) are not read; pass their values to Route53Config and with_session_token.
API secret key from https://console.scaleway.com/iam/api-keys. The key needs the DNSFullAccess permission set.
use cheti::{ScalewayConfig, ScalewayProvider};
let config = ScalewayConfig::new(std::env::var("SCW_SECRET_KEY").unwrap());
let provider = ScalewayProvider::new(config).unwrap();By default each provider calls find_zone() (an SOA walk) to determine which zone owns the FQDN. If you already know the zone, pass it explicitly to skip the lookup:
use cheti::{GandiConfig};
let config = GandiConfig::new("token").with_zone("example.com").unwrap();needs_renewal parses the leaf certificate and returns true when expiry is within the threshold. An unparseable certificate also returns true — the safe default for a renewal gate is to re-issue:
use cheti::needs_renewal;
let cert_pem = std::fs::read_to_string("/etc/ssl/example.com.crt").unwrap();
if needs_renewal(&cert_pem, 30) {
// Re-issue: rebuild your solver and call solve_and_finalize again.
}If you need to distinguish "expiring" from "couldn't parse", use needs_renewal_checked, which returns Result<bool, DnsError>.
Hook this into a daily cron or a periodic task. For Let's Encrypt (90-day certs) a 30-day threshold is the conventional choice.
instant_acme exposes the ACME profile mechanism. Let's Encrypt and Pebble both ship a shortlived profile that issues 6-hour certificates:
# async fn run(account: instant_acme::Account) -> Result<(), Box<dyn std::error::Error>> {
use instant_acme::{Identifier, NewOrder};
let identifiers = vec![Identifier::Dns("example.com".to_string())];
let order = account
.new_order(&NewOrder::new(&identifiers).profile("shortlived"))
.await?;
# Ok(()) }The solver doesn't care — set the profile on the order and pass it in.
The solver polls the zone's authoritative nameservers until every one of them serves the TXT record, then asks the CA to validate (if the nameservers cannot be found, it falls back to public resolvers). Defaults (120s timeout, 5s interval) are conservative; tune for your provider:
use std::time::Duration;
use cheti::Dns01Solver;
# use cheti::{GandiConfig, GandiProvider};
# let provider = GandiProvider::new(GandiConfig::new("t")).unwrap();
let solver = Dns01Solver::new(provider)
.with_timeout(Duration::from_secs(60))
.with_interval(Duration::from_secs(2));If your DNS provider's authoritative servers lag (anycast, split-horizon), you can bypass the active poll:
# use std::time::Duration;
# use cheti::{Dns01Solver, GandiConfig, GandiProvider};
# let provider = GandiProvider::new(GandiConfig::new("t")).unwrap();
let solver = Dns01Solver::new(provider)
.skip_propagation_check(Duration::from_secs(30));This blindly sleeps instead of verifying — only use when the safety net is the problem.
The e2e test suite runs against Pebble (the official ACME test CA) plus its DNS challenge server. To run it locally:
./scripts/pebble-up.sh
cargo test --test pebble_e2e -- --ignored
./scripts/pebble-down.shThis is the only test that exercises the full Dns01Solver → instant_acme::Order flow; the rest of the suite uses wiremock against each provider's HTTP API.
The RFC 2136 provider is tested against an in-process mock server, and optionally against a real BIND primary:
./scripts/bind-up.sh
cargo test --test rfc2136_bind -- --ignored
./scripts/bind-down.shuse async_trait::async_trait;
use cheti::{DnsError, DnsProvider};
struct MyProvider { /* ... */ }
#[async_trait]
impl DnsProvider for MyProvider {
async fn present(&self, fqdn: &str, value: &str) -> Result<(), DnsError> {
// Create or update a TXT record at `fqdn` containing `value`.
// If the record already has other values, preserve them.
todo!()
}
async fn cleanup(&self, fqdn: &str, value: &str) -> Result<(), DnsError> {
// Remove `value` from the TXT record at `fqdn`. If that empties
// the record entirely, delete the record.
todo!()
}
}The trait uses async_trait, so annotate your impl with #[async_trait]. Being object-safe, providers can also be used as Box<dyn DnsProvider> when the concrete type is chosen at runtime.
present must be idempotent (re-calling with the same value is a no-op) and concurrent-safe per fqdn. The built-in providers use a KeyedMutex for the latter — feel free to copy the pattern.
MIT.