Metadata-Version: 2.4
Name: certbot-dns-nexdns
Version: 1.0.1
Summary: NexDNS DNS Authenticator plugin for Certbot
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: certbot>=2.0.0
Requires-Dist: requests>=2.25.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# certbot-dns-nexdns

[![PyPI](https://img.shields.io/pypi/v/certbot-dns-nexdns)](https://pypi.org/project/certbot-dns-nexdns/)
[![Python versions](https://img.shields.io/pypi/pyversions/certbot-dns-nexdns)](https://pypi.org/project/certbot-dns-nexdns/)
[![Docker image](https://img.shields.io/docker/pulls/nexdns/certbot)](https://hub.docker.com/r/nexdns/certbot)
[![License](https://img.shields.io/pypi/l/certbot-dns-nexdns)](LICENSE)

Official [Certbot](https://certbot.eff.org/) DNS authenticator for [NexDNS](https://nexdns.tech), a managed authoritative DNS platform with a REST API.

The plugin answers the ACME `dns-01` challenge for you. Before validation it creates the `_acme-challenge` TXT record in your NexDNS zone, and after validation it removes it again, so issuing and renewing certificates needs no manual DNS edits.

## Why DNS-01

- **Wildcards.** Let's Encrypt issues `*.example.com` only through `dns-01`. No wildcard certificate can be obtained with `http-01`.
- **No inbound HTTP.** Validation happens entirely over DNS, so nothing has to listen on port 80 or 443 and the host does not have to be reachable from the internet. You can issue for a machine behind NAT, for an internal service, or before the site is live.
- **One certificate for the apex and everything under it**, in a single run.

## Requirements

- Python 3.9 or newer. The test matrix covers 3.9, 3.10, 3.11, 3.12 and 3.13
- Certbot 2.0 or newer
- A NexDNS account on a [plan that includes API access](https://nexdns.tech/pricing), with the zone for the certificate's domain already hosted on it
- An API token carrying the three scopes listed below

`requests` 2.25 or newer is installed as a dependency.

## Installation

```bash
pip install certbot-dns-nexdns
```

If Certbot itself was installed with pipx, add the plugin to that same environment instead, or Certbot will not find it:

```bash
pipx inject certbot certbot-dns-nexdns
```

Confirm that Certbot picked it up:

```bash
certbot plugins
```

`dns-nexdns` should appear in the list, described as "Obtain certificates using a DNS TXT record (if you are using NexDNS for DNS hosting)".

There is also a prebuilt image, [`nexdns/certbot`](https://hub.docker.com/r/nexdns/certbot), which is Certbot with this plugin already installed, for `linux/amd64` and `linux/arm64`. See [Docker](#docker).

## API token

Create a token under **Settings → API keys** in the panel ([nexdns.tech/settings/api-keys](https://nexdns.tech/settings/api-keys)) and grant it exactly three scopes:

| Scope | What the plugin does with it |
| --- | --- |
| `zones.read` | Finds the zone that owns the challenge name, so `_acme-challenge.shop.example.com` is written into the `example.com` zone |
| `records.write` | Creates the challenge TXT record, and deletes it after validation |
| `records.read` | Reads the zone's TXT records back during cleanup, to find the exact record it created |

Nothing beyond these three is used. In particular the plugin never asks for `zones.write`, so a token scoped this way cannot create or delete zones.

Scopes are fixed when the key is created. To change them, create a new key and revoke the old one.

## Credentials file

Certbot reads the token from an INI file:

```bash
mkdir -p ~/.nexdns && chmod 700 ~/.nexdns
cat > ~/.nexdns/certbot-credentials.ini <<'EOF'
dns_nexdns_token = nxd_0000000000000000000000000000000000000000
EOF
chmod 600 ~/.nexdns/certbot-credentials.ini
```

`dns_nexdns_token` is the only required key. When the file is world-readable Certbot logs `Unsafe permissions on credentials configuration file` and carries on regardless, so the warning is easy to miss in an unattended run. Set the mode yourself.

## Issuing a certificate

### Wildcard plus apex in one run

```bash
certbot certonly \
    --authenticator dns-nexdns \
    --dns-nexdns-credentials ~/.nexdns/certbot-credentials.ini \
    --email admin@example.com \
    --agree-tos \
    --non-interactive \
    -d example.com \
    -d '*.example.com'
```

Quote `*.example.com` so the shell does not expand it. A wildcard does not cover the apex, which is why both names are listed, and it covers only one label: `*.example.com` matches `www.example.com` but not `a.b.example.com`.

Both names are validated at `_acme-challenge.example.com`, so this run creates two TXT records there and removes both afterwards.

### Several domains on one certificate

```bash
certbot certonly \
    --authenticator dns-nexdns \
    --dns-nexdns-credentials ~/.nexdns/certbot-credentials.ini \
    --email admin@example.com \
    --agree-tos --non-interactive \
    -d example.com -d www.example.com \
    -d example.net -d '*.example.net'
```

Every zone named in one run must be hosted on the account the token belongs to, since the plugin uses that single token for all of them.

### Propagation wait

`--dns-nexdns-propagation-seconds` is how long Certbot waits after the record is written before asking the ACME server to look for it. The default is **60**. Do not lower it: at 30 seconds validation fails with `NXDOMAIN`. Raise it if your resolvers are slower to pick the record up.

```bash
certbot certonly \
    --authenticator dns-nexdns \
    --dns-nexdns-credentials ~/.nexdns/certbot-credentials.ini \
    --dns-nexdns-propagation-seconds 120 \
    -d '*.example.com'
```

## Renewal

On the first successful issuance Certbot stores the authenticator and its options in the certificate's renewal configuration (`/etc/letsencrypt/renewal/example.com.conf`), so renewal takes no plugin flags:

```bash
certbot renew --dry-run   # runs against the staging CA and issues nothing
certbot renew
```

Two things must still hold at renewal time: the credentials file has to exist at the path recorded in that configuration, and the token has to still be valid. A revoked or expired token is invisible until then, which is what makes a scheduled `--dry-run` worth having.

Certbot installed from PyPI does not schedule itself. Distribution packages usually ship a `certbot.timer` systemd unit or an `/etc/cron.d/certbot` entry; a `pip` or `pipx` install ships neither, so add one:

```bash
# /etc/cron.d/certbot
17 3,15 * * * root certbot renew --quiet
```

Twice a day is what Certbot's own documentation recommends. `renew` exits without doing anything unless a certificate is close to expiry, and an off-the-hour minute avoids the pile-up at `00`.

## Docker

The image inherits Certbot's entrypoint, so it is a drop-in replacement for `certbot/certbot` with the authenticator already present:

```bash
docker run --rm \
    -v /etc/letsencrypt:/etc/letsencrypt \
    -v /var/lib/letsencrypt:/var/lib/letsencrypt \
    -v ~/.nexdns/certbot-credentials.ini:/credentials.ini:ro \
    nexdns/certbot certonly \
        --authenticator dns-nexdns \
        --dns-nexdns-credentials /credentials.ini \
        --email admin@example.com \
        --agree-tos --non-interactive \
        -d example.com -d '*.example.com'
```

Tags are `nexdns/certbot:latest` and one per release, for example `nexdns/certbot:1.0.0`.

A renewal loop with Compose:

```yaml
services:
  certbot:
    image: nexdns/certbot:latest
    restart: unless-stopped
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt
      - /var/lib/letsencrypt:/var/lib/letsencrypt
      - ./certbot-credentials.ini:/credentials.ini:ro
    entrypoint: >
      /bin/sh -c 'trap exit TERM; while :; do
        certbot renew --quiet;
        sleep 12h & wait $${!};
      done'
```

`$$` is how Compose escapes a literal `$`, so the container shell sees `wait ${!}` and waits on the backgrounded `sleep`. That is what makes `docker compose down` stop the container at once instead of up to twelve hours later.

## Choosing the API base URL

The plugin talks to `https://api.nexdns.tech/v1`. Set `dns_nexdns_api_url` in the credentials file to point it at a different base URL:

```ini
# ~/.nexdns/certbot-credentials.ini
dns_nexdns_token    = nxd_0000000000000000000000000000000000000000
dns_nexdns_api_url = https://api.nexdns.tech/v1
```

The value is the full base URL including the API version segment. A trailing slash is ignored. Omit the key entirely to get the default, which is the value shown above.

## Options

Command-line options, as reported by `certbot --help dns-nexdns`:

| Option | Default | Description |
| --- | --- | --- |
| `--dns-nexdns-credentials` | none | Path to the credentials INI file. Certbot prompts for it interactively when it is not supplied, and stores the resolved absolute path |
| `--dns-nexdns-propagation-seconds` | `60` | Seconds to wait for the TXT record to become visible before the ACME server is asked to verify it |

Credentials file keys:

| Key | Default | Description |
| --- | --- | --- |
| `dns_nexdns_token` | none, required | API token, in the form `nxd_…` |
| `dns_nexdns_api_url` | `https://api.nexdns.tech/v1` | API base URL |

## What happens during a challenge

1. Certbot asks the plugin to publish a value at `_acme-challenge.<domain>`.
2. The plugin looks up the zone by trying the longest suffix of that name first and shortening it label by label, so `_acme-challenge.shop.example.com` lands in a delegated `shop.example.com` zone when the account has one, and in `example.com` otherwise.
3. It creates the TXT record with a TTL of 120 seconds. Certbot then waits out `--dns-nexdns-propagation-seconds`.
4. After validation the plugin lists the zone's TXT records, matches its own value exactly and deletes that one record. Other TXT records at the same name are left untouched.
5. If cleanup fails it logs a warning instead of failing the run, because the certificate has already been issued by that point. The leftover record stays in the zone until you remove it.

Every API call carries a 30-second timeout, so an unreachable API fails the run rather than hanging an unattended `certbot renew` indefinitely.

## Troubleshooting

API failures reach you through Certbot as `Failed to add TXT record: NexDNS API error: <message>`, where the message comes from the API and names the cause.

**`NexDNS API error: Invalid API key.`**
The value of `dns_nexdns_token` matches no key on the account. A truncated paste is the usual reason; tokens begin with `nxd_`. A key that has been revoked in the panel reports the same thing.

**`NexDNS API error: API key has expired.`**
The key was created with an expiry date that has passed. Create a replacement and update the credentials file. The renewal configuration keeps pointing at the same file, so nothing else changes.

**`NexDNS API error: API access is not included in your plan.`**
The key is valid, but the account's plan does not include API access. See [pricing](https://nexdns.tech/pricing). This is checked on every request, so it can appear on a key that worked before a plan change.

**`NexDNS API error: API key lacks the "records.write" permission.`**
The key is missing a scope. It needs all three of `zones.read`, `records.read` and `records.write`. Since scopes cannot be edited, create a new key with all three and revoke the old one. A key created with no scopes at all reports `API key has no permissions assigned` instead.

**`NexDNS API error: Too many requests. Please retry after …`**
The account's API rate limit was reached. The response carries a `Retry-After` header. This is worth checking if you drive many certificates through one token in a tight loop.

**`Failed to add TXT record: Zone not found for _acme-challenge.example.com.`**
No zone on the token's account matches any suffix of that name. `nexdns zone list` from the [CLI](https://github.com/nexdns/cli) shows what the account actually holds. A certificate for `shop.example.com` needs either the `example.com` zone or a `shop.example.com` zone of its own to be present there.

**`Missing property in credentials configuration file …: Property "dns_nexdns_token" not found`**
The INI file exists but has no `dns_nexdns_token` line, or its value is empty. Key names are exact, including the `dns_nexdns_` prefix.

**`File not found: …`**
`--dns-nexdns-credentials` points at a path that does not exist. Certbot resolves the path to an absolute one at issuance and records that in the renewal configuration, so a credentials file that is later moved or deleted fails at renewal rather than at issuance.

**`No TXT record found at _acme-challenge.example.com`, reported by the ACME server**
The record was written, but the ACME server did not see it. Two causes actually produce this:

- The wait was too short. Keep `--dns-nexdns-propagation-seconds` at 60 or raise it.
- The domain is not delegated to the NexDNS nameservers, so the record is published in a zone nobody queries for that name. Check the delegation with `dig NS example.com`.

To watch the record appear, run this from a second terminal while Certbot is in its propagation wait, since the record is cleaned up as soon as the attempt ends:

```bash
dig +short TXT _acme-challenge.example.com @1.1.1.1
```

Certbot writes the full detail of any failure to `/var/log/letsencrypt/letsencrypt.log`. Reproduce with `--dry-run -v`, which runs against the staging CA and so does not spend production issuance quota on a failing attempt.

## Development

```bash
git clone https://github.com/nexdns/certbot-dns-nexdns
cd certbot-dns-nexdns
pip install -e .[dev] certbot
python -m pytest
```

The unit tests mock the HTTP session, so they need neither an account nor network access.

Installing is not the same as being loadable: Certbot discovers plugins through entry points, and a load failure is reported to the user as this plugin's bug. Check discovery the way CI does:

```bash
certbot plugins 2>&1 | grep dns-nexdns
```

For end-to-end work, issue against Let's Encrypt's staging CA. Its certificates are untrusted, which is the point, and a failing run there costs no production issuance quota:

```bash
certbot certonly \
    --server https://acme-staging-v02.api.letsencrypt.org/directory \
    --authenticator dns-nexdns \
    --dns-nexdns-credentials ~/.nexdns/certbot-credentials.ini \
    -d '*.example.com'
```

## Links

- **PyPI**: [pypi.org/project/certbot-dns-nexdns](https://pypi.org/project/certbot-dns-nexdns/)
- **Docker Hub**: [hub.docker.com/r/nexdns/certbot](https://hub.docker.com/r/nexdns/certbot)
- **ACME guide**: [nexdns.tech/docs/acme](https://nexdns.tech/docs/acme)
- **NexDNS**: [nexdns.tech](https://nexdns.tech)
- **Certbot documentation**: [certbot.eff.org](https://certbot.eff.org/)
- **CLI**: [github.com/nexdns/cli](https://github.com/nexdns/cli)
- **Terraform provider**: [github.com/nexdns/terraform-provider-nexdns](https://github.com/nexdns/terraform-provider-nexdns)
- **OctoDNS provider**: [github.com/nexdns/octodns-nexdns](https://github.com/nexdns/octodns-nexdns)
- **Issues**: [github.com/nexdns/certbot-dns-nexdns/issues](https://github.com/nexdns/certbot-dns-nexdns/issues)

## License

[Apache-2.0](LICENSE)
