Skip to main content

Crate desec

Crate desec 

Source
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. Subname carries both spellings, so an RRset read from the API can be written back without a translation step to forget.
  • records: null is a 400, not “leave unchanged”. Nothing in RrsetPatch can serialize to null, and a TTL-only update is expressible.
  • perm_write: false must be sent, not omitted, or write permission can be granted but never revoked. TokenPolicyPatch sends it.
  • PUT needs every field even when deleting, so delete_bulk uses PATCH.
  • A body subname disagreeing with the path subname is a 400; the write methods derive one from the other.
  • max_age and max_unused_period must be clearable, which takes an explicit null — see TokenUpdate::clear_max_age.
  • Omitting cursor is what triggers 400 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.
ClientBuilder
Builds a Client.
Cursor
An opaque position in a paginated collection.
DjangoDuration
A duration in the format Django serializes, used by a token’s max_age and max_unused_period.
InvalidValue
A value rejected by client-side validation.
ListRequest
A pending request for a paginated collection.
Page
One page of a paginated collection.
Rate
A limit of limit requests per period.
RateLimits
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.
ErrorDetail
One node of an error document.
RecordType
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-Agent sent unless the builder overrides it.

Type Aliases§

ItemStream
A lazy stream of items across pages, as returned by ListRequest::stream.
Result
Result alias used throughout the crate.