dns-update 0.4.1

Dynamic DNS update (RFC 2136 and cloud) library for Rust
Documentation
# CLAUDE.md

Notes for Claude (and future contributors) working on `dns-update`.

## What this crate is

`dns-update` is an async Rust library for dynamic DNS updates. It exposes a
single enum `DnsUpdater` that fronts many backend providers behind a uniform
`create` / `update` / `delete` API for record types `A`, `AAAA`, `CNAME`, `NS`,
`MX`, `TXT`, `SRV`, `TLSA`, `CAA`. It targets RFC 2136 (with TSIG) and a
growing list of cloud / registrar DNS APIs. The stated goal in `README.md` is
to eventually cover as many providers as Go's
[lego](https://go-acme.github.io/lego/dns/) library.

## Layout

- `src/lib.rs`: public surface. Defines `Error`, `DnsRecord*`, `CAARecord`,
  `MXRecord`, `SRVRecord`, `TLSARecord`, `KeyValue`, `TsigAlgorithm`,
  `Algorithm`, `IntoFqdn`, and the `DnsUpdater` enum that dispatches to a
  provider.
- `src/update.rs`: the `impl DnsUpdater` factories (`new_*`) and the
  `create` / `update` / `delete` match arms that fan out to each provider.
- `src/providers/`: one file per provider. Each file is self-contained and
  exposes a `pub struct <Name>Provider` plus `pub(crate) async fn new`,
  `create`, `update`, `delete` methods called from `update.rs`.
- `src/tests/`: one `*_tests.rs` file per provider, registered in
  `src/tests/mod.rs`.
- `src/http.rs`: shared `HttpClientBuilder` / `HttpClient` over `reqwest`.
  Handles JSON bodies, retries on 429 with `retry-after`, maps statuses to
  `Error::Unauthorized` / `Error::NotFound` / `Error::BadRequest` /
  `Error::Api`.
- `src/utils.rs`: `strip_origin_from_name`, `txt_chunks*`, `IntoFqdn` impls,
  `Display` for record types, helpers like `CAARecord::decompose`. Reuse
  these.
- `src/crypto.rs`, `src/jwt.rs`: HMAC, SHA, RSA-via-`ring`/`aws-lc-rs`, and
  Google-style JWT helpers (used by route53, ovh, google_cloud_dns).
- `src/bind.rs`: BIND zone-file rendering. Not a provider.

## Adding a new provider (the recipe)

For a vanilla token-based REST provider, follow `digitalocean.rs` or
`desec.rs` as the template. Steps:

1. Create `src/providers/<name>.rs` defining:
   - `#[derive(Clone)] pub struct <Name>Provider { client: HttpClientBuilder, ... }`
   - `pub(crate) fn new(...) -> crate::Result<Self>` (or `-> Self` if no
     fallible setup). Inject auth headers via
     `HttpClientBuilder::default().with_header(...).with_timeout(timeout)`.
   - `pub(crate) async fn create / update / delete` with the same signatures
     used elsewhere:
     ```rust
     async fn create(&self, name: impl IntoFqdn<'_>, record: DnsRecord, ttl: u32, origin: impl IntoFqdn<'_>) -> crate::Result<()>
     async fn update(&self, name: impl IntoFqdn<'_>, record: DnsRecord, ttl: u32, origin: impl IntoFqdn<'_>) -> crate::Result<()>
     async fn delete(&self, name: impl IntoFqdn<'_>, origin: impl IntoFqdn<'_>, record_type: DnsRecordType) -> crate::Result<()>
     ```
   - A test-only `with_endpoint` setter (gated `#[cfg(test)]`) so mockito can
     point the provider at a local URL.
2. Register it in `src/providers/mod.rs` (`pub mod <name>;`).
3. Wire it into `src/lib.rs`: add a variant on `DnsUpdater` and import the
   provider type.
4. Wire it into `src/update.rs`:
   - Add a `new_<name>(...)` constructor.
   - Add match arms in `create` / `update` / `delete`.
5. Write `src/tests/<name>_tests.rs` and register the module in
   `src/tests/mod.rs`. Use `mockito::Server::new_async()` for unit tests; the
   existing `cloudflare_tests.rs` is the canonical example. Also include a
   `#[ignore = "..."]` integration test that hits the real API behind env
   vars, again like cloudflare.

### Conventions

- **No comments in source code.** Identifiers carry the *what*; rationale
  belongs in commit / PR messages.
- **Never use em dashes (`—`).** Use `:`, `;`, parens, comma, or sentence
  breaks.
- **Naming**: subdomain vs name vs fqdn distinction matters. Use
  `name.into_name()` and `origin.into_name()` from `IntoFqdn`. To get the
  bare subdomain relative to a zone use
  `utils::strip_origin_from_name(name, origin, Some(""))` (or `None` for the
  `@` default).
- **Zone discovery**: when the API exposes zones by name, walk up the
  origin one label at a time until a zone matches (see Cloudflare's
  `obtain_zone_id`). For providers that always require an explicit zone,
  trust the caller's `origin`.
- **Record-ID resolution**: providers that need an ID for update / delete
  usually do a list-records lookup filtered by name + type. Reuse the
  Cloudflare and DigitalOcean patterns. For update specifically, resolve
  the record ID before issuing PATCH / PUT (see issue #52 / commit 707b82c).
- **TXT quoting / chunking**: some APIs want raw text, others want quoted
  with `\"`-escaping, others want chunks of <=255 bytes. Reuse
  `utils::txt_chunks_to_text` / `utils::txt_chunks`. Be deliberate about
  which form you send; check the provider's docs and look at lego's
  `presentRecord` for a hint.
- **CAA**: convert via `CAARecord::decompose()` to `(flags, tag, value)`.
  Many APIs accept that flat form; others want the BIND-style string given
  by `CAARecord::Display`.
- **TLSA**: `TLSARecord::Display` produces `"<usage> <selector> <matching>
  <hex>"`. Many providers can take that directly. Some don't support TLSA at
  all: return `Error::Api("TLSA records are not supported by ...")` (see
  `digitalocean.rs`'s `TryFrom<DnsRecord> for RecordData`).
- **Errors**: don't invent new error variants for one provider; use the
  existing `Error::{Api, Unauthorized, NotFound, BadRequest, Parse,
  Serialize, Client, Response, Protocol}`. API-level failure messages go
  into `Error::Api(_)`.
- **No new dependencies** unless you've checked with the maintainer. Most
  things we need (HTTP, JSON, urlencoded form bodies, base64, hex, HMAC,
  SHA, RSA, XML) are already in `Cargo.toml`. `quick-xml` is available for
  XML APIs (used by Route53).
- **Crypto**: use `ring` (feature `ring`) or `aws-lc-rs` (default). The
  cfg pattern is shown at the top of `src/jwt.rs`. Providers requiring
  crypto must gate themselves on `#[cfg(any(feature = "ring", feature =
  "aws-lc-rs"))]` the way `OvhProvider` does.

### What the public API looks like for the user

```rust
let updater = DnsUpdater::new_cloudflare(token, None::<&str>, Some(Duration::from_secs(30)))?;
updater.create("test._domainkey.example.org", DnsRecord::TXT("v=DKIM1; ...".into()), 300, "example.org").await?;
updater.update(..., DnsRecord::A(addr), ttl, origin).await?;
updater.delete(name, origin, DnsRecordType::TXT).await?;
```

## Helpers reference (reuse, do not reinvent)

Before writing anything in a new provider, check this list. If the same shape
exists here, use it.

### `src/http.rs`

The default HTTP layer. `HttpClientBuilder` is `Clone`able; build one in
`Provider::new` with the right auth headers and timeout, store it on the
provider struct, and call `.get(url) / .post(url) / .put(url) / .patch(url)
/ .delete(url)` to get an `HttpClient` per request.

- `HttpClientBuilder::default()`: starts with `Content-Type: application/json`.
- `.with_header(name: &'static str, value: impl AsRef<str>)`: chainable, repeatable.
- `.with_timeout(Option<Duration>)`: chainable.
- `HttpClient::with_header(...)`: per-request override.
- `HttpClient::with_body(B: Serialize)`: JSON-serialize the body. Returns `crate::Result<HttpClient>`.
- `HttpClient::with_raw_body(String)`: pre-rendered body (XML, form-encoded, etc).
- `HttpClient::send_raw() -> crate::Result<String>`: discards JSON parsing.
- `HttpClient::send<T: DeserializeOwned>() -> crate::Result<T>`.
- `HttpClient::send_with_retry<T>(max_retries: u32)`: retries on `429` honoring
  `Retry-After`, otherwise identical to `send`. Use this by default.

Status mapping (do not re-implement): `204` -> empty `{}` JSON, `2xx` -> body,
`400` -> `Error::Api("BadRequest ...")`, `401` -> `Error::Unauthorized`,
`404` -> `Error::NotFound`, other -> `Error::Api(...)`.

If you need form-encoded bodies use `serde_urlencoded::to_string(...)` and
`HttpClient::with_raw_body(...)` together (override the content-type header
on the builder or per-request via `.with_header`). If you need XML bodies use
`quick_xml::se::to_string(...)` plus `with_raw_body`.

### `src/utils.rs`

- `strip_origin_from_name(name, origin, return_if_equal: Option<&str>) -> String`:
  returns the bare subdomain. If `name == origin`, returns `return_if_equal`
  or `"@"` when `None`. Trailing dots are stripped.
- `txt_chunks_to_text(out: &mut String, text: &str, separator: &str)`:
  produces `"chunk1" "chunk2"` 255-byte-wise quoted+escaped TXT content.
- `txt_chunks(content: String) -> Vec<String>`: raw 255-byte chunks, no
  quoting.

Already-impl'd `Display` impls (use `format!("{}", record)` instead of
hand-rolling):

- `DnsRecord` (dispatches per variant).
- `DnsRecordType` -> `"A"`, `"AAAA"`, `"CNAME"`, ... Use `DnsRecordType::as_str()`
  for `&'static str` (avoids allocation in URL building).
- `MXRecord` -> `"<priority> <exchange>"`.
- `SRVRecord` -> `"<priority> <weight> <port> <target>"`.
- `TLSARecord` -> `"<usage> <selector> <matching> <hexcertdata>"`.
- `CAARecord` -> BIND-style `0 issue "letsencrypt.org"` (with options
  appended as `;key=value`).
- `KeyValue` -> `"key=value"` (or just `"key"` if value is empty).
- `Error` (implements `std::error::Error`).

Conversions:

- `DnsRecord::as_type() -> DnsRecordType`.
- `DnsRecord::priority() -> Option<u16>` (only MX / SRV; defined in
  `src/providers/mod.rs`).
- `CAARecord::decompose() -> (u8 flags, String tag, String value)`: the most
  common form APIs want for CAA. Prefer this over the `Display` impl when
  the API expects separate fields.
- `u8::from(TlsaCertUsage / TlsaSelector / TlsaMatching)`: numeric wire form.
- `IntoFqdn::into_fqdn(self) -> Cow<str>`: adds trailing `.`.
- `IntoFqdn::into_name(self) -> Cow<str>`: strips trailing `.`. Impls cover
  `&str`, `&String`, `String`.

### `src/crypto.rs`

Feature-gated to use `aws-lc-rs` (default) or `ring`:

- `sha1_digest(&[u8]) -> Vec<u8>`.
- `sha256_digest(&[u8]) -> Vec<u8>`.
- `hmac_sha256(key, data) -> Vec<u8>`.

If you need HMAC-SHA1 (websupport, dnsmadeeasy, constellix), add an
`hmac_sha1` helper here (single function); do not duplicate the cfg dance
across providers.

### `src/jwt.rs`

For providers that use OAuth2 JWT-bearer grants (Google, and any provider
that signs a JWT with a service-account private key to swap for an access
token, e.g. yandexcloud):

- `ServiceAccount { client_email, private_key, token_uri }` (`Deserialize`).
- `create_jwt(&ServiceAccount, scopes: &str) -> Result<String, ...>`: RS256
  signed JWT.
- `exchange_jwt_for_token(token_uri, jwt) -> Result<String, ...>`: standard
  `urn:ietf:params:oauth:grant-type:jwt-bearer` exchange. Returns the
  `access_token`.

If you need a non-Google JWT (different claims structure, e.g. yandexcloud
PS256 with custom `aud`), generalize this file rather than copying. Plain
RSA signing primitives are exposed by `ring` / `aws-lc-rs` directly.

### Provider patterns to copy verbatim

- **AWS-Sigv4 family** (any provider whose docs say "HMAC-SHA256",
  canonical request, signed headers, scoped derivation): `route53.rs`'s
  `send_signed_request` + `get_signature_key` is the template. Swap the
  `algorithm` constant, the service name, the canonical-headers list, and
  any region scoping format. Aliyun ACS3-HMAC-SHA256, Tencent
  TC3-HMAC-SHA256, Huawei SDK-HMAC-SHA256, Baidu BCE, and Volcengine all
  fit.
- **Bearer + zone-walk by suffix** (provider gives a zone-by-name endpoint,
  the user supplies a subdomain or apex): `cloudflare.rs`'s
  `obtain_zone_id` loop.
- **List + find by name+type for update/delete**: `cloudflare.rs`'s
  `obtain_record_id` and `digitalocean.rs`'s `obtain_record_id` are the
  two patterns (one walks all records with a name filter, one filters
  server-side). Pick whichever the API supports.
- **OAuth2 client_credentials -> Bearer** (azuredns, ibmcloud): write a
  tiny `ensure_token` method on the provider; cache the token + expiry in
  `Arc<Mutex<Option<(String, Instant)>>>` exactly like
  `google_cloud_dns.rs` does. Don't paginate / sign / retry yourself; just
  POST the form body via `HttpClient::with_raw_body(...)`.

### `src/bind.rs`

`BindSerializer::serialize(&[NamedDnsRecord]) -> String` renders zone-file
text. Not needed for cloud providers, but if you implement one that
accepts a BIND zone-file as input, reuse this rather than building strings
inline.

### What `Cargo.toml` already gives you

Anything in this list is fair game; pulling in a new crate is not. Each
crate is feature-flagged where relevant.

- `tokio` (`rt`, `net`, plus `full` in dev).
- `hickory-net`, `hickory-proto` (DNS protocol).
- `reqwest` (with `http2`, `gzip`, `deflate`, `json`).
- `serde`, `serde_json`, `serde_urlencoded`.
- `quick-xml` (XML serde for Route53; also use for SOAP-ish APIs).
- `base64`, `hex`.
- `chrono` (with `std`, `clock`, `now`) for date formatting and timestamps.
- `ring` / `aws-lc-rs` (feature-gated): SHA1/SHA256/SHA512, HMAC, RSA, ECDSA.
- `rustls` (feature-gated): TLS plumbing if you need a custom config.
- Dev-only: `mockito` (preferred for new tests), `httpmock`.

If you find yourself wanting `chrono::DateTime<Utc>::now()` for HTTP signing,
look at how Route53 already does it before writing your own helper.

## Testing

- Run all tests: `cargo test`. Tests that hit live APIs are gated behind
  `#[ignore = "..."]` and require env vars; CI runs only the mocked tests.
- The default crate feature is `aws-lc-rs`. To exercise the alternative
  crypto stack: `cargo test --no-default-features --features ring`.
- The `test_provider` feature unlocks `InMemoryProvider` and `PebbleProvider`
  for downstream consumers writing their own tests.

## Misc

- `examples/` has small `main.rs` programs that exercise the public API.
- License: dual Apache-2.0 / MIT. New files keep the existing header.