Expand description
An async client for the deSEC.io DNS API, covering the whole documented surface: domains, DNS records, tokens with their scoping policies, the account lifecycle, and the dynDNS update protocol.
§Getting started
use desec::{Client, RecordType, Subname};
use desec::api::rrsets::NewRrset;
let client = Client::new("i-T3b1h_OI-H9ab8tRS98stGtURe")?;
client
.rrsets("example.com")
.create(&NewRrset::new(
"www".parse()?,
RecordType::A,
3600,
["127.0.0.1"],
))
.await?;
let apex = client
.rrsets("example.com")
.get(&Subname::apex(), &RecordType::MX)
.await?;
println!("{:?}", apex.records);§Rate limiting
deSEC throttles per scope, and most scopes carry several limits at once — RRset writes
on one domain are capped at 2/s, 15/min, 100/h and 300/day simultaneously. The client
enforces the documented rates itself, so it paces requests rather than collecting
429s, and a 429 that does arrive is honoured via Retry-After and retried.
use std::time::Duration;
use desec::{Client, Rate, RateLimits, Scope};
let client = Client::builder()
.token("i-T3b1h_OI-H9ab8tRS98stGtURe")
// Halve the per-domain write rate, because something else shares this account.
.rate_limits(RateLimits::desec_defaults().with_scope(
Scope::DnsApiPerDomainExpensive,
[
Rate::new(1, Duration::from_secs(1))?,
Rate::new(7, Duration::from_secs(60))?,
],
))
// Wait out a per-minute bucket, but fail fast on an hourly one.
.max_rate_limit_wait(Duration::from_secs(90))
.build()?;Pass RateLimits::unlimited to opt out and handle 429s reactively only. Clones of
a Client share one limiter, so concurrent tasks pace against the same buckets.
§Pagination
GET /domains/, GET /domains/{name}/rrsets/ and GET /auth/tokens/ are paginated at
500 items. Of these only the RRset list routinely exceeds a page. Three ways to read
one, in increasing eagerness:
use futures_util::TryStreamExt;
// One page, with cursors, for full control.
let page = client.rrsets("example.com").list().send().await?;
// Lazy across pages: `.take(10)` costs one request no matter how large the zone.
let mut stream = client.rrsets("example.com").list().stream();
while let Some(rrset) = stream.try_next().await? {
println!("{}", rrset.name);
}
// Eager, for collections known to be small.
let domains = client.domains().list().all().await?;Filters are what keep the write path off the rate limiter: an ACME challenge should
find its zone with owner_of and address one RRset
directly, never list a zone.
§Errors
Error is a thiserror enum. A rejected request keeps the server’s error document
intact as an ErrorDetail tree rather than flattening it to a string, so the field
that failed — and, for a bulk RRset write, which item’s field — is still there:
// A bulk write reports errors positionally, with an empty object per item that passed.
let err = ApiError::parse(r#"[{}, {"records": ["Invalid record."]}, {}]"#);
assert_eq!(err.messages(), vec![("1.records".to_owned(), "Invalid record.")]);§Tracing
Every request runs in a desec.request span carrying the method and path, with events
for the response status, local rate-limit waits, server throttling and retries.
Credentials are never recorded: Secret redacts itself in Debug and Display.
§API semantics the types enforce
Several of the API’s rules are easy to get wrong, and each has already cost a shipped client a bug. Where possible the mistake is unrepresentable rather than merely documented:
- The zone apex is
@in a URL path but""in a JSON body, and the API returns the latter.Subnamecarries both spellings, so an RRset read from the API can be written back without a translation step to forget. records: nullis a400, not “leave unchanged”. Nothing inRrsetPatchcan serialize tonull, and a TTL-only update is expressible.perm_write: falsemust be sent, not omitted, or write permission can be granted but never revoked.TokenPolicyPatchsends it.PUTneeds every field even when deleting, sodelete_bulkusesPATCH.- A body
subnamedisagreeing with the pathsubnameis a400; the write methods derive one from the other. max_ageandmax_unused_periodmust be clearable, which takes an explicitnull— seeTokenUpdate::clear_max_age.- Omitting
cursoris what triggers400 Pagination required; the client always sends it.
§Not covered
/auth/totp/ (2FA), which the API documents only as “interface subject to change” and
gives no field reference for, and PATCH /domains/{name}/, which is deprecated
upstream.
Re-exports§
pub use api::dyndns;
Modules§
- api
- The API surface, grouped by resource.
Structs§
- ApiError
- The body of an error response, with its structure preserved.
- Client
- An asynchronous deSEC API client.
- Client
Builder - Builds a
Client. - Cursor
- An opaque position in a paginated collection.
- Django
Duration - A duration in the format Django serializes, used by a token’s
max_ageandmax_unused_period. - Invalid
Value - A value rejected by client-side validation.
- List
Request - A pending request for a paginated collection.
- Page
- One page of a paginated collection.
- Rate
- A limit of
limitrequests perperiod. - Rate
Limits - The rates to enforce for each scope.
- Secret
- A credential that must not appear in logs.
- Subname
- The label part of an RRset name, relative to the zone.
Enums§
- Error
- Anything that can go wrong talking to the deSEC API.
- Error
Detail - One node of an error document.
- Record
Type - A DNS record type.
- Scope
- A throttling scope, named as deSEC names it.
Constants§
- DEFAULT_
BASE_ URL - The public deSEC API.
- DEFAULT_
USER_ AGENT User-Agentsent unless the builder overrides it.
Type Aliases§
- Item
Stream - A lazy stream of items across pages, as returned by
ListRequest::stream. - Result
- Result alias used throughout the crate.