Metadata-Version: 2.4
Name: certinext
Version: 0.3.0rc1
Summary: Python client library for the CertiNext certificate management API
Author-email: University of Maine System <tod.detre@maine.edu>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/tod-uma/certinext
Project-URL: Repository, https://github.com/tod-uma/certinext
Project-URL: Issue Tracker, https://github.com/tod-uma/certinext/issues
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python :: 3
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: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: tabulate>=0.9.0
Requires-Dist: structlog
Requires-Dist: tomli>=2.0; python_version < "3.11"
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pyright>=1.1; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: types-requests>=2.31; extra == "dev"
Requires-Dist: types-tabulate>=0.9; extra == "dev"
Requires-Dist: keyring; extra == "dev"
Requires-Dist: cryptography>=42.0.0; extra == "dev"
Requires-Dist: dnspython>=2.0; extra == "dev"
Provides-Extra: keyring
Requires-Dist: keyring; extra == "keyring"
Provides-Extra: csr
Requires-Dist: cryptography>=42.0.0; extra == "csr"
Provides-Extra: dns
Requires-Dist: dnspython>=2.0; extra == "dns"
Dynamic: license-file

# certinext

[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

Python library and CLI scripts for managing your [CertiNext](https://us.certinext.io) environment via the REST API.

## Contents

- [Requirements](#requirements)
- [Installation](#installation)
- [Credentials](#credentials)
- [Using the CLI tools](#using-the-cli-tools)
  - [certinext-setup-keyring](#certinext-setup-keyring)
  - [certinext-setup-defaults](#certinext-setup-defaults)
  - [certinext-accounts](#certinext-accounts)
  - [certinext-domains](#certinext-domains)
  - [certinext-ledger](#certinext-ledger)
  - [certinext-list-certificates](#certinext-list-certificates)
  - [certinext-pending-dcv](#certinext-pending-dcv)
  - [certinext-domain-cert-count](#certinext-domain-cert-count)
  - [certinext-issue-cert](#certinext-issue-cert)
  - [certinext-parent-dcv-status](#certinext-parent-dcv-status)
- [Log output](#log-output)
- [Python library](#python-library)
- [Examples](#examples)
- [API documentation](#api-documentation)
- [Project structure](#project-structure)

## Requirements

- Python 3.10+ (installed automatically when using uv — see [Installation](#installation))
- A CertiNext account with OAuth API credentials (account number + client secret)

**Runtime dependencies** (`requests`, `tabulate`, `structlog`) are installed automatically. `structlog` provides structured logging for the CLI tools and library internals — in cron or redirected contexts all output is emitted as JSON; in a terminal it uses a human-readable format. If you use certinext purely as a library and have a strong reason to exclude `structlog`, open an issue and we'll consider making it optional.

## Installation

All instructions use [uv](https://docs.astral.sh/uv/). You don't need
Python installed first — uv downloads and manages Python for you.

To install the `certinext-*` CLI tools (issuing certificates, listing
domains, etc.):

```bash
uv tool install "certinext[csr,keyring]"
```

That's the whole install — the commands now work from any terminal. (If a
command isn't found, run `uv tool update-shell` and open a new terminal.)
The two extras are recommended for CLI use: `csr` enables CSR parsing for
`certinext-issue-cert`, and `keyring` lets the commands store and read
credentials in the OS keychain.

Everything else — installing uv itself, library use, pre-releases, the UMS
GitLab registry, development installs, and pip/pipx equivalents — is in the
collapsible sections below.

<details>
<summary>Install uv (one-time, Windows / macOS / Linux)</summary>

**Windows** (PowerShell):

```powershell
winget install --id=astral-sh.uv -e
```

**macOS / Linux**:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

(On macOS, `brew install uv` also works.)

Then open a **new** terminal so `uv` is on your PATH. The first time a
command needs Python, uv downloads a suitable version automatically.

</details>

<details>
<summary>All uv install variants (library use, pre-releases, UMS GitLab registry, development)</summary>

**Library in your project** — if you want `import certinext` in your own
code, add it as a dependency of your uv-managed project:

```bash
uv add certinext
```

Add extras only if your code uses them: `certinext[csr]` (CSR parsing),
`certinext[keyring]` (OS keychain credential lookup), `certinext[dns]`
(DNS lookups).

**Pre-releases** — to get the latest alpha, beta, or release candidate:

```bash
uv tool install --prerelease=allow "certinext[csr,keyring]"   # CLI tools
uv add certinext --prerelease=allow                           # library
```

**From the UMS GitLab package registry** — releases are also published to
the UMS GitLab package registry:

```bash
uv tool install certinext \
  --extra-index-url https://gitlab.its.maine.edu/api/v4/groups/2236/-/packages/pypi/simple
```

**Development install** — clone the repository, then create a venv and
install in editable mode with the `dev` extra (test and lint toolchain plus
all runtime extras):

```bash
uv venv
.venv\Scripts\activate        # Windows
# source .venv/bin/activate   # macOS / Linux

uv pip install -e ".[dev]"
```

</details>

<details>
<summary>Using pip or pipx instead of uv</summary>

All of the above with pip or pipx (both require Python 3.10+ already
installed).

**CLI tools** — with [pipx](https://pipx.pypa.io/) (isolated install, like
`uv tool`):

```bash
pipx install "certinext[csr,keyring]"
pipx install --pip-args=--pre "certinext[csr,keyring]"   # pre-release
```

Or with plain pip (installs into the active Python environment, not
isolated):

```bash
pip install "certinext[csr,keyring]"
pip install --pre "certinext[csr,keyring]"               # pre-release
```

**Library in your project**:

```bash
pip install certinext
pip install --pre certinext        # pre-release
```

**Optional extras** — add any of `csr`, `keyring`, or `dns` after the fact,
e.g. the `keyring` extra needed by `certinext-setup-keyring`:

```bash
pip install "certinext[keyring]"
```

**From the UMS GitLab package registry**:

```bash
pip install certinext \
  --extra-index-url https://gitlab.its.maine.edu/api/v4/groups/2236/-/packages/pypi/simple
```

**Development install**:

```bash
python -m venv .venv
.venv\Scripts\activate        # Windows
# source .venv/bin/activate   # macOS / Linux

pip install -e ".[dev]"
```

</details>

## Credentials

The CLI tools and Python library both talk to the CertiNext **REST API**,
so you need REST API (OAuth) credentials — your portal username and
password won't work. Generate the two required values in the CertiNext
portal under **Integrations → APIs → OAuth mode**:

| Value | Description |
|---|---|
| Account number | Your CertiNext account number (used as the OAuth `client_id`) |
| Client secret | The OAuth access key generated in the portal |
| Prevetting token | Optional, for auto-approving OV/EV orders — see [Prevetting token](#prevetting-token-optional-ovev-orders) |

The token endpoint defaults to `https://us-api.certinext.io/oauth/token`. Override with `--token-url` if yours differs.

### Storing credentials in the OS keychain (recommended)

Run the setup command once to store your credentials securely in the system
keychain (Windows Credential Manager on Windows, Keychain on macOS,
libsecret/SecretService on Linux):

```bash
certinext-setup-keyring
```

This needs the `keyring` extra. It's included in the recommended
`uv tool install "certinext[csr,keyring]"` from
[Installation](#installation); pip users can add it with
`pip install "certinext[keyring]"`.

Scripts read credentials from the keychain automatically — no CLI flags or
environment variables needed for day-to-day use.

<details>
<summary>Named profiles and credential resolution order</summary>

#### Named profiles

Use `--profile NAME` to store multiple credential sets (e.g. different
accounts or environments):

```bash
certinext-setup-keyring --profile prod
```

Select a profile at runtime with `--profile` or the `CERTINEXT_PROFILE`
environment variable:

```bash
certinext-domains --profile prod list
CERTINEXT_PROFILE=prod certinext-pending-dcv
```

#### Credential resolution order

All scripts resolve credentials in this priority order:

1. Explicit CLI argument (`--account-number`, `--client-secret`)
2. OS keychain (active profile; see above)
3. Environment variables (`CERTINEXT_CLIENT_ID`, `CERTINEXT_CLIENT_SECRET`)
4. Interactive prompt (falls back to `getpass` for secrets)

</details>

<details>
<summary>WSL and headless Linux (no keyring backend)</summary>

#### WSL and headless Linux

On Linux, `keyring` needs a running Secret Service daemon (gnome-keyring or
KWallet). WSL and headless servers usually have none, so `certinext-setup-keyring`
reports that no usable OS keyring backend was found. Options:

- **Skip the keyring.** All scripts fall back to the `CERTINEXT_CLIENT_ID` and
  `CERTINEXT_CLIENT_SECRET` environment variables (see the resolution order
  above).

- **WSL: bridge to the Windows Credential Manager** with
  [keyring-pybridge](https://pypi.org/project/keyring-pybridge/), which
  forwards keyring calls to a Python interpreter on the Windows host.
  Credentials are then shared between Windows and WSL.

  ```bash
  # Prerequisite: a Windows-side Python with the keyring package installed
  pip install keyring-pybridge
  export PYTHON_KEYRING_BACKEND=keyring_pybridge.PyBridgeKeyring
  export KEYRING_PROPERTY_PYTHON='C:\path\to\python.exe'
  certinext-setup-keyring
  ```

  Add the two `export` lines to your shell profile so every session uses the
  bridge.

- **Headless Linux: start a Secret Service daemon** such as gnome-keyring.

</details>

### Prevetting token (optional, OV/EV orders)

OV and EV certificate orders normally pause at a manual approval step at
the CA before issuance. If your organization has consent configured, an
**Organization Consent Token** (prevetting token) lets the CA auto-approve
the order — useful when you want `certinext-issue-cert` to run end-to-end
without a human approving each order.

Find it in the CertiNext portal under **Organization Management →
Organization Consent / Consent Tokens** for the target organization, and
pass it per run:

```bash
certinext-issue-cert example.com.csr --type ov --org-id 8921215 \
  --prevetting-token TOKEN
```

The token is a secret — it is never written to the config file by
`--save-defaults` or `certinext-setup-defaults`.

### Storing issue-cert defaults (optional)

Store the values `certinext-issue-cert` needs on every run — requestor
identity, certificate type, org ID, validity — so that issuing a certificate
is just:

```bash
certinext-issue-cert new.csr
```

Run the interactive setup once:

```bash
certinext-setup-defaults
```

Or pass `--save-defaults` on any `certinext-issue-cert` run to capture the
values you used. Or hand-edit the config file
(`~/.config/certinext/config.toml` on Linux/macOS,
`%APPDATA%\certinext\config.toml` on Windows, override with
`CERTINEXT_CONFIG`):

```toml
[defaults]
requestor_name  = "Jane Doe"
requestor_email = "jane@maine.edu"
requestor_phone = "+12075551234"
signer_place    = "Orono, ME"
type            = "ov"
org_id          = "12345"
validity        = 1

[profiles.sandbox]
# overrides applied when --sandbox / --profile sandbox is active
type = "dv"
```

Values resolve in priority order: explicit CLI argument → environment
variable → `[profiles.NAME]` → `[defaults]` → built-in default. Secrets
(client secret, prevetting token) are never stored here — use
`certinext-setup-keyring` for credentials.

### Sandbox environment

A sandbox environment is available at `https://sandbox-us-api.certinext.io` for
testing API calls without affecting production data.  Store sandbox credentials
once with:

```bash
certinext-setup-keyring --sandbox
```

Then pass `--sandbox` to any CLI command to target the sandbox:

```bash
certinext-accounts --sandbox
certinext-domains --sandbox list
certinext-ledger --sandbox
certinext-list-certificates --sandbox
certinext-pending-dcv --sandbox
certinext-domain-cert-count --sandbox
```

`--sandbox` is a shortcut that sets `--base-url` and `--token-url` to the
sandbox endpoints and defaults `--profile` to `sandbox`.

### Integration tests

The test suite includes integration tests that call the live sandbox API.
They are skipped automatically when credentials are not available, so they
are safe to include in CI environments that lack a keyring.

**Local development** — store credentials in the keyring once:

```bash
certinext-setup-keyring --sandbox
pytest -m integration
```

**GitLab CI** — set two CI/CD Variables in the project's Settings → CI/CD → Variables:

| Variable | Description |
|---|---|
| `CERTINEXT_SANDBOX_CLIENT_ID` | Sandbox account number (client ID) |
| `CERTINEXT_SANDBOX_CLIENT_SECRET` | Sandbox client secret |

The pipeline includes a dedicated `integration-test` job that runs `pytest -m integration`
automatically whenever these variables are defined.

---

## Using the CLI tools

The complete copy-paste path from nothing to an issued certificate
([Installation](#installation) covers the install command and uv itself;
[Credentials](#credentials) covers where the two credential values come
from):

```bash
uv tool install "certinext[csr,keyring]"
certinext-setup-keyring      # store API credentials in the OS keychain (once)
certinext-setup-defaults     # store requestor/cert defaults (once, optional)
certinext-issue-cert example.com.csr --cert-out cert.pem --fullchain-out fullchain.pem
```

Each command is documented below.

### certinext-setup-keyring

`certinext-setup-keyring` stores CertiNext API credentials in the OS keychain
interactively. Run it once before using the other commands.

```bash
# Store credentials for the default profile
certinext-setup-keyring

# Store credentials for a named profile
certinext-setup-keyring --profile prod

# Store credentials for the sandbox environment
certinext-setup-keyring --sandbox
```

The script prompts for your account number and client secret, shows any
currently stored value as a default so you can keep it by pressing Enter, and
masks the secret with asterisks on confirmation.

### certinext-setup-defaults

`certinext-setup-defaults` interactively stores defaults for
`certinext-issue-cert` (requestor identity, certificate type, org ID,
validity) in the config file, so future issuance runs only need the CSR. See
[Storing issue-cert defaults](#storing-issue-cert-defaults-optional) for the
file format and resolution order.

```bash
# Edit the [defaults] section
certinext-setup-defaults

# Edit a profile section ([profiles.prod])
certinext-setup-defaults --profile prod

# Edit the sandbox profile
certinext-setup-defaults --sandbox
```

Each prompt shows the currently stored value — press Enter to keep it, or
enter `-` to clear it.

### certinext-accounts

`certinext-accounts` shows the current account identity, billing groups, and
pre-vetted organizations.

```bash
certinext-accounts
certinext-accounts --sandbox
certinext-accounts --json
```

| Argument | Description |
|---|---|
| `--json` | Output raw JSON instead of tabular format |

---

### certinext-domains

`certinext-domains` is a command-line interface for the domains API.

#### Common arguments

These appear before the subcommand. Credentials are optional when stored in the
keychain (see [Credentials](#credentials) above).

```
--profile NAME          Credential profile for keyring lookup (env: CERTINEXT_PROFILE)
--sandbox               Use the sandbox API and sandbox keyring profile
--account-number ACCT   CertiNext account number / client_id (env: CERTINEXT_CLIENT_ID)
--client-secret SECRET  OAuth2 client secret (env: CERTINEXT_CLIENT_SECRET)
--base-url URL          API base URL (default: https://us-api.certinext.io)
--token-url URL         Token endpoint URL (default: https://us-api.certinext.io/oauth/token)
--scope SCOPE           OAuth2 scope (optional)
--json                  Output raw JSON instead of tabular format
```

<details>
<summary>Subcommands</summary>

#### list

List all domains.

```bash
# credentials from keychain
certinext-domains list
certinext-domains list --offset 50 --limit 25

# credentials explicit
certinext-domains --account-number ACCT --client-secret SECRET list
```

#### get

Get a single domain by name or ID.

```bash
certinext-domains get maine.edu
certinext-domains get vuxwZgEXWWFXQQWC-...
```

#### create

Create a new domain. Additional API fields can be passed as `KEY=VALUE` pairs.

```bash
certinext-domains create newdomain.example.com
```

#### deactivate

Deactivate a domain by ID. Prompts for confirmation unless `-y` is passed.

```bash
certinext-domains deactivate DOMAIN_ID
certinext-domains deactivate DOMAIN_ID -y
```

#### get-dcv

Show current DCV status for a domain.

```bash
certinext-domains get-dcv DOMAIN_ID
```

#### verify-dcv

Trigger DCV verification for a domain.

```bash
certinext-domains verify-dcv DOMAIN_ID
```

#### change-dcv-method

Change the DCV method for a domain. Accepted values: `DNS-TXT`, `HTTP-URL`.

```bash
certinext-domains change-dcv-method DOMAIN_ID DNS-TXT
```

#### last-dcv-attempt

Show the most recent DCV attempt for a domain.

```bash
certinext-domains last-dcv-attempt DOMAIN_ID
```

#### dcv-attempt-history

Show the full DCV attempt history for a domain.

```bash
certinext-domains dcv-attempt-history DOMAIN_ID
```

</details>

#### JSON output

Add `--json` before the subcommand to get raw JSON instead of the default tabular output. Useful for piping into `jq`:

```bash
certinext-domains --json list | jq '.[] | .domainName'
```

### certinext-ledger

`certinext-ledger` shows the account transaction history (all debits, credits,
and running balance) with automatic pagination.

#### Arguments

```
--last N   Show only the N most recent transactions
--json     Output raw JSON instead of tabular format
```

#### Examples

```bash
certinext-ledger
certinext-ledger --last 20
certinext-ledger --sandbox --json
```

---

### certinext-list-certificates

`certinext-list-certificates` lists all SSL/TLS certificate orders from the
orders report. Use `--status` to filter by lifecycle status.

#### Arguments

```
--status STATUS   Filter by certificate status (issued, expired, pending-dcv, etc.)
--json            Output raw JSON instead of tabular format
```

#### Examples

```bash
certinext-list-certificates
certinext-list-certificates --status issued
certinext-list-certificates --status expired
certinext-list-certificates --status pending-dcv
certinext-list-certificates --sandbox --json
```

---

### certinext-pending-dcv

`certinext-pending-dcv` lists every active domain that has not yet completed
DCV verification. It is a quick read-only diagnostic — no changes are made to
any domain.

#### Arguments

```
--profile NAME          Credential profile for keyring lookup (env: CERTINEXT_PROFILE)
--sandbox               Use the sandbox API and sandbox keyring profile
--account-number ACCT   CertiNext account number (env: CERTINEXT_CLIENT_ID)
--client-secret SECRET  OAuth2 client secret (env: CERTINEXT_CLIENT_SECRET)
--base-url URL          API base URL (default: https://us-api.certinext.io)
--token-url URL         Token endpoint URL (default: https://us-api.certinext.io/oauth/token)
--pattern REGEX         Filter by domain name regex (re.fullmatch, case-insensitive)
--json                  Output raw JSON instead of tabular format
```

#### Examples

```bash
# Credentials from keychain (no flags needed after setup)
certinext-pending-dcv

# Use a named profile
certinext-pending-dcv --profile prod

# Filter to a specific subdomain pattern
certinext-pending-dcv --pattern ".*\.maine\.edu"

# Raw JSON output for scripting
certinext-pending-dcv --json | jq '.[] | .domainName'

# Credentials from environment variables
CERTINEXT_CLIENT_ID=ACCT CERTINEXT_CLIENT_SECRET=SECRET certinext-pending-dcv
```

### certinext-domain-cert-count

`certinext-domain-cert-count` shows all registered domains and how many
certificates each one has. It fetches the domain list and the orders report,
then matches each certificate to its most specific registered domain by suffix
— a cert for `host.subdomain.example.org` counts toward `subdomain.example.org`
when that domain is registered, rather than the less-specific `example.org`.

#### Arguments

```
--profile NAME           Credential profile for keyring lookup (env: CERTINEXT_PROFILE)
--sandbox                Use the sandbox API and sandbox keyring profile
--account-number ACCT    CertiNext account number (env: CERTINEXT_CLIENT_ID)
--client-secret SECRET   OAuth2 client secret (env: CERTINEXT_CLIENT_SECRET)
--base-url URL           API base URL (default: https://us-api.certinext.io)
--token-url URL          Token endpoint URL (default: https://us-api.certinext.io/oauth/token)
--status issued|expired  Filter to only issued or only expired certificates
--condense               Show only top-level domains; subdomain counts roll up into their apex
--json                   Output raw JSON instead of tabular format
```

#### Examples

```bash
# All certificates, all statuses (credentials from keychain)
certinext-domain-cert-count

# Only issued (active) certificates
certinext-domain-cert-count --status issued

# Only expired certificates
certinext-domain-cert-count --status expired

# Collapse subdomains — subdomain.example.org rolls into example.org
certinext-domain-cert-count --condense

# Condense + issued only
certinext-domain-cert-count --condense --status issued

# Raw JSON for scripting
certinext-domain-cert-count --json | jq '.[] | select(.certificates != "0")'
```

### certinext-issue-cert

`certinext-issue-cert` submits a CSR to CertiNext and downloads the issued
certificate.  It reads the domain and SANs directly from the CSR, creates a
certificate order, handles the full lifecycle (agreement, DCV if needed, CSR
submission), and writes the signed PEM to stdout or a file once the CA has
issued it.

Requires the `csr` optional extra — included in the recommended
`uv tool install "certinext[csr,keyring]"` from
[Installation](#installation).

#### Arguments

```
# Connection
--profile NAME              Credential profile for keyring lookup (env: CERTINEXT_PROFILE)
--sandbox                   Use the sandbox API and sandbox keyring profile
--account-number ACCT       CertiNext account number (env: CERTINEXT_CLIENT_ID)
--client-secret SECRET      OAuth2 client secret (env: CERTINEXT_CLIENT_SECRET)

# Certificate
csr_file                    PEM-encoded CSR file (positional; omit to read from stdin)
--csr FILE                  Same as positional argument
--type dv|ov|ev             Validation type (default: dv)
--validity YEARS            Validity in years: 1, 2, or 3 (default: 1)
--org-id ID                 Organization ID — required for OV and EV certificates
--domain FQDN               Override the primary domain (default: extracted from CSR CN)
--san FQDN                  Override SANs (default: extracted from CSR; repeatable)
--auto-secure-www           Request automatic www-redirect coverage (API default: true)

# Requestor (can also be set via environment variables)
--requestor-name NAME       Full name of the requestor (env: CERTINEXT_REQUESTOR_NAME)
--requestor-email EMAIL     Email address of the requestor (env: CERTINEXT_REQUESTOR_EMAIL)
--requestor-phone PHONE     Phone in E.164 format, e.g. +12075551234 (env: CERTINEXT_REQUESTOR_PHONE)
--requestor-designation TTL Job title or designation (env: CERTINEXT_REQUESTOR_DESIGNATION)
--signer-place PLACE        City/location for the subscriber agreement (env: CERTINEXT_SIGNER_PLACE)

# Output / control
-o FILE, --output FILE      Write the certificate PEM to FILE (default: stdout)
--cert-out FILE             Write only the end-entity (leaf) certificate PEM to FILE
--chain-out FILE            Write only the intermediate CA chain PEM to FILE
--fullchain-out FILE        Write the leaf-first fullchain PEM (leaf + intermediates) to FILE
--wait SECONDS              Seconds to wait for issuance (default: 300; 0 = submit and exit)
--order-id ID               Resume polling an existing order instead of creating a new one
--save-defaults             Store the effective requestor/certificate values as config defaults
-v, --verbose               Increase verbosity (-vvv for debug logging)
```

Requestor and certificate values can also come from stored defaults — see
[Storing issue-cert defaults](#storing-issue-cert-defaults-optional).

#### Examples

```bash
# DV certificate — credentials and requestor info from keychain / env vars
certinext-issue-cert example.com.csr

# Read CSR from stdin
certinext-issue-cert < example.com.csr

# Save certificate to a file
certinext-issue-cert example.com.csr --output example.com.pem

# Write leaf, intermediate chain, and fullchain to separate files
# (the layout nginx, Apache, and HAProxy configs typically expect)
certinext-issue-cert example.com.csr --cert-out cert.pem --chain-out chain.pem --fullchain-out fullchain.pem

# OV certificate with explicit org
certinext-issue-cert example.com.csr --type ov --org-id 8921215

# Two-year DV certificate against the sandbox
certinext-issue-cert example.com.csr --validity 2 --sandbox

# Submit and exit immediately without waiting for issuance
certinext-issue-cert example.com.csr --wait 0

# Resume polling an order created in a previous run
certinext-issue-cert --order-id ORDER-ID --wait 600

# Resume and supply the CSR (in case the order is still in pending-csr)
certinext-issue-cert --order-id ORDER-ID --csr example.com.csr

# Capture the values used on this run as defaults for future runs
certinext-issue-cert example.com.csr --type ov --org-id 8921215 --save-defaults
```

To avoid repeating requestor flags on every call, store them once with
`certinext-setup-defaults` (or `--save-defaults` above), or set environment
variables (which take precedence over stored defaults):

```bash
export CERTINEXT_REQUESTOR_NAME="Jane Doe"
export CERTINEXT_REQUESTOR_EMAIL="jane.doe@example.com"
export CERTINEXT_REQUESTOR_PHONE="+12075551234"
export CERTINEXT_REQUESTOR_DESIGNATION="Systems Administrator"
export CERTINEXT_SIGNER_PLACE="Portland, ME"

certinext-issue-cert example.com.csr --output example.com.pem
```

#### Certificate lifecycle

The tool handles the full CertiNext order lifecycle automatically:

1. **`pending-approval`** — waits for CA approval (no action needed)
2. **`pending-agreement`** — accepts the subscriber agreement on your behalf
3. **`pending-dcv`** — logs challenge details and triggers verification; in
   environments where domains are pre-validated (e.g. University of Maine
   System), DCV auto-resolves without manual intervention
4. **`pending-csr`** — submits the provided CSR
5. **`issued`** — downloads and writes the PEM certificate chain

If the order does not reach `issued` within `--wait` seconds, the tool exits
with code 1 and prints the order ID so you can resume with `--order-id`.

### certinext-parent-dcv-status

`certinext-parent-dcv-status` shows DCV status and expiry for every domain
that requires direct DCV validation — either because it has no registered
ancestor in the account, or because its own NS records form a DNS zone
boundary that blocks DCV inheritance from a parent.

By default an NS lookup is performed for each domain to detect zone
boundaries (requires `certinext[dns]`). Use `--no-ns-check` to skip DNS
lookups and list only account-level parents.

#### Arguments

```
--pattern REGEX         Filter domains by regex before identifying parents (re.fullmatch)
--status STATUS         Filter by DCV status: all (default), verified, expiring, pending, expired
--expiring-days DAYS    Days ahead to flag as expiring soon (default: 30)
--json                  Output raw JSON instead of tabular format
--no-ns-check           Skip DNS NS lookups; list account-level parents only
-v, --verbose           Increase verbosity (-v shows progress, -vvv enables debug logging)
```

#### Examples

```bash
# All parent domains with DCV status
certinext-parent-dcv-status --sandbox

# Only domains expiring within 60 days
certinext-parent-dcv-status --status expiring --expiring-days 60

# Skip DNS NS checks (faster, account-level parents only)
certinext-parent-dcv-status --no-ns-check

# Raw JSON for scripting
certinext-parent-dcv-status --json | jq '.[] | select(.dcv_status != "VERIFIED")'
```

---

## Log output

All CLI scripts write diagnostic messages to **stderr**. The format adapts to
the environment automatically:

| Context | Format |
|---|---|
| Interactive terminal (TTY) | `HH:MM:SS [level] event  field=value …` — human-readable, local time |
| Non-TTY (cron, redirected stderr) | One JSON object per line — suitable for log aggregators and `jq` |

**Verbosity flags** (cumulative, same for all scripts):

| Flag | Effect |
|---|---|
| `-v` | Show extra context fields (`correlation_id`, `pid`, credential profile, domain filters) |
| `-vvv` | Enable DEBUG logging |
| `-vvvv` | Also enable third-party DEBUG output (urllib3, keyring) |

**Cron example** — capture JSON logs to a file:

```bash
certinext-parent-dcv-status --sandbox 2>> /var/log/certinext.log
```

Each line is a self-contained JSON object:

```json
{"timestamp": "2026-06-03T14:00:01.234Z", "level": "info", "event": "Connecting", "account": "5912517854", "profile": "default", "url": "https://us-api.certinext.io"}
{"timestamp": "2026-06-03T14:00:02.456Z", "level": "info", "event": "Fetched domains", "count": 234}
```

---

## Python library

### Creating a session

```python
import certinext

sess = certinext.session(
    client_id="YOUR_ACCOUNT_NUMBER",
    client_secret="YOUR_CLIENT_SECRET",
)
```

<details>
<summary>All session() parameters</summary>

```python
sess = certinext.session(
    client_id="YOUR_ACCOUNT_NUMBER",
    client_secret="YOUR_CLIENT_SECRET",
    scope="",           # optional
    sandbox=False,      # True → use sandbox endpoints automatically
    base_url="",        # override; defaults to production (or sandbox when sandbox=True)
    token_url="",       # override; defaults to match base_url
)
```

When `sandbox=True`, `base_url` and `token_url` default to the sandbox endpoints
(`https://sandbox-us-api.certinext.io`). Explicit `base_url` / `token_url` values
always take precedence over the `sandbox` flag.

</details>

The session obtains and caches an OAuth 2.0 bearer token automatically, refreshing it before it expires.

### Working with domains

#### List all domains

```python
domains = sess.domain.get_list()
for d in domains:
    print(d)
```

Paginate with `offset` and `limit`:

```python
page = sess.domain.get_list(offset=50, limit=25)
```

Filter by status server-side (reduces data transferred):

```python
# Only active domains with pending or rejected DCV
domains = sess.domain.get_list(domain_status="ACTIVE", dcv_status="PENDING,REJECTED,EXPIRED")
```

> **Note:** The API `search` parameter is partially fixed (re-tested 2026-06-05):
> exact FQDN matches now work, but substring searches (values without `.`) return
> 0 results instead of matching domains. Use `pattern` (below) for reliable
> filtering.

Filter by name with a regex (applied client-side after the API response):

```python
# Exact match
domains = sess.domain.get_list(pattern=r"maine\.edu")

# Multiple names via alternation
domains = sess.domain.get_list(pattern=r"maine\.edu|umaine\.edu")

# Subdomain wildcard
domains = sess.domain.get_list(pattern=r".*\.maine\.edu")
```

`pattern` uses `re.fullmatch` with `re.IGNORECASE`, so it must match the entire
domain name. Combine with status filters to narrow the API response first:

```python
domains = sess.domain.get_list(domain_status="ACTIVE", pattern=r".*\.maine\.edu")
```

#### List domains needing DCV

`get_pending_dcv()` returns active domains that have not yet completed DCV
verification. It fetches all domains and filters client-side using
`domain.needs_dcv`.

> **Note:** The API `domainStatus` and `dcvStatus` filter parameters return a
> 400 error when used together — confirmed vendor bug (reported 2026-05-20).
> Server-side status filtering is disabled until CertiNext notifies the fix is
> deployed.

```python
pending = sess.domain.get_pending_dcv()

# Narrow to a subset by name
pending = sess.domain.get_pending_dcv(pattern=r".*\.maine\.edu")
```

#### Get a domain

Look up by domain name or by domain ID:

```python
domain = sess.domain.get("maine.edu")
domain = sess.domain.get("vuxwZgEXWWFXQQWC-3zElI5VlhinKlE8xyYJqfeYNtFE0SAP")
```

When a name is passed (contains a `.`), the library lists all domains and finds the match. When an ID is passed, it calls the single-domain endpoint directly.

#### Create a domain

```python
domain = sess.domain.create("newdomain.example.com")
```

<details>
<summary>Domain properties and DcvInfo fields</summary>

#### Domain properties

| Property | Type | Description |
|---|---|---|
| `id` | `str \| None` | Domain ID |
| `name` | `str \| None` | Domain name (FQDN). Settable, but only updates the local object — does not persist to the API. |
| `status` | `str \| None` | `ACTIVE` or `INACTIVE` |
| `dcv_status` | `str \| None` | `VERIFIED`, `PENDING`, `REJECTED`, `EXPIRED`, etc. |
| `organization_id` | `str \| None` | Organization ID |
| `organization_name` | `str \| None` | Organization display name |
| `created_at` | `datetime \| None` | Creation timestamp (timezone-aware UTC) |
| `needs_dcv` | `bool` | `True` if status is `ACTIVE` and `dcv_status` is not `VERIFIED` |

`Domain` objects support `str()` and `repr()`:

```python
print(domain)
# Domain: maine.edu
#   id:              vuxwZgEXWWFXQQWC-...
#   status:          ACTIVE
#   dcv_status:      VERIFIED
#   organization:    University of Maine System
#   created:         2026-05-04 21:27:14+00:00

repr(domain)
# Domain(id='vuxwZgEXWWFXQQWC-...', name='maine.edu', status='ACTIVE', dcv_status='VERIFIED')
```

#### DcvInfo

`domain.get_dcv()` returns a `DcvInfo` dataclass with the following fields:

| Field | Type | Description |
|---|---|---|
| `method` | `str` | DCV method in upper case: `DNS-TXT` or `HTTP-URL` |
| `token` | `str` | Challenge value to publish (TXT record content for DNS-TXT, file token for HTTP-URL) |
| `host` | `str` | Sub-domain prefix for the challenge record (e.g. `_emudhra-challenge`). Empty string if not returned by the API. |

</details>

#### Domain methods

```python
# Re-fetch from API and update the object in place
domain.refresh()

# Deactivate (updates the object in place, returns self)
domain.deactivate()

# DCV — Domain Control Validation
dcv = domain.get_dcv()             # returns DcvInfo(method, token, host)
print(dcv.method)                  # e.g. "DNS-TXT" or "HTTP-URL"
print(dcv.token)                   # challenge value to publish
print(dcv.host)                    # sub-domain prefix for the challenge record

result = domain.verify()           # trigger verification; returns raw API response dict
domain.change_dcv_method("DNS-TXT")   # accepted values: "DNS-TXT", "HTTP-URL"
attempt = domain.last_dcv_attempt()   # returns raw API response dict
history = domain.dcv_attempt_history() # returns raw API response dict or list

# Get the raw API response dict
raw = domain.as_dict()
```

#### Example: verify all pending domains

```python
import certinext

sess = certinext.session(
    client_id="YOUR_ACCOUNT_NUMBER",
    client_secret="YOUR_CLIENT_SECRET",
)

# Due to a vendor API bug, server-side status filtering is currently disabled.
# get_pending_dcv() fetches all domains and filters client-side for needs_dcv.
for domain in sess.domain.get_pending_dcv():
    print(f"Verifying {domain.name} ...")
    domain.verify()
```

Or check `needs_dcv` manually if you already have a full domain list:

```python
for domain in sess.domain.get_list():
    if domain.needs_dcv:
        print(f"Verifying {domain.name} ...")
        domain.verify()
```

### Working with orders

`sess.orders` provides access to the CertiNext orders report API
(`GET /api/certinext/v2/reports/orders`).

#### Fetch all orders

```python
orders = sess.orders.get_list()
for o in orders:
    print(o.common_name, o.certificate_status)
```

Filter by certificate status:

```python
issued = sess.orders.get_list(status="issued")
expired = sess.orders.get_list(status="expired")
```

`get_list()` paginates automatically. Use `get_page()` for manual control:

```python
page = sess.orders.get_page(page=1, size=50, status="issued")
```

#### OrderRecord properties

| Property | Type | Description |
|---|---|---|
| `order_number` | `str \| None` | CertiNext order number |
| `request_number` | `str \| None` | Request number |
| `product_code` | `str \| None` | Product code (e.g. `OV_SSL`, `DV_SSL`) |
| `order_status` | `str \| None` | Order lifecycle status (e.g. `complete`) |
| `certificate_status` | `str \| None` | Certificate status (`issued`, `expired`, etc.) |
| `common_name` | `str \| None` | Certificate common name (hostname or domain) |

```python
o.as_dict()   # raw API response dict
o.to_row()    # flat dict[str, str] for tabular display
repr(o)       # OrderRecord(order_number='ORD-001', common_name='example.org', ...)
```

---

### Working with accounts

`sess.accounts` exposes the authenticated account identity, billing groups, and
pre-vetted organizations.

```python
me = sess.accounts.me()
print(me.account_number, me.account_name, me.account_type)

groups = sess.accounts.list_groups()
for g in groups:
    print(g.group_number, g.group_name)

orgs = sess.accounts.list_organizations()
for o in orgs:
    print(o.organization_number, o.organization_name, o.locality)

# Fetch a single organization by its number
org = sess.accounts.get_organization("8921215")
```

### Working with the catalog

`sess.catalog` lists available certificate products and their custom fields.

```python
categories = sess.catalog.list_products()
for cat in categories:
    for product in cat.products:
        print(product.product_code, product.product_name, product.price)

# Custom fields required for a specific product
fields = sess.catalog.get_custom_fields("842")
for f in fields:
    print(f.field_name, f.required)
```

### Working with the ledger

`sess.ledger` provides access to the account transaction history.

```python
records = sess.ledger.get_list()
for r in records:
    print(r.transaction_date, r.description, r.debit, r.credit, r.balance)

# Single page
page = sess.ledger.get_page(page=1, size=50)
```

`get_list()` paginates automatically. `LedgerRecord.to_row()` returns a flat
`dict[str, str]` suitable for `tabulate`.

### Working with SSL/TLS certificates

`sess.ssl` covers the full certificate lifecycle. Product codes are resolved
automatically from the catalog — you never hardcode a product code.

#### Create a certificate

Use `sess.ssl.create()` when the validation level is a runtime value (e.g. read
from configuration). It dispatches to the appropriate `create_*` method and
validates that `organization_id` is provided for OV and EV orders:

```python
# Product determined at runtime (e.g. from config)
order = sess.ssl.create("dv", "example.com", validity_years=1)
order = sess.ssl.create("ov", "example.com", organization_id="8921215", validity_years=1)
order = sess.ssl.create("ev", "example.com", organization_id="8921215", validity_years=1)
```

Or call the specific variant directly:

```python
# DV single-domain
order = sess.ssl.create_dv("example.com", validity_years=1)

# DV wildcard
order = sess.ssl.create_dv_wildcard("example.com", validity_years=1)

# OV single-domain (requires organization_id from sess.accounts.list_organizations())
order = sess.ssl.create_ov("example.com", organization_id="8921215", validity_years=1)

# EV single-domain
order = sess.ssl.create_ev("example.com", organization_id="8921215", validity_years=1)

# UCC (multi-domain) — pass a list for DV, OV, or EV
order = sess.ssl.create_dv_ucc(["example.com", "www.example.com"], validity_years=1)
```

#### DV lifecycle

Each mutation call returns an opaque response dict; call `order.refresh()` afterwards to see the updated `order.status`.

```python
# 1. Get challenges
for challenge in order.get_dcv():
    print(challenge.domain, challenge.method, challenge.host, challenge.token)

# 2. (Publish the DNS TXT or HTTP file challenge externally)

# 3. Trigger verification (publish the challenge first, then call this)
order.verify_dcv()
order.refresh()
print(order.status)  # "pending-csr" once DCV passes

# 4. Submit CSR
order.submit_csr(csr_pem)
order.refresh()

# 5. Accept agreement
order.accept_agreement()
order.refresh()
print(order.status)  # "pending-approval" or "issued"

# 6. Download once issued
cert = order.download_certificate()           # JSON — cert + chain PEM strings
pem  = order.download_certificate_pem()      # raw PEM bundle (ordering not guaranteed)
chain = order.download_certificate().as_pem_chain()  # leaf-first fullchain, normalised newline
der  = order.download_certificate_der()      # raw DER bytes
```

**Complete end-to-end DV example:**

```python
import certinext, time

sess = certinext.session(client_id="YOUR_ACCOUNT", client_secret="YOUR_SECRET")

order = sess.ssl.create_dv("example.com", validity_years=1)
print(f"Order {order.order_id} created, status={order.status}")

for ch in order.get_dcv():
    print(f"  {ch.domain}: add TXT at {ch.host!r}  value={ch.token!r}")

input("Press Enter once DNS TXT records are published…")

order.verify_dcv()
order.submit_csr(open("csr.pem").read())
order.accept_agreement()

while True:
    order.refresh()
    if order.status == "issued":
        break
    print(f"  status={order.status}, waiting…")
    time.sleep(30)

open("cert.pem", "w").write(order.download_certificate_pem())
print("Certificate written to cert.pem")
```

#### Retrieve an existing order

```python
order = sess.ssl.get("ORDER-ID")
print(order.status, order.domain, order.created_at)
order.refresh()   # re-fetch current state from the API
```

#### OrderWorkflow helpers

`OrderWorkflow` drives an order through its full lifecycle automatically.
Three helpers simplify common patterns:

```python
from certinext import OrderWorkflow

# Drive a new order to issuance (blocking)
wf = OrderWorkflow.from_csr(order, csr_pem, signer_name="Jane Doe")
pem = wf.run()   # blocks until issued or timeout

# Resume from a persisted order ID (e.g. after a restart)
wf = OrderWorkflow.from_order_id(sess, "ORDER-ID", signer_name="Jane Doe")
wf.advance(csr_pem)   # one non-blocking step

# Download the issued certificate as a deterministic leaf-first fullchain
chain = wf.download_chain()   # retries HTTP 422 ("not ready yet") automatically
```

`download_chain()` uses `CertificateDownload.as_pem_chain()` internally — the
end-entity certificate followed by its intermediates, with a single trailing
newline. Use this instead of `download()` when the bundle order matters (e.g.
when writing a `fullchain.pem` for an ACME server).

#### Other lifecycle operations

```python
order.cancel()
order.revoke(reason="keyCompromise")
order.reissue("rekey", csr=new_csr_pem)
```

---

## Examples

### DNS-TXT DCV automation

[`examples/dns_txt_dcv.py`](examples/dns_txt_dcv.py) is a ready-to-adapt script that automates the full DNS-TXT DCV pipeline: publishing the challenge token, waiting for DNS propagation, and triggering `domain.verify()` once the token is visible everywhere.

It contains two stub functions you implement for your DNS provider:

| Function | Purpose |
|---|---|
| `set_dns_txt_record(fqdn, value, dry_run)` | Publish the TXT record via your DNS provider API |
| `has_dns_txt_record(fqdn, value, nameserver)` | Check whether a nameserver returns the expected TXT value |

Each stub raises `NotImplementedError` until implemented and includes inline examples using **dnspython** (nsupdate/TSIG) and **AWS Route 53** (boto3).

<details>
<summary>Usage</summary>

```bash
export CERTINEXT_CLIENT_ID="your-account-number"
export CERTINEXT_CLIENT_SECRET="your-client-secret"

# Process all pending domains
python examples/dns_txt_dcv.py

# Preview without making changes
python examples/dns_txt_dcv.py --dry-run

# Limit to a specific domain or pattern
python examples/dns_txt_dcv.py example.com
python examples/dns_txt_dcv.py --pattern r".*\.example\.com"

# Configure nameserver propagation checks
python examples/dns_txt_dcv.py \
  --auth-nameservers ns1.example.com,ns2.example.com \
  --public-nameservers 8.8.8.8,1.1.1.1
```

Run the script repeatedly — each run advances every pending domain as far as it can go and exits cleanly when waiting for propagation. Once a domain is fully propagated, the script calls `domain.verify()` automatically.

</details>

---

## API documentation

The CertiNext REST API is documented in two places:

| Resource | URL | Notes |
|---|---|---|
| Swagger UI (sandbox) | [sandbox-us-api.certinext.io/swagger-ui/index.html](https://sandbox-us-api.certinext.io/swagger-ui/index.html) | Interactive; select **certinext-v2** from the spec dropdown |
| OpenAPI spec (sandbox) | [sandbox-us-api.certinext.io/v3/api-docs/certinext-v2](https://sandbox-us-api.certinext.io/v3/api-docs/certinext-v2) | Raw JSON — complete schema including undocumented fields |
| Postman collection | [documenter.getpostman.com/…](https://documenter.getpostman.com/view/40123569/2sBXqJJLFh) | Official docs; less complete than the Swagger spec |

Replace `sandbox-us-api.certinext.io` with `us-api.certinext.io` for the production equivalents.

The Swagger spec is the most authoritative source — it exposes fields not present in the Postman collection (e.g. `preVettingToken`, `csr` in the initial order body, `delegation`, `recipientEmails`, `tags`).

---

## Project structure

<details>
<summary>File tree</summary>

```
certinext/
    __init__.py                   # session() factory, top-level exports, URL constants
    _cli.py                       # shared CLI utilities (add_connection_args, add_requestor_args, fatal_api_error, build_session)
    _config.py                    # stored issue-cert defaults (config.toml load/merge/save)
    _keyring.py                   # shared keyring helpers (keyring_service, keyring_get, keyring_available, no_keyring_help)
    accounts.py                   # AccountInfo, Group, Organization, AccountAccessor
    accounts_cli.py               # certinext-accounts CLI entry point
    auth.py                       # OAuth 2.0 client credentials token management
    catalog.py                    # Product, ProductCategory, CustomField, CatalogAccessor
    client.py                     # HTTP session wrapper (get/post/put/delete/get_bytes)
    csr.py                        # parse_csr() — extract CN and SANs from a PEM CSR (requires certinext[csr])
    domain_cert_count_cli.py      # certinext-domain-cert-count CLI entry point
    domains.py                    # Domain class and DomainAccessor
    domains_cli.py                # certinext-domains CLI entry point
    exceptions.py                 # CertiNextAPIError
    issue_certificate_cli.py      # certinext-issue-cert CLI entry point
    ledger.py                     # LedgerRecord and LedgerAccessor
    ledger_cli.py                 # certinext-ledger CLI entry point
    list_certificates_cli.py      # certinext-list-certificates CLI entry point
    orders.py                     # OrderRecord and OrderAccessor
    pending_dcv_cli.py            # certinext-pending-dcv CLI entry point
    session.py                    # CertiNextSession (accounts, catalog, domain, ledger, orders, ssl)
    setup_defaults_cli.py         # certinext-setup-defaults CLI entry point
    setup_keyring_cli.py          # certinext-setup-keyring CLI entry point
    ssl_certificates.py           # SslOrder, DcvChallenge, CertificateDownload, SslAccessor, OrderWorkflow
                                  #   SslAccessor.create() — DV/OV/EV dispatcher
                                  #   CertificateDownload.as_pem_chain() — leaf-first fullchain
                                  #   OrderWorkflow.download_chain() — 422-retry + normalised chain
                                  #   OrderWorkflow.from_order_id() — resume from persisted order ID
tests/
    test_integration.py           # integration tests against the sandbox API (pytest -m integration)
examples/
    dns_txt_dcv.py                # DNS-TXT DCV automation example (see Examples above)
```

</details>
