ssl-provisioning 1.0.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.
- ssl_provisioning-1.0.0.dist-info/METADATA +172 -0
- ssl_provisioning-1.0.0.dist-info/RECORD +19 -0
- ssl_provisioning-1.0.0.dist-info/WHEEL +4 -0
- ssl_provisioning-1.0.0.dist-info/entry_points.txt +2 -0
- sslpv/__init__.py +3 -0
- sslpv/dependencies.py +176 -0
- sslpv/main.py +185 -0
- sslpv/models/__init__.py +0 -0
- sslpv/models/config.py +67 -0
- sslpv/response.py +41 -0
- sslpv/routers/__init__.py +0 -0
- sslpv/routers/certs.py +305 -0
- sslpv/services/__init__.py +0 -0
- sslpv/services/client.py +531 -0
- sslpv/services/config.py +136 -0
- sslpv/services/server.py +224 -0
- sslpv/utils/__init__.py +0 -0
- sslpv/utils/crypto.py +269 -0
- sslpv/utils/logging.py +105 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ssl-provisioning
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SSL certificate provisioning tool: a FastAPI server distributes fullchain/privkey to one-shot CLI clients over an authenticated, end-to-end encrypted channel.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: cryptography>=42.0
|
|
8
|
+
Requires-Dist: fastapi>=0.110
|
|
9
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
10
|
+
Requires-Dist: uvicorn>=0.27
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# sslpv
|
|
14
|
+
|
|
15
|
+
`sslpv` is an SSL certificate provisioning tool. A long-running FastAPI server holds
|
|
16
|
+
the paths to a `fullchain`/`privkey` pair and a set of API keys. A one-shot CLI client
|
|
17
|
+
authenticates with an API key and pulls the current certificate and private key over an
|
|
18
|
+
authenticated, end-to-end encrypted channel, writing them atomically to local paths.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
The distribution is published on PyPI as `ssl-provisioning`; the installed command
|
|
25
|
+
and import package are both named `sslpv`.
|
|
26
|
+
|
|
27
|
+
**From PyPI:**
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install ssl-provisioning
|
|
31
|
+
# or, one-shot without a permanent install:
|
|
32
|
+
uvx --from ssl-provisioning sslpv --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**From a local checkout:**
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv pip install . # or: pip install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Server
|
|
44
|
+
|
|
45
|
+
### Starting the server
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
sslpv server --config /path/to/config.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The server blocks until interrupted (Ctrl-C).
|
|
52
|
+
|
|
53
|
+
### config.json reference
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
|
|
58
|
+
"privkey": "/etc/letsencrypt/live/example.com/privkey.pem",
|
|
59
|
+
"apikeys": ["replace-with-a-long-random-secret", "another-client-key"],
|
|
60
|
+
"host": "0.0.0.0",
|
|
61
|
+
"port": 1243,
|
|
62
|
+
"server_certfile": null,
|
|
63
|
+
"server_keyfile": null,
|
|
64
|
+
"trusted_proxies": []
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Field | Type | Required | Description |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `fullchain` | string | yes | Path to the PEM fullchain certificate to distribute to clients. |
|
|
71
|
+
| `privkey` | string | yes | Path to the PEM private key to distribute to clients. |
|
|
72
|
+
| `apikeys` | list of strings | yes | One or more API keys that clients may authenticate with. |
|
|
73
|
+
| `host` | string | no | Bind address. Defaults to `"0.0.0.0"`. |
|
|
74
|
+
| `port` | int | no | TCP port to listen on. Defaults to `1243`. |
|
|
75
|
+
| `server_certfile` | string or null | no | TLS certificate for the server itself. Falls back to `fullchain` when null. |
|
|
76
|
+
| `server_keyfile` | string or null | no | TLS private key for the server itself. Falls back to `privkey` when null. |
|
|
77
|
+
| `trusted_proxies` | list of strings | no | IP addresses of trusted reverse proxies whose `X-Forwarded-For` header is used to determine the real client IP for rate limiting. |
|
|
78
|
+
|
|
79
|
+
### File permissions
|
|
80
|
+
|
|
81
|
+
The server hard-errors on startup if the config file is readable by group or other.
|
|
82
|
+
Restrict permissions before starting:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
chmod 600 /path/to/config.json
|
|
86
|
+
chmod 600 /path/to/privkey.pem
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Client
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
sslpv client \
|
|
95
|
+
--server https://example.com:1243 \
|
|
96
|
+
--key /path/to/apikey.txt \
|
|
97
|
+
--cert /path/to/fullchain.pem \
|
|
98
|
+
--privkey /path/to/privkey.pem
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Client flags
|
|
102
|
+
|
|
103
|
+
| Flag | Required | Description |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `--server URL` | yes | Server base URL. Must use `https`. |
|
|
106
|
+
| `--key PATH` | yes | File containing the API key. |
|
|
107
|
+
| `--cert PATH` | yes | Destination path for the retrieved PEM certificate. |
|
|
108
|
+
| `--privkey PATH` | yes | Destination path for the retrieved PEM private key. |
|
|
109
|
+
| `--insecure` | no | Disable TLS certificate verification. Dangerous; see note below. |
|
|
110
|
+
| `--ca-cert PATH` | no | Path to a custom PEM CA bundle for TLS verification. |
|
|
111
|
+
| `--pin-sha256 HEX` | no | Expected SHA-256 hex fingerprint of the server leaf certificate. |
|
|
112
|
+
| `--timeout SECONDS` | no | Per-request timeout in seconds. Default: `30.0`. |
|
|
113
|
+
|
|
114
|
+
### TLS for self-signed or IP-addressed servers
|
|
115
|
+
|
|
116
|
+
For servers with self-signed certificates or no matching DNS name, use `--ca-cert` or
|
|
117
|
+
`--pin-sha256` instead of `--insecure`:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
# Trust a custom CA bundle
|
|
121
|
+
sslpv client --server https://192.0.2.1:1243 --ca-cert /path/to/ca.pem ...
|
|
122
|
+
|
|
123
|
+
# Pin by SHA-256 fingerprint (colons optional, case-insensitive)
|
|
124
|
+
sslpv client --server https://192.0.2.1:1243 --pin-sha256 ab:cd:ef:... ...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`--insecure` disables all chain and hostname verification and leaves the connection
|
|
128
|
+
vulnerable to MITM attacks. The end-to-end encryption described below still protects
|
|
129
|
+
the payload content, but the identity of the server is not verified.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## How it works
|
|
134
|
+
|
|
135
|
+
### Stateless signed challenge-response authentication
|
|
136
|
+
|
|
137
|
+
1. The client fetches a signed one-time nonce from `/challenge`.
|
|
138
|
+
2. The client computes an HMAC proof that binds the API key, HTTP method, endpoint path,
|
|
139
|
+
nonce, issue timestamp, and an ephemeral X25519 public key. The raw API key is never
|
|
140
|
+
sent over the wire.
|
|
141
|
+
3. The server verifies the proof, checks the nonce has not been spent, and marks the
|
|
142
|
+
nonce as spent immediately (one-time use).
|
|
143
|
+
|
|
144
|
+
### End-to-end AES-256-GCM payload encryption
|
|
145
|
+
|
|
146
|
+
Each response payload (certificate or private key) is encrypted with AES-256-GCM. The
|
|
147
|
+
symmetric key is derived from an X25519 ECDH exchange between a server-side ephemeral
|
|
148
|
+
key and the client's ephemeral key, with the API key mixed in as additional key
|
|
149
|
+
material. Even if the TLS layer is broken or bypassed (e.g. by an on-path attacker
|
|
150
|
+
under `--insecure`), the payload cannot be decrypted or forged without knowledge of the
|
|
151
|
+
API key.
|
|
152
|
+
|
|
153
|
+
### TLS transport
|
|
154
|
+
|
|
155
|
+
The server uses uvicorn with `ssl.PROTOCOL_TLS_SERVER` (TLS 1.2 or higher) and a
|
|
156
|
+
modern cipher suite (`ECDHE+AESGCM:ECDHE+CHACHA20`). Use `--ca-cert` or `--pin-sha256`
|
|
157
|
+
on the client when the server certificate is not trusted by the system CA store.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Security model and limitations
|
|
162
|
+
|
|
163
|
+
- **Availability / DoS**: Protection against on-path denial-of-service is out of scope.
|
|
164
|
+
Rate limiting is implemented per-IP but an on-path attacker can still disrupt
|
|
165
|
+
availability.
|
|
166
|
+
- **Single-process requirement**: The server must run with `workers=1` (the default).
|
|
167
|
+
Nonce deduplication and the rate limiter use in-memory state; multiple workers would
|
|
168
|
+
allow nonce replay across process boundaries.
|
|
169
|
+
- **API key confidentiality**: Keep API key files restricted to `0600`. A key with group
|
|
170
|
+
or other read permission will trigger a warning from the client.
|
|
171
|
+
- **Config file confidentiality**: The server rejects a config file with group or other
|
|
172
|
+
read bits set. Always `chmod 600` the config.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
sslpv/__init__.py,sha256=ypRf1GUxSlkDMpH-HzLU26s2XSgVo1qiy-RkpfrdK9c,84
|
|
2
|
+
sslpv/dependencies.py,sha256=vESbqjM0YVagRVBV2DM1P7oOMZjtwS7ELmU29wICPE0,5809
|
|
3
|
+
sslpv/main.py,sha256=GN5osK5akagFcTGsCcBs1yXFR3m-zY6pKFWoyGd9etc,4949
|
|
4
|
+
sslpv/response.py,sha256=5EDwgnPty54lDbINm8e-_63mSUEDdAlzneZxdifzXbM,1109
|
|
5
|
+
sslpv/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
sslpv/models/config.py,sha256=sW_XBad0yXI0jHh5HmXywLIKLoHN2lKLBLH03pexJSg,2320
|
|
7
|
+
sslpv/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
sslpv/routers/certs.py,sha256=brrzuDTFsfrZ3m175-Qv26t0i1Yyy6HO78W4BuHalT8,10053
|
|
9
|
+
sslpv/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
sslpv/services/client.py,sha256=B4E7fUhbM23a67o3Rva5ZzHBcFmMQByhmIC6DuUpEBs,18385
|
|
11
|
+
sslpv/services/config.py,sha256=ofenFk5viml8Dtk63QOE9NEFYPCf8b5AJCdpKwpK9yM,3998
|
|
12
|
+
sslpv/services/server.py,sha256=kUPmERQhxK0d1HK3Ue--quX-HSNPRi1SoyXwYyc7SaU,7486
|
|
13
|
+
sslpv/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
sslpv/utils/crypto.py,sha256=G14KMCdFoyM5nFDVpM4Ln0KhPxzx5qcViVBh2863_UQ,9337
|
|
15
|
+
sslpv/utils/logging.py,sha256=7LDinL6YtIo6eKiGO-PVk-HLicEJ38oc3n1C5DZDlJQ,3486
|
|
16
|
+
ssl_provisioning-1.0.0.dist-info/METADATA,sha256=7wPLjxDobOTt6-YaMMkoIjrdBPBCUBj8N1_EgJAfR6w,6262
|
|
17
|
+
ssl_provisioning-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
18
|
+
ssl_provisioning-1.0.0.dist-info/entry_points.txt,sha256=3Zfy1xTI7P5pr9wRhZHaPVfSVCHPcF22KRCT0-Cubyg,42
|
|
19
|
+
ssl_provisioning-1.0.0.dist-info/RECORD,,
|
sslpv/__init__.py
ADDED
sslpv/dependencies.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""FastAPI dependencies: authentication and rate limiting."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import x25519
|
|
8
|
+
from fastapi import Depends, HTTPException, Request
|
|
9
|
+
|
|
10
|
+
from sslpv.utils.crypto import verify_challenge, verify_proof
|
|
11
|
+
|
|
12
|
+
# Challenge TTL in seconds
|
|
13
|
+
TTL = 60
|
|
14
|
+
|
|
15
|
+
# Maximum allowed clock skew between client and server
|
|
16
|
+
_CLOCK_SKEW = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_auth_header(authorization: str) -> tuple[str, int, str, str]:
|
|
20
|
+
"""Parse a Bearer token of the form ``Bearer v1.<nonce>.<ts>.<sig>.<proof>``.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
authorization(str): Raw Authorization header value.
|
|
24
|
+
|
|
25
|
+
Return:
|
|
26
|
+
parts(tuple): ``(nonce_b64, issue_ts, sig_b64, proof_b64)``.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
HTTPException: 401 if the header is missing, malformed, or wrong version.
|
|
30
|
+
"""
|
|
31
|
+
if not authorization or not authorization.startswith("Bearer "):
|
|
32
|
+
raise HTTPException(401, "unauthorized")
|
|
33
|
+
|
|
34
|
+
token = authorization[len("Bearer "):]
|
|
35
|
+
|
|
36
|
+
if not token.startswith("v1."):
|
|
37
|
+
raise HTTPException(401, "unauthorized")
|
|
38
|
+
|
|
39
|
+
body = token[len("v1."):]
|
|
40
|
+
# nonce_b64, issue_ts, sig_b64, proof_b64 — base64 segments never contain '.'
|
|
41
|
+
parts = body.split(".")
|
|
42
|
+
if len(parts) != 4:
|
|
43
|
+
raise HTTPException(401, "unauthorized")
|
|
44
|
+
|
|
45
|
+
nonce_b64, ts_str, sig_b64, proof_b64 = parts
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
issue_ts = int(ts_str)
|
|
49
|
+
except ValueError:
|
|
50
|
+
raise HTTPException(401, "unauthorized")
|
|
51
|
+
|
|
52
|
+
return nonce_b64, issue_ts, sig_b64, proof_b64
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_client_pubkey(header_value: str) -> tuple[bytes, str]:
|
|
56
|
+
"""Decode and validate the X-Client-Pubkey header.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
header_value(str): Base64-encoded 32-byte X25519 public key.
|
|
60
|
+
|
|
61
|
+
Return:
|
|
62
|
+
result(tuple): ``(raw_bytes, b64_string)``.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
HTTPException: 400 if the header is missing, not valid base64, or not 32 bytes.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
raw = base64.b64decode(header_value, validate=True)
|
|
69
|
+
except Exception:
|
|
70
|
+
raise HTTPException(400, "invalid X-Client-Pubkey header")
|
|
71
|
+
|
|
72
|
+
if len(raw) != 32:
|
|
73
|
+
raise HTTPException(400, "invalid X-Client-Pubkey header")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
x25519.X25519PublicKey.from_public_bytes(raw)
|
|
77
|
+
except Exception:
|
|
78
|
+
raise HTTPException(400, "invalid X-Client-Pubkey header")
|
|
79
|
+
|
|
80
|
+
return raw, header_value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_client_ip(request: Request, trusted_proxies: list[str]) -> str:
|
|
84
|
+
"""Determine the real client IP, honouring trusted proxy forwarding.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
request(Request): The incoming FastAPI request.
|
|
88
|
+
trusted_proxies(list[str]): IP addresses of trusted reverse proxies.
|
|
89
|
+
|
|
90
|
+
Return:
|
|
91
|
+
ip(str): The effective client IP address.
|
|
92
|
+
"""
|
|
93
|
+
direct_ip = request.client.host if request.client else "unknown"
|
|
94
|
+
if direct_ip in trusted_proxies:
|
|
95
|
+
forwarded = request.headers.get("X-Forwarded-For", "")
|
|
96
|
+
first = forwarded.split(",")[0].strip()
|
|
97
|
+
if first:
|
|
98
|
+
return first
|
|
99
|
+
return direct_ip
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def verify_auth(request: Request) -> dict[str, Any]:
|
|
103
|
+
"""FastAPI dependency that authenticates and rate-limits every protected request.
|
|
104
|
+
|
|
105
|
+
Authentication steps (order is security-critical):
|
|
106
|
+
1. Determine real client IP; apply rate limiting.
|
|
107
|
+
2. Parse Authorization header (Bearer v1 token) and X-Client-Pubkey header.
|
|
108
|
+
3. Verify challenge signature.
|
|
109
|
+
4. Verify token freshness (TTL and clock skew).
|
|
110
|
+
5. Check nonce has not been spent.
|
|
111
|
+
6. Verify API-key proof (bound to method, path, nonce, pubkey).
|
|
112
|
+
7. Mark nonce as spent; return auth context.
|
|
113
|
+
|
|
114
|
+
All 401 failures return the same message ("unauthorized") to avoid oracles.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
request(Request): The incoming FastAPI request.
|
|
118
|
+
|
|
119
|
+
Return:
|
|
120
|
+
auth(dict): ``{"apikey": str, "client_pubkey": bytes}``.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
HTTPException: 401 for auth failures, 429 for rate limiting, 400 for bad headers.
|
|
124
|
+
"""
|
|
125
|
+
config = request.app.state.config
|
|
126
|
+
server_secret: bytes = request.app.state.server_secret
|
|
127
|
+
spent_nonces = request.app.state.spent_nonces
|
|
128
|
+
rate_limiter = request.app.state.rate_limiter
|
|
129
|
+
|
|
130
|
+
# Step a: rate limiting
|
|
131
|
+
ip = _get_client_ip(request, config.trusted_proxies)
|
|
132
|
+
if not rate_limiter.allow(ip):
|
|
133
|
+
raise HTTPException(429, "rate limited")
|
|
134
|
+
|
|
135
|
+
# Step b: parse Authorization header
|
|
136
|
+
authorization = request.headers.get("Authorization", "")
|
|
137
|
+
nonce_b64, issue_ts, sig_b64, proof_b64 = _parse_auth_header(authorization)
|
|
138
|
+
|
|
139
|
+
client_pubkey_header = request.headers.get("X-Client-Pubkey", "")
|
|
140
|
+
if not client_pubkey_header:
|
|
141
|
+
raise HTTPException(400, "invalid X-Client-Pubkey header")
|
|
142
|
+
client_pubkey_raw, client_pubkey_b64 = _parse_client_pubkey(client_pubkey_header)
|
|
143
|
+
|
|
144
|
+
# Step c: verify challenge signature
|
|
145
|
+
if not verify_challenge(server_secret, nonce_b64, issue_ts, sig_b64):
|
|
146
|
+
raise HTTPException(401, "unauthorized")
|
|
147
|
+
|
|
148
|
+
# Step d: check expiry and clock skew
|
|
149
|
+
now = int(time.time())
|
|
150
|
+
if now - issue_ts > TTL:
|
|
151
|
+
raise HTTPException(401, "unauthorized")
|
|
152
|
+
if issue_ts > now + _CLOCK_SKEW:
|
|
153
|
+
raise HTTPException(401, "unauthorized")
|
|
154
|
+
|
|
155
|
+
# Step e: check spent nonce
|
|
156
|
+
if spent_nonces.contains(nonce_b64):
|
|
157
|
+
raise HTTPException(401, "unauthorized")
|
|
158
|
+
|
|
159
|
+
# Step f: verify proof (bound to method, path, nonce, pubkey, apikey)
|
|
160
|
+
matched_apikey = verify_proof(
|
|
161
|
+
proof_b64,
|
|
162
|
+
config.apikeys,
|
|
163
|
+
request.method,
|
|
164
|
+
request.url.path,
|
|
165
|
+
nonce_b64,
|
|
166
|
+
issue_ts,
|
|
167
|
+
client_pubkey_b64,
|
|
168
|
+
)
|
|
169
|
+
if matched_apikey is None:
|
|
170
|
+
# Do NOT mark the nonce as spent — the attacker has not authenticated
|
|
171
|
+
raise HTTPException(401, "unauthorized")
|
|
172
|
+
|
|
173
|
+
# Step g: mark nonce spent and return auth context
|
|
174
|
+
spent_nonces.add(nonce_b64, expiry=issue_ts + TTL)
|
|
175
|
+
|
|
176
|
+
return {"apikey": matched_apikey, "client_pubkey": client_pubkey_raw}
|
sslpv/main.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""CLI entry point for sslpv.
|
|
2
|
+
|
|
3
|
+
Provides two subcommands:
|
|
4
|
+
sslpv server --config /path/to/config.json
|
|
5
|
+
sslpv client --server URL --key PATH --cert PATH --privkey PATH [options]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from sslpv import __version__
|
|
13
|
+
from sslpv.services.client import run_client
|
|
14
|
+
from sslpv.services.server import run_server
|
|
15
|
+
from sslpv.utils.logging import print_message, setup_logging
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
"""Build and return the top-level argument parser.
|
|
20
|
+
|
|
21
|
+
Return:
|
|
22
|
+
parser(argparse.ArgumentParser): Configured argument parser with
|
|
23
|
+
server and client subcommands.
|
|
24
|
+
"""
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="sslpv",
|
|
27
|
+
description="SSL certificate provisioning tool.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version",
|
|
31
|
+
action="store_true",
|
|
32
|
+
default=False,
|
|
33
|
+
help="Print version and exit.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
subparsers = parser.add_subparsers(dest="subcommand")
|
|
37
|
+
|
|
38
|
+
# server subcommand
|
|
39
|
+
server_parser = subparsers.add_parser(
|
|
40
|
+
"server",
|
|
41
|
+
help="Run the sslpv provisioning server.",
|
|
42
|
+
)
|
|
43
|
+
server_parser.add_argument(
|
|
44
|
+
"--config",
|
|
45
|
+
required=True,
|
|
46
|
+
metavar="PATH",
|
|
47
|
+
help="Path to the JSON server config file.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# client subcommand
|
|
51
|
+
client_parser = subparsers.add_parser(
|
|
52
|
+
"client",
|
|
53
|
+
help="Fetch a certificate and private key from a running sslpv server.",
|
|
54
|
+
)
|
|
55
|
+
client_parser.add_argument(
|
|
56
|
+
"--server",
|
|
57
|
+
required=True,
|
|
58
|
+
metavar="URL",
|
|
59
|
+
help="Server base URL (must be https, e.g. https://example.com:1243).",
|
|
60
|
+
)
|
|
61
|
+
client_parser.add_argument(
|
|
62
|
+
"--key",
|
|
63
|
+
required=True,
|
|
64
|
+
metavar="PATH",
|
|
65
|
+
help="Path to a file containing the API key.",
|
|
66
|
+
)
|
|
67
|
+
client_parser.add_argument(
|
|
68
|
+
"--cert",
|
|
69
|
+
required=True,
|
|
70
|
+
metavar="PATH",
|
|
71
|
+
help="Destination path for the retrieved PEM certificate.",
|
|
72
|
+
)
|
|
73
|
+
client_parser.add_argument(
|
|
74
|
+
"--privkey",
|
|
75
|
+
required=True,
|
|
76
|
+
metavar="PATH",
|
|
77
|
+
help="Destination path for the retrieved PEM private key.",
|
|
78
|
+
)
|
|
79
|
+
client_parser.add_argument(
|
|
80
|
+
"--insecure",
|
|
81
|
+
action="store_true",
|
|
82
|
+
default=False,
|
|
83
|
+
help="Disable TLS certificate verification (dangerous; not recommended).",
|
|
84
|
+
)
|
|
85
|
+
client_parser.add_argument(
|
|
86
|
+
"--ca-cert",
|
|
87
|
+
metavar="PATH",
|
|
88
|
+
default=None,
|
|
89
|
+
help="Path to a PEM CA bundle to use for TLS verification.",
|
|
90
|
+
)
|
|
91
|
+
client_parser.add_argument(
|
|
92
|
+
"--pin-sha256",
|
|
93
|
+
metavar="HEX",
|
|
94
|
+
default=None,
|
|
95
|
+
help="Expected SHA-256 hex fingerprint of the server leaf certificate.",
|
|
96
|
+
)
|
|
97
|
+
client_parser.add_argument(
|
|
98
|
+
"--timeout",
|
|
99
|
+
type=float,
|
|
100
|
+
default=30.0,
|
|
101
|
+
metavar="SECONDS",
|
|
102
|
+
help="Per-request timeout in seconds (default: 30.0).",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_server_subcommand(args: argparse.Namespace) -> int:
|
|
109
|
+
"""Handle the 'server' subcommand.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
args(argparse.Namespace): Parsed arguments containing config path.
|
|
113
|
+
|
|
114
|
+
Return:
|
|
115
|
+
exit_code(int): 0 on clean exit, 1 on error.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
run_server(args.config)
|
|
119
|
+
return 0
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
print_message("Server stopped.", "fg:ansigreen")
|
|
122
|
+
return 0
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
print_message(f"Error: {exc}", "fg:ansired")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_client_subcommand(args: argparse.Namespace) -> int:
|
|
129
|
+
"""Handle the 'client' subcommand.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
args(argparse.Namespace): Parsed arguments for the client.
|
|
133
|
+
|
|
134
|
+
Return:
|
|
135
|
+
exit_code(int): Exit code returned by run_client (0 = success).
|
|
136
|
+
"""
|
|
137
|
+
return run_client(
|
|
138
|
+
server=args.server,
|
|
139
|
+
key_path=args.key,
|
|
140
|
+
cert_path=args.cert,
|
|
141
|
+
privkey_path=args.privkey,
|
|
142
|
+
insecure=args.insecure,
|
|
143
|
+
ca_cert=args.ca_cert,
|
|
144
|
+
pin_sha256=args.pin_sha256,
|
|
145
|
+
timeout=args.timeout,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
150
|
+
"""CLI entry point for sslpv.
|
|
151
|
+
|
|
152
|
+
Parses arguments, sets up logging, and dispatches to the appropriate
|
|
153
|
+
subcommand handler. Returns an integer exit code suitable for
|
|
154
|
+
sys.exit().
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
argv(list[str], optional): Argument list; uses sys.argv[1:] when None.
|
|
158
|
+
|
|
159
|
+
Return:
|
|
160
|
+
exit_code(int): 0 on success, 1 on error, 2 when no subcommand given.
|
|
161
|
+
"""
|
|
162
|
+
setup_logging()
|
|
163
|
+
parser = build_parser()
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
|
|
166
|
+
if args.version:
|
|
167
|
+
print_message(f"sslpv {__version__}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
if args.subcommand is None:
|
|
171
|
+
parser.print_help()
|
|
172
|
+
return 2
|
|
173
|
+
|
|
174
|
+
if args.subcommand == "server":
|
|
175
|
+
return _run_server_subcommand(args)
|
|
176
|
+
|
|
177
|
+
if args.subcommand == "client":
|
|
178
|
+
return _run_client_subcommand(args)
|
|
179
|
+
|
|
180
|
+
# Unreachable, but satisfies exhaustiveness.
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
raise SystemExit(main())
|
sslpv/models/__init__.py
ADDED
|
File without changes
|
sslpv/models/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Server configuration model."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ServerConfig(BaseModel):
|
|
9
|
+
"""Server configuration loaded from a JSON config file.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
fullchain(str): Path to the PEM fullchain certificate file served to clients.
|
|
13
|
+
privkey(str): Path to the PEM private key file served to clients.
|
|
14
|
+
apikeys(list[str]): List of authorized API keys.
|
|
15
|
+
host(str): Host address to bind. Defaults to "0.0.0.0".
|
|
16
|
+
port(int): Port to bind. Defaults to 1243.
|
|
17
|
+
server_certfile(Optional[str]): Path to the TLS certificate for the server
|
|
18
|
+
itself. Falls back to ``fullchain`` when not set.
|
|
19
|
+
server_keyfile(Optional[str]): Path to the TLS private key for the server
|
|
20
|
+
itself. Falls back to ``privkey`` when not set.
|
|
21
|
+
trusted_proxies(list[str]): IP addresses of trusted reverse proxies whose
|
|
22
|
+
X-Forwarded-For header is used to determine the real client IP.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
fullchain: str
|
|
26
|
+
privkey: str
|
|
27
|
+
apikeys: list[str]
|
|
28
|
+
host: str = "0.0.0.0"
|
|
29
|
+
port: int = 1243
|
|
30
|
+
server_certfile: Optional[str] = None
|
|
31
|
+
server_keyfile: Optional[str] = None
|
|
32
|
+
trusted_proxies: list[str] = []
|
|
33
|
+
|
|
34
|
+
model_config = {
|
|
35
|
+
"json_schema_extra": {
|
|
36
|
+
"examples": [
|
|
37
|
+
{
|
|
38
|
+
"fullchain": "/etc/ssl/certs/fullchain.pem",
|
|
39
|
+
"privkey": "/etc/ssl/private/privkey.pem",
|
|
40
|
+
"apikeys": ["changeme-replace-with-a-strong-random-key"],
|
|
41
|
+
"host": "0.0.0.0",
|
|
42
|
+
"port": 1243,
|
|
43
|
+
"server_certfile": None,
|
|
44
|
+
"server_keyfile": None,
|
|
45
|
+
"trusted_proxies": ["127.0.0.1"],
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def tls_certfile(self) -> str:
|
|
53
|
+
"""Return the TLS certificate path for the server (server_certfile or fullchain).
|
|
54
|
+
|
|
55
|
+
Return:
|
|
56
|
+
path(str): Effective TLS certificate file path.
|
|
57
|
+
"""
|
|
58
|
+
return self.server_certfile or self.fullchain
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def tls_keyfile(self) -> str:
|
|
62
|
+
"""Return the TLS key path for the server (server_keyfile or privkey).
|
|
63
|
+
|
|
64
|
+
Return:
|
|
65
|
+
path(str): Effective TLS key file path.
|
|
66
|
+
"""
|
|
67
|
+
return self.server_keyfile or self.privkey
|
sslpv/response.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Standard API response envelope and helper.
|
|
2
|
+
|
|
3
|
+
Every endpoint returns the unified ``{"status", "message", "data"}`` envelope through
|
|
4
|
+
:func:`make_response`. No endpoint returns a raw value or a bare model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIResponse(BaseModel):
|
|
14
|
+
"""Standard API response envelope.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
status(int): HTTP status code.
|
|
18
|
+
message(str): Human-readable message.
|
|
19
|
+
data(Any): Response payload (dict, list, or None).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
status: int
|
|
23
|
+
message: str
|
|
24
|
+
data: Optional[Any] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_response(status: int, message: str, data: Any = None) -> JSONResponse:
|
|
28
|
+
"""Build a standard JSON response.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
status(int): HTTP status code.
|
|
32
|
+
message(str): Human-readable message.
|
|
33
|
+
data(Any, optional): Response payload.
|
|
34
|
+
|
|
35
|
+
Return:
|
|
36
|
+
response(JSONResponse): FastAPI JSONResponse with the unified format.
|
|
37
|
+
"""
|
|
38
|
+
return JSONResponse(
|
|
39
|
+
status_code=status,
|
|
40
|
+
content={"status": status, "message": message, "data": data},
|
|
41
|
+
)
|
|
File without changes
|