certmate-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,111 @@
1
+ # Node (Tailwind CSS build)
2
+ node_modules/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+ MANIFEST
26
+
27
+ # Virtual Environment
28
+ venv/
29
+ venv*/
30
+ .venv/
31
+ env/
32
+ ENV/
33
+
34
+ # Application specific
35
+ # SECURITY: Never commit settings.json - may contain API tokens and secrets
36
+ settings.json
37
+ data/settings.json
38
+ cloudflare.ini
39
+ certificates/
40
+ data/
41
+ logs/
42
+ *.pem
43
+ *.key
44
+ *.crt
45
+ *.csr
46
+ letsencrypt/
47
+ .secrets/
48
+
49
+ # Environment files
50
+ .env
51
+ .env.local
52
+ .env.production
53
+
54
+ # IDE
55
+ .vscode/
56
+ .idea/
57
+ *.swp
58
+ *.swo
59
+
60
+ # OS
61
+ .DS_Store
62
+ Thumbs.db
63
+
64
+ # Logs
65
+ *.log
66
+
67
+ # Backup files
68
+ *_old.*
69
+ *_backup.*
70
+
71
+ # Planning and development documents
72
+ NEXT_FEATURES.md
73
+ PLAN_2.0.md
74
+ PLAN_*.docx
75
+
76
+ htmlcov/
77
+ .pytest_cache/
78
+ .coverage
79
+ coverage.xml
80
+ test_results.json
81
+
82
+ # Local dev / debug scripts (kept on developer machines only)
83
+ debug_*.py
84
+
85
+ backups/
86
+ app.py.backup
87
+
88
+ .claude/
89
+ test_screenshots/
90
+
91
+ # Test files with sensitive data
92
+ test_real_cert_*.py
93
+ test_api_*.py
94
+ test_certificate_creation_*.py
95
+ real_world_test_*.py
96
+ integration_test_*.py
97
+ github_response.md
98
+
99
+ # local
100
+ PLAN.md
101
+ scratch/
102
+ test_settings.py
103
+ # Internal audit docs (not for distribution)
104
+ UI_AUDIT.md
105
+ UI_SHOTS_ANALYSIS.md
106
+
107
+ # Theme migration screenshot baseline (regenerated by scripts/theme_baseline.py)
108
+ theme-baseline/
109
+ UI_DRACONIAN_ANALYSIS.md
110
+ UI_UX_Opinioni_CertMate.pdf
111
+ UI_REDESIGN_PLAN.md
@@ -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,16 @@
1
+ # certmate-sdk
2
+
3
+ A thin, dependency-light Python client for the [CertMate](https://github.com/fabriziosalmi/certmate) REST API.
4
+
5
+ ```python
6
+ from certmate import Client
7
+
8
+ with Client("https://certmate.example.com", token="...") as c:
9
+ job = c.create_certificate("app.example.com", dns_provider="cloudflare", wait=True)
10
+ for cert in c.list_certificates():
11
+ print(cert.domain, cert.days_until_expiry)
12
+ print(c.audit_verify()["ok"])
13
+ ```
14
+
15
+ `base_url`/`token` fall back to `CERTMATE_URL` / `CERTMATE_TOKEN`. The only
16
+ runtime dependency is `httpx` — no server code, no certbot.
@@ -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
+ ]
@@ -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 {}
@@ -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."""
@@ -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,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "certmate-sdk"
7
+ version = "0.1.0"
8
+ description = "Thin Python client for the CertMate REST API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Fabrizio Salmi", email = "fabrizio.salmi@gmail.com" }]
13
+ keywords = ["certmate", "acme", "letsencrypt", "tls", "certificates", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Security",
27
+ "Topic :: System :: Systems Administration",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ ]
30
+ # Deliberately tiny: the SDK is an HTTP client, not the server. No certbot,
31
+ # no DNS plugins, no cloud SDKs — `pip install certmate-sdk` stays light.
32
+ dependencies = ["httpx>=0.24"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/fabriziosalmi/certmate"
36
+ Source = "https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-sdk"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["certmate"]