Metadata-Version: 2.5
Name: certbot-dns-muumuu
Version: 0.1.0
Summary: Muumuu Domain DNS Authenticator plugin for Certbot
Project-URL: Homepage, https://github.com/kenchan/certbot-dns-muumuu
Project-URL: Issues, https://github.com/kenchan/certbot-dns-muumuu/issues
Author: Kenichi Takahashi
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: acme,certbot,dns-01,letsencrypt,muumuu-domain
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Plugins
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Topic :: System :: Installation/Setup
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Requires-Dist: certbot>=4.0.0
Requires-Dist: requests>=2.32.4
Description-Content-Type: text/markdown

# certbot-dns-muumuu

A [Certbot](https://certbot.eff.org/) DNS Authenticator plugin for
[Muumuu Domain](https://muumuu-domain.com/) (ムームードメイン). It completes ACME `dns-01`
challenges by creating, and then removing, `_acme-challenge` TXT records through the
[Muumuu Domain API v2](https://muumuu-domain.com/developers/).

With `dns-01` you can obtain wildcard certificates (`*.example.com`) and certificates for hosts
that are not reachable from the Internet.

## Requirements

- Python 3.10 or later and Certbot 4.0 or later
- A domain registered at Muumuu Domain whose nameservers are set to **Muumuu DNS**
  (ムームーDNS, API `setup-type` `muumuu_dns`)
- A Muumuu Domain Personal Access Token (PAT) with the `domains:read`, `dns:read` and
  `dns:write` scopes

## Installation

The plugin is published on [PyPI](https://pypi.org/project/certbot-dns-muumuu/).

### pip

Install the plugin into the same Python environment as Certbot (e.g. the virtualenv set up by
[Certbot's pip instructions](https://certbot.eff.org/instructions?ws=other&os=pip)):

```sh
pip install certbot-dns-muumuu
```

### Docker

Extend the official image:

```dockerfile
FROM certbot/certbot
RUN pip install --no-cache-dir certbot-dns-muumuu
```

```sh
docker build -t certbot-dns-muumuu .
docker run --rm -it \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/lib/letsencrypt:/var/lib/letsencrypt \
  -v ~/.secrets/certbot:/secrets:ro \
  certbot-dns-muumuu certonly \
  --authenticator dns-muumuu \
  --dns-muumuu-credentials /secrets/muumuu.ini \
  -d example.com -d '*.example.com'
```

### snap

Not supported. The Certbot snap only loads third-party plugins that are packaged as snaps
themselves, and this plugin is not. Use pip or Docker instead.

### Verify

Check that Certbot sees the plugin:

```sh
certbot plugins
# * dns-muumuu
# Description: Obtain certificates using a DNS TXT record (if you are using Muumuu DNS for DNS).
```

## Issuing a Personal Access Token

Issue a PAT from the Muumuu Domain developer portal / control panel
(<https://muumuu-domain.com/developers/>) with these scopes:

| Scope          | Used for                                                       |
| -------------- | -------------------------------------------------------------- |
| `domains:read` | Finding the registered domain (`GET /me/domains?fqdn=...`)     |
| `dns:read`     | Reading the nameserver setting and listing TXT records         |
| `dns:write`    | Creating and deleting the `_acme-challenge` TXT records        |

A token with `dns:write` can modify every DNS record of every domain in the account, so treat it
like your account password. Certbot renews certificates unattended, so pick an expiry that
outlives your renewal schedule (or none) and rotate it deliberately.

## Credentials file

```ini
# ~/.secrets/certbot/muumuu.ini
dns_muumuu_token = muu_pat_0123456789abcdef

# Optional: API endpoint, defaults to https://muumuu-domain.com/api/v2
# dns_muumuu_endpoint = https://api-sandbox.muumuu-domain.com/api/v2
```

Restrict access to it; Certbot warns when the file is readable by other users:

```sh
chmod 600 ~/.secrets/certbot/muumuu.ini
```

Certbot stores the path to this file (not its contents) in the renewal configuration, so keep it
in place for renewals.

The file is checked before any API call:

- the token must start with `muu_pat_`;
- a custom endpoint must be an `https://` URL without a query string or fragment;
- a sandbox token (`muu_pat_sandbox_...`) is rejected for the production endpoint, whether it is
  the default or set explicitly, because the production API refuses it.

## Usage

```sh
certbot certonly \
  --authenticator dns-muumuu \
  --dns-muumuu-credentials ~/.secrets/certbot/muumuu.ini \
  -d example.com \
  -d '*.example.com'
```

| Option                               | Description                                                                 |
| ------------------------------------ | --------------------------------------------------------------------------- |
| `--dns-muumuu-credentials`           | Path to the credentials INI file (required)                                 |
| `--dns-muumuu-propagation-seconds`   | Seconds to wait before asking the ACME server to validate (default: `30`)   |

Sub-domains and multi-label TLDs work as expected: for `-d www.example.co.jp` the plugin tries
`www.example.co.jp`, `example.co.jp` and `co.jp` against the domains in your account and uses the
first exact match. Bare TLDs such as `jp` are not queried.

## How it works

1. Find the domain ID (`MU` + 8 digits) with `GET /me/domains?fqdn=<candidate>`. This happens
   once per registered domain, so `-d a.example.com -d b.example.com -d '*.example.com'` needs a
   single lookup.
2. Check `GET /me/domains/{id}/nameservers` and log a warning when `setup-type` is not
   `muumuu_dns`. If this check itself fails, it is skipped rather than aborting issuance.
3. Create the record with `POST /me/domains/{id}/dns-records`
   (`{"fqdn": "_acme-challenge.example.com", "type": "TXT", "value": "<token>"}`).
   If an identical record already exists (HTTP 409, e.g. after an interrupted run), it is reused.
4. Wait `--dns-muumuu-propagation-seconds`, then let the ACME server validate.
5. Delete exactly the record created in step 3 with `DELETE /me/domains/{id}/dns-records/{record-id}`.
   If its ID is unknown, the TXT records of that name are listed and only the one whose value
   equals the validation token is deleted. Other records are never touched. Cleanup failures are
   logged instead of aborting Certbot, and a failed deletion does not stop the remaining ones.

Every request is attempted up to 3 times:

- HTTP 429 and 503 wait for `Retry-After` (seconds or an HTTP date); 429 without the header
  waits 60 seconds. If the server asks for more than 300 seconds, the plugin gives up
  immediately instead.
- HTTP 500/502/503/504 without `Retry-After`, connection errors and timeouts are retried after
  1 and then 2 seconds.
  Retrying a `POST` is safe: if the first attempt did create the record, the retry gets
  HTTP 409 and the existing record is used.

Other errors are reported as Certbot errors naming the request that failed. HTTP 401/403 errors
come with a hint about the token and its scopes. Responses that do not match the documented
shape (wrong types, missing fields, non-JSON bodies) are reported the same way rather than as
Python tracebacks.

## Behaviour verified against the production API

The following was checked on 2026-10-06 by creating and deleting `_acme-challenge` TXT records
on a real domain with the production API and querying `dns01.muumuu-domain.com` /
`dns02.muumuu-domain.com` directly:

- **Multiple TXT records with the same name coexist.** Two records with the same `fqdn` and
  different values were both created (HTTP 201) and both served, so `example.com` and
  `*.example.com` can be validated in the same run.
- **Exact duplicates are rejected** with HTTP 409 `conflict`
  ("A record with the same fqdn, type, and value already exists").
- **TXT values are returned unquoted** (e.g. `did=did:plc:...`) and `fqdn` is returned with a
  trailing dot. The `fqdn` filter on `GET .../dns-records` accepts it with or without the dot.
- **Deleting a missing record returns HTTP 404**, which the plugin treats as already deleted.
- **Bare TLDs are rejected:** `GET /me/domains?fqdn=com` returns HTTP 400, while `fqdn=co.jp`
  returns an empty list.
- **Propagation:** new records were answered authoritatively by both Muumuu DNS servers within
  about 2 seconds. The default of 30 seconds leaves a wide margin for slower updates; raise
  `--dns-muumuu-propagation-seconds` if validation fails with "no TXT record found".
- **End-to-end issuance:** a certificate for `example.com` + `*.example.com` (a `.com` domain
  delegated to Muumuu DNS) was issued from the Let's Encrypt staging environment with the default
  settings, and `certbot renew --dry-run` succeeded. Both challenge records were removed
  afterwards.
- **TTL 3600 did not get in the way:** the dry run, about one minute after the first issuance,
  validated new challenge values for the same `_acme-challenge` name.

## Limitations

- **The nameservers must be Muumuu DNS.** The API stores records for a domain regardless of its
  nameserver setting, but they are only visible on the public DNS when the domain is delegated to
  Muumuu DNS. Other settings (`custom`, `acquired_domain`, `lolipop`, `parking`, ...) only cause a
  warning, because some of them may still point at Muumuu DNS; validation will fail if they don't.
- **TTL is fixed at 3600 seconds** by the API and cannot be changed (other providers' plugins
  use 10 to 120 seconds). A resolver that cached an earlier challenge could in theory serve
  stale values for up to an hour. Let's Encrypt was not affected in testing (see above).
- **At most 200 records per domain** (excluding SOA). Creating a record beyond that fails with
  "Record limit exceeded".
- **Rate limit:** 1,000 authenticated requests per hour.
- **Internationalized domain names are untested.** Certbot passes them in Punycode
  (`xn--...`); whether the API matches that form has not been verified.

## Development

```sh
uv sync
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv build

# Minimum supported versions
uv run --isolated --python 3.10 --with-editable . \
  --with certbot==4.0.0 --with acme==4.0.0 --with requests==2.32.4 \
  --with pytest --with responses pytest
```

## Releasing

Releases are automated with [tagpr](https://github.com/Songmu/tagpr) and PyPI
[trusted publishing](https://docs.pypi.org/trusted-publishers/):

1. Every push to `main` creates or updates a release pull request that bumps the version in
   `pyproject.toml` / `uv.lock` and adds a `CHANGELOG.md` entry generated from the merged pull
   requests. Label it `tagpr:minor` or `tagpr:major` for a bigger bump than a patch release.
2. Merging the release pull request tags the commit and creates the GitHub release.
3. The same workflow then tests and builds that tag, publishes it to PyPI from the `pypi`
   environment, and attaches the distributions to the GitHub release.

If publishing fails after the tag was created, run the `tagpr` workflow manually with the tag
to publish it again.

The release flow depends on settings that live outside the repository. Recreate them if the
repository or the PyPI project changes hands:

- GitHub: Settings → Actions → General → "Allow GitHub Actions to create and approve pull
  requests" is enabled.
- GitHub: an environment named `pypi` whose deployment branches are limited to `main`.
- PyPI: a trusted publisher for owner `kenchan`, repository `certbot-dns-muumuu`, workflow
  `tagpr.yml` and environment `pypi`.

Release notes are generated from merged pull requests, so land changes through pull requests
rather than direct pushes to `main`.

## License

Apache License 2.0. See [LICENSE](LICENSE).
