certmate-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- certmate/__init__.py +17 -0
- certmate/client.py +198 -0
- certmate/errors.py +47 -0
- certmate/models.py +78 -0
- certmate_sdk-0.1.0.dist-info/METADATA +43 -0
- certmate_sdk-0.1.0.dist-info/RECORD +7 -0
- certmate_sdk-0.1.0.dist-info/WHEEL +4 -0
certmate/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""certmate-sdk — a thin Python client for the CertMate REST API.
|
|
2
|
+
|
|
3
|
+
from certmate import Client
|
|
4
|
+
with Client("https://certmate.example.com", token="...") as c:
|
|
5
|
+
job = c.create_certificate("app.example.com", dns_provider="cloudflare", wait=True)
|
|
6
|
+
"""
|
|
7
|
+
from .client import Client
|
|
8
|
+
from .errors import (APIError, AuthError, CertMateError, ConflictError,
|
|
9
|
+
JobFailed, JobTimeout, NotFoundError)
|
|
10
|
+
from .models import Certificate, Job
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Client", "Certificate", "Job",
|
|
15
|
+
"CertMateError", "APIError", "AuthError", "NotFoundError", "ConflictError",
|
|
16
|
+
"JobFailed", "JobTimeout",
|
|
17
|
+
]
|
certmate/client.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the CertMate REST API.
|
|
2
|
+
|
|
3
|
+
Endpoints mirror the ones the MCP server drives (the working reference client),
|
|
4
|
+
so the SDK talks to the same `/api/...` surface the web UI and agents use.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import (APIError, AuthError, ConflictError, JobFailed, JobTimeout,
|
|
15
|
+
NotFoundError)
|
|
16
|
+
from .models import Certificate, Job
|
|
17
|
+
|
|
18
|
+
_DEFAULT_URL = "http://localhost:8000"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
"""A thin CertMate API client.
|
|
23
|
+
|
|
24
|
+
>>> with Client("https://certmate.example.com", token="...") as c:
|
|
25
|
+
... for cert in c.list_certificates():
|
|
26
|
+
... print(cert.domain, cert.days_until_expiry)
|
|
27
|
+
|
|
28
|
+
``base_url``/``token`` fall back to ``CERTMATE_URL`` / ``CERTMATE_TOKEN``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
|
|
32
|
+
*, timeout: float = 30.0, verify: bool = True):
|
|
33
|
+
base_url = (base_url or os.getenv("CERTMATE_URL") or _DEFAULT_URL).rstrip("/")
|
|
34
|
+
token = token if token is not None else os.getenv("CERTMATE_TOKEN")
|
|
35
|
+
headers = {"Accept": "application/json"}
|
|
36
|
+
if token:
|
|
37
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
38
|
+
self._http = httpx.Client(base_url=base_url, headers=headers,
|
|
39
|
+
timeout=timeout, verify=verify)
|
|
40
|
+
self.base_url = base_url
|
|
41
|
+
|
|
42
|
+
# -- lifecycle -----------------------------------------------------------
|
|
43
|
+
def __enter__(self) -> "Client":
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *exc) -> None:
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
self._http.close()
|
|
51
|
+
|
|
52
|
+
# -- low level -----------------------------------------------------------
|
|
53
|
+
def _request(self, method: str, path: str, **kw) -> Any:
|
|
54
|
+
resp = self._http.request(method, path, **kw)
|
|
55
|
+
if resp.status_code // 100 == 2:
|
|
56
|
+
if not resp.content:
|
|
57
|
+
return None
|
|
58
|
+
ctype = resp.headers.get("content-type", "")
|
|
59
|
+
return resp.json() if "application/json" in ctype else resp.content
|
|
60
|
+
# Error path: pull CertMate's {error, code} shape when present.
|
|
61
|
+
payload: Any = None
|
|
62
|
+
code = None
|
|
63
|
+
message = f"HTTP {resp.status_code}"
|
|
64
|
+
try:
|
|
65
|
+
payload = resp.json()
|
|
66
|
+
if isinstance(payload, dict):
|
|
67
|
+
message = payload.get("error") or payload.get("message") or message
|
|
68
|
+
code = payload.get("code")
|
|
69
|
+
except Exception:
|
|
70
|
+
payload = resp.text
|
|
71
|
+
exc_cls = {401: AuthError, 403: AuthError, 404: NotFoundError,
|
|
72
|
+
409: ConflictError}.get(resp.status_code, APIError)
|
|
73
|
+
raise exc_cls(message, status=resp.status_code, code=code, payload=payload)
|
|
74
|
+
|
|
75
|
+
# -- certificates --------------------------------------------------------
|
|
76
|
+
def list_certificates(self) -> List[Certificate]:
|
|
77
|
+
data = self._request("GET", "/api/certificates")
|
|
78
|
+
items = data.get("certificates", data) if isinstance(data, dict) else data
|
|
79
|
+
return [Certificate.from_dict(d) for d in (items or [])]
|
|
80
|
+
|
|
81
|
+
def get_certificate(self, domain: str) -> Certificate:
|
|
82
|
+
data = self._request("GET", f"/api/certificates/{domain}")
|
|
83
|
+
return Certificate.from_dict(data if isinstance(data, dict) else {"domain": domain})
|
|
84
|
+
|
|
85
|
+
def create_certificate(self, domain: str, *, dns_provider: Optional[str] = None,
|
|
86
|
+
ca_provider: Optional[str] = None,
|
|
87
|
+
san_domains: Optional[List[str]] = None,
|
|
88
|
+
account_id: Optional[str] = None,
|
|
89
|
+
wait: bool = False,
|
|
90
|
+
on_progress: Optional[Callable[[Job], None]] = None,
|
|
91
|
+
**extra: Any) -> Job:
|
|
92
|
+
"""Submit an async issuance. Returns the accepted Job immediately;
|
|
93
|
+
with ``wait=True`` polls until the job reaches a terminal state (and
|
|
94
|
+
raises :class:`JobFailed` on failure)."""
|
|
95
|
+
body: Dict[str, Any] = {"domain": domain, "async": True}
|
|
96
|
+
if dns_provider:
|
|
97
|
+
body["dns_provider"] = dns_provider
|
|
98
|
+
if ca_provider:
|
|
99
|
+
body["ca_provider"] = ca_provider
|
|
100
|
+
if san_domains:
|
|
101
|
+
body["san_domains"] = san_domains
|
|
102
|
+
if account_id:
|
|
103
|
+
body["account_id"] = account_id
|
|
104
|
+
body.update(extra)
|
|
105
|
+
data = self._request("POST", "/api/certificates/create", json=body)
|
|
106
|
+
job = Job.from_dict(data if isinstance(data, dict) else {})
|
|
107
|
+
if not job.job_id:
|
|
108
|
+
# Server ran it synchronously (async not honoured): treat as done.
|
|
109
|
+
job.status = job.status or "succeeded"
|
|
110
|
+
return job
|
|
111
|
+
return self.wait_for_job(job.job_id, on_progress=on_progress) if wait else job
|
|
112
|
+
|
|
113
|
+
def renew_certificate(self, domain: str, *, force: bool = False) -> Dict[str, Any]:
|
|
114
|
+
# Always send an (empty) JSON body so endpoints that read request.json
|
|
115
|
+
# don't 415 on a bodyless POST.
|
|
116
|
+
params = {"force": "true"} if force else None
|
|
117
|
+
return self._request("POST", f"/api/certificates/{domain}/renew",
|
|
118
|
+
params=params, json={}) or {}
|
|
119
|
+
|
|
120
|
+
def reissue_certificate(self, domain: str, **body: Any) -> Dict[str, Any]:
|
|
121
|
+
return self._request("POST", f"/api/certificates/{domain}/reissue", json=body) or {}
|
|
122
|
+
|
|
123
|
+
def delete_certificate(self, domain: str) -> None:
|
|
124
|
+
self._request("DELETE", f"/api/certificates/{domain}")
|
|
125
|
+
|
|
126
|
+
def download_certificate(self, domain: str, fmt: str = "json") -> Any:
|
|
127
|
+
"""Download the bundle. ``fmt`` is one of json/pem/zip/pfx (json returns
|
|
128
|
+
a dict, the binary formats return bytes)."""
|
|
129
|
+
return self._request("GET", f"/api/certificates/{domain}/download",
|
|
130
|
+
params={"format": fmt})
|
|
131
|
+
|
|
132
|
+
def set_auto_renew(self, domain: str, enabled: bool) -> Dict[str, Any]:
|
|
133
|
+
return self._request("PUT", f"/api/certificates/{domain}/auto-renew",
|
|
134
|
+
json={"enabled": bool(enabled)}) or {}
|
|
135
|
+
|
|
136
|
+
def deploy_certificate(self, domain: str) -> Dict[str, Any]:
|
|
137
|
+
return self._request("POST", f"/api/certificates/{domain}/deploy", json={}) or {}
|
|
138
|
+
|
|
139
|
+
# -- async jobs ----------------------------------------------------------
|
|
140
|
+
def get_job(self, job_id: str) -> Job:
|
|
141
|
+
return Job.from_dict(self._request("GET", f"/api/certificates/jobs/{job_id}") or {})
|
|
142
|
+
|
|
143
|
+
def wait_for_job(self, job_id: str, *, interval: float = 2.0, timeout: float = 600.0,
|
|
144
|
+
on_progress: Optional[Callable[[Job], None]] = None) -> Job:
|
|
145
|
+
deadline = None # computed lazily to avoid importing time at module import
|
|
146
|
+
start = time.monotonic()
|
|
147
|
+
while True:
|
|
148
|
+
job = self.get_job(job_id)
|
|
149
|
+
if on_progress:
|
|
150
|
+
on_progress(job)
|
|
151
|
+
if job.is_terminal:
|
|
152
|
+
if job.failed:
|
|
153
|
+
raise JobFailed(job.error or f"job {job_id} failed", job=job)
|
|
154
|
+
return job
|
|
155
|
+
if time.monotonic() - start > timeout:
|
|
156
|
+
raise JobTimeout(f"job {job_id} did not finish within {timeout:.0f}s")
|
|
157
|
+
time.sleep(interval)
|
|
158
|
+
|
|
159
|
+
# -- dns -----------------------------------------------------------------
|
|
160
|
+
def list_dns_providers(self) -> Any:
|
|
161
|
+
return self._request("GET", "/api/settings/dns-providers")
|
|
162
|
+
|
|
163
|
+
def list_dns_accounts(self, provider: Optional[str] = None) -> Any:
|
|
164
|
+
path = f"/api/dns/{provider}/accounts" if provider else "/api/dns/accounts"
|
|
165
|
+
return self._request("GET", path)
|
|
166
|
+
|
|
167
|
+
def test_dns_provider(self, provider: str, **config: Any) -> Dict[str, Any]:
|
|
168
|
+
"""Preflight a DNS provider without issuing. Backs the CLI ``--dry-run``."""
|
|
169
|
+
body = {"provider": provider}
|
|
170
|
+
body.update(config)
|
|
171
|
+
return self._request("POST", "/api/web/certificates/test-provider", json=body) or {}
|
|
172
|
+
|
|
173
|
+
# -- audit / misc --------------------------------------------------------
|
|
174
|
+
def audit_verify(self) -> Dict[str, Any]:
|
|
175
|
+
"""Verify the audit chain. The endpoint returns 200 when intact and
|
|
176
|
+
409 (WITH the full result body) when the chain is broken/absent — both
|
|
177
|
+
are valid verification results, so return the dict in either case and
|
|
178
|
+
only raise on a genuine transport/auth error. Inspect ``result['ok']``.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
return self._request("GET", "/api/audit/verify") or {}
|
|
182
|
+
except ConflictError as e:
|
|
183
|
+
if isinstance(e.payload, dict):
|
|
184
|
+
return e.payload
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
def activity(self, limit: int = 100) -> Any:
|
|
188
|
+
return self._request("GET", "/api/activity", params={"limit": limit})
|
|
189
|
+
|
|
190
|
+
def health(self) -> Dict[str, Any]:
|
|
191
|
+
return self._request("GET", "/health") or {}
|
|
192
|
+
|
|
193
|
+
# -- backups -------------------------------------------------------------
|
|
194
|
+
def list_backups(self) -> Any:
|
|
195
|
+
return self._request("GET", "/api/web/backups")
|
|
196
|
+
|
|
197
|
+
def create_backup(self, *, reason: str = "manual") -> Dict[str, Any]:
|
|
198
|
+
return self._request("POST", "/api/web/backups/create", json={"reason": reason}) or {}
|
certmate/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Exception hierarchy for the CertMate SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CertMateError(Exception):
|
|
8
|
+
"""Base class for every SDK error."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIError(CertMateError):
|
|
12
|
+
"""The API returned a non-2xx response.
|
|
13
|
+
|
|
14
|
+
``status`` is the HTTP status code; ``code`` is CertMate's machine-readable
|
|
15
|
+
error code (e.g. ``DOMAIN_OUT_OF_SCOPE``) when present; ``payload`` is the
|
|
16
|
+
parsed response body."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, status: int, code: Optional[str] = None,
|
|
19
|
+
payload: Any = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
self.code = code
|
|
23
|
+
self.payload = payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthError(APIError):
|
|
27
|
+
"""401/403 — missing, invalid, or insufficiently-scoped credentials."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(APIError):
|
|
31
|
+
"""404 — the domain / resource does not exist."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConflictError(APIError):
|
|
35
|
+
"""409 — e.g. the certificate already exists, or an operation is in flight."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class JobFailed(CertMateError):
|
|
39
|
+
"""An async issuance/renewal job finished in a failed state."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, job: Any = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.job = job
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JobTimeout(CertMateError):
|
|
47
|
+
"""wait_for_job exceeded its timeout before the job reached a terminal state."""
|
certmate/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Light, tolerant dataclasses over the CertMate API responses.
|
|
2
|
+
|
|
3
|
+
Kept deliberately forgiving: the API is the source of truth, so every model
|
|
4
|
+
keeps the raw dict and only surfaces the well-known fields as typed
|
|
5
|
+
attributes. Unknown/absent fields never raise — the SDK must not break when
|
|
6
|
+
the server adds a field."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Certificate:
|
|
15
|
+
domain: str
|
|
16
|
+
expiry_date: Optional[str] = None
|
|
17
|
+
days_until_expiry: Optional[int] = None
|
|
18
|
+
needs_renewal: Optional[bool] = None
|
|
19
|
+
ca_provider: Optional[str] = None
|
|
20
|
+
dns_provider: Optional[str] = None
|
|
21
|
+
auto_renew: Optional[bool] = None
|
|
22
|
+
san_domains: List[str] = field(default_factory=list)
|
|
23
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Certificate":
|
|
27
|
+
d = d or {}
|
|
28
|
+
return cls(
|
|
29
|
+
domain=d.get("domain") or d.get("name") or "",
|
|
30
|
+
expiry_date=d.get("expiry_date") or d.get("expires") or d.get("not_after"),
|
|
31
|
+
days_until_expiry=d.get("days_until_expiry"),
|
|
32
|
+
needs_renewal=d.get("needs_renewal"),
|
|
33
|
+
ca_provider=d.get("ca_provider") or d.get("staging"),
|
|
34
|
+
dns_provider=d.get("dns_provider"),
|
|
35
|
+
auto_renew=d.get("auto_renew"),
|
|
36
|
+
san_domains=list(d.get("san_domains") or []),
|
|
37
|
+
raw=d,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Terminal job states, normalised.
|
|
42
|
+
JOB_DONE = {"succeeded", "success", "completed", "done"}
|
|
43
|
+
JOB_FAILED = {"failed", "error"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Job:
|
|
48
|
+
job_id: str
|
|
49
|
+
operation: Optional[str] = None
|
|
50
|
+
domain: Optional[str] = None
|
|
51
|
+
status: Optional[str] = None
|
|
52
|
+
error: Optional[str] = None
|
|
53
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Job":
|
|
57
|
+
d = d or {}
|
|
58
|
+
return cls(
|
|
59
|
+
job_id=d.get("job_id") or d.get("id") or "",
|
|
60
|
+
operation=d.get("operation"),
|
|
61
|
+
domain=d.get("domain"),
|
|
62
|
+
status=(d.get("status") or d.get("state")),
|
|
63
|
+
error=d.get("error"),
|
|
64
|
+
raw=d,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_terminal(self) -> bool:
|
|
69
|
+
s = (self.status or "").lower()
|
|
70
|
+
return s in JOB_DONE or s in JOB_FAILED
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def succeeded(self) -> bool:
|
|
74
|
+
return (self.status or "").lower() in JOB_DONE
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def failed(self) -> bool:
|
|
78
|
+
return (self.status or "").lower() in JOB_FAILED
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certmate-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin Python client for the CertMate REST API
|
|
5
|
+
Project-URL: Homepage, https://github.com/fabriziosalmi/certmate
|
|
6
|
+
Project-URL: Source, https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-sdk
|
|
7
|
+
Author-email: Fabrizio Salmi <fabrizio.salmi@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: acme,certificates,certmate,letsencrypt,sdk,tls
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx>=0.24
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# certmate-sdk
|
|
29
|
+
|
|
30
|
+
A thin, dependency-light Python client for the [CertMate](https://github.com/fabriziosalmi/certmate) REST API.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from certmate import Client
|
|
34
|
+
|
|
35
|
+
with Client("https://certmate.example.com", token="...") as c:
|
|
36
|
+
job = c.create_certificate("app.example.com", dns_provider="cloudflare", wait=True)
|
|
37
|
+
for cert in c.list_certificates():
|
|
38
|
+
print(cert.domain, cert.days_until_expiry)
|
|
39
|
+
print(c.audit_verify()["ok"])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`base_url`/`token` fall back to `CERTMATE_URL` / `CERTMATE_TOKEN`. The only
|
|
43
|
+
runtime dependency is `httpx` — no server code, no certbot.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
certmate/__init__.py,sha256=gZmT-GjJ8uJ5H3CJkVkllk-5RWiAZRiGW312cmgzIO0,643
|
|
2
|
+
certmate/client.py,sha256=TftHq6ZBZDpjhMRMTBtmymOlvWrEOLYPBMeOIpi_eEI,9121
|
|
3
|
+
certmate/errors.py,sha256=EGIS7NAQX9mtcuP2vgFbKA7yBRqXKQYbkEesJFzsT10,1351
|
|
4
|
+
certmate/models.py,sha256=ezrtMA6SBx1IOUnN5Ws9kOpb4UJB8AfgJTpuqxmW_XE,2514
|
|
5
|
+
certmate_sdk-0.1.0.dist-info/METADATA,sha256=M3rLkQXg7kDRUigp2fWcFHspr4ysy3YkAowAaAmIwl4,1739
|
|
6
|
+
certmate_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
certmate_sdk-0.1.0.dist-info/RECORD,,
|