dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/security.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync Security — Layer 2
|
|
3
|
+
=========================
|
|
4
|
+
PKI local + TLS/mTLS para el hub DoSync.
|
|
5
|
+
|
|
6
|
+
Arquitectura:
|
|
7
|
+
- La Pi actúa como Certificate Authority (CA) local
|
|
8
|
+
- El hub tiene su propio certificado firmado por la CA
|
|
9
|
+
- Los adapters externos (gpio_adapter, adapters de terceros)
|
|
10
|
+
reciben certificados firmados por la misma CA
|
|
11
|
+
- mTLS verifica identidad en ambas direcciones
|
|
12
|
+
|
|
13
|
+
Archivos generados (en certs/):
|
|
14
|
+
certs/
|
|
15
|
+
├── ca.key — clave privada de la CA (nunca sale de la Pi)
|
|
16
|
+
├── ca.crt — certificado de la CA (se distribuye a clientes)
|
|
17
|
+
├── hub.key — clave privada del hub
|
|
18
|
+
├── hub.crt — certificado del hub (firmado por CA)
|
|
19
|
+
├── hub.csr — certificate signing request (intermedio)
|
|
20
|
+
└── adapters/
|
|
21
|
+
├── gpio.key — clave privada del gpio_adapter
|
|
22
|
+
└── gpio.crt — certificado del gpio_adapter (firmado por CA)
|
|
23
|
+
|
|
24
|
+
Uso rápido:
|
|
25
|
+
# Generar PKI completa (primera vez)
|
|
26
|
+
python3 -m dosync.security setup
|
|
27
|
+
|
|
28
|
+
# Emitir certificado para un adapter nuevo
|
|
29
|
+
python3 -m dosync.security issue --name gpio --ip 127.0.0.1
|
|
30
|
+
|
|
31
|
+
# Verificar que los certs son válidos y no han expirado
|
|
32
|
+
python3 -m dosync.security verify
|
|
33
|
+
|
|
34
|
+
# Ver info de un certificado
|
|
35
|
+
python3 -m dosync.security info --cert certs/hub.crt
|
|
36
|
+
|
|
37
|
+
Integración con uvicorn (HTTPS):
|
|
38
|
+
uvicorn server:app --ssl-keyfile certs/hub.key --ssl-certfile certs/hub.crt
|
|
39
|
+
|
|
40
|
+
Integración con uvicorn (mTLS — requiere cert de cliente):
|
|
41
|
+
uvicorn server:app \\
|
|
42
|
+
--ssl-keyfile certs/hub.key \\
|
|
43
|
+
--ssl-certfile certs/hub.crt \\
|
|
44
|
+
--ssl-ca-certs certs/ca.crt
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import datetime
|
|
50
|
+
import ipaddress
|
|
51
|
+
import json
|
|
52
|
+
import logging
|
|
53
|
+
import os
|
|
54
|
+
import subprocess
|
|
55
|
+
import sys
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Optional
|
|
59
|
+
|
|
60
|
+
log = logging.getLogger("dosync.security")
|
|
61
|
+
|
|
62
|
+
# ── Configuración ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
CERTS_DIR = Path(os.environ.get("DOSYNC_CERTS_DIR", "certs"))
|
|
65
|
+
CA_KEY_PATH = CERTS_DIR / "ca.key"
|
|
66
|
+
CA_CERT_PATH = CERTS_DIR / "ca.crt"
|
|
67
|
+
HUB_KEY_PATH = CERTS_DIR / "hub.key"
|
|
68
|
+
HUB_CERT_PATH = CERTS_DIR / "hub.crt"
|
|
69
|
+
HUB_CSR_PATH = CERTS_DIR / "hub.csr"
|
|
70
|
+
ADAPTERS_DIR = CERTS_DIR / "adapters"
|
|
71
|
+
|
|
72
|
+
CA_VALIDITY_DAYS = 3650 # 10 años
|
|
73
|
+
CERT_VALIDITY_DAYS = 365 # 1 año (renovación anual)
|
|
74
|
+
KEY_SIZE = 4096 # bits RSA
|
|
75
|
+
|
|
76
|
+
CA_SUBJECT = (
|
|
77
|
+
"/C=AR/ST=Cordoba/L=Cordoba"
|
|
78
|
+
"/O=DoSync Local PKI"
|
|
79
|
+
"/OU=Certificate Authority"
|
|
80
|
+
"/CN=DoSync CA"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
HUB_SUBJECT = (
|
|
84
|
+
"/C=AR/ST=Cordoba/L=Cordoba"
|
|
85
|
+
"/O=DoSync Protocol"
|
|
86
|
+
"/OU=Hub"
|
|
87
|
+
"/CN=dosync-hub"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CertInfo:
|
|
95
|
+
"""Información de un certificado."""
|
|
96
|
+
subject: str
|
|
97
|
+
issuer: str
|
|
98
|
+
not_before: str
|
|
99
|
+
not_after: str
|
|
100
|
+
serial: str
|
|
101
|
+
is_ca: bool
|
|
102
|
+
path: Path
|
|
103
|
+
days_until_expiry: int
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_expired(self) -> bool:
|
|
107
|
+
return self.days_until_expiry <= 0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_expiring_soon(self) -> bool:
|
|
111
|
+
return 0 < self.days_until_expiry <= 30
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class PKIStatus:
|
|
116
|
+
"""Estado completo de la PKI."""
|
|
117
|
+
ca_exists: bool = False
|
|
118
|
+
hub_cert_exists: bool = False
|
|
119
|
+
ca_info: Optional[CertInfo] = None
|
|
120
|
+
hub_info: Optional[CertInfo] = None
|
|
121
|
+
adapter_certs: list[CertInfo] = field(default_factory=list)
|
|
122
|
+
errors: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_ready(self) -> bool:
|
|
126
|
+
return self.ca_exists and self.hub_cert_exists and not self.errors
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Helpers internos ──────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
132
|
+
"""Ejecuta un comando openssl."""
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
cmd,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=False,
|
|
138
|
+
)
|
|
139
|
+
if check and result.returncode != 0:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
f"openssl command failed:\n"
|
|
142
|
+
f" cmd: {' '.join(cmd)}\n"
|
|
143
|
+
f" stderr: {result.stderr.strip()}"
|
|
144
|
+
)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _openssl_available() -> bool:
|
|
149
|
+
result = subprocess.run(["openssl", "version"], capture_output=True)
|
|
150
|
+
return result.returncode == 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _ensure_dirs() -> None:
|
|
154
|
+
CERTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
ADAPTERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cert_info(cert_path: Path) -> Optional[CertInfo]:
|
|
159
|
+
"""Extrae información de un certificado PEM."""
|
|
160
|
+
if not cert_path.exists():
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
result = _run([
|
|
164
|
+
"openssl", "x509",
|
|
165
|
+
"-in", str(cert_path),
|
|
166
|
+
"-noout",
|
|
167
|
+
"-subject", "-issuer", "-dates", "-serial",
|
|
168
|
+
"-ext", "basicConstraints",
|
|
169
|
+
])
|
|
170
|
+
lines = result.stdout.strip().splitlines()
|
|
171
|
+
info = {}
|
|
172
|
+
for line in lines:
|
|
173
|
+
if "=" in line:
|
|
174
|
+
k, _, v = line.partition("=")
|
|
175
|
+
info[k.strip().lower().replace(" ", "_")] = v.strip()
|
|
176
|
+
|
|
177
|
+
# Parsear fecha de expiración
|
|
178
|
+
not_after_str = info.get("notafter", "")
|
|
179
|
+
try:
|
|
180
|
+
not_after = datetime.datetime.strptime(
|
|
181
|
+
not_after_str, "%b %d %H:%M:%S %Y %Z"
|
|
182
|
+
).replace(tzinfo=datetime.timezone.utc)
|
|
183
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
184
|
+
days_left = (not_after - now).days
|
|
185
|
+
except ValueError:
|
|
186
|
+
days_left = -1
|
|
187
|
+
|
|
188
|
+
is_ca = "CA:TRUE" in result.stdout
|
|
189
|
+
|
|
190
|
+
return CertInfo(
|
|
191
|
+
subject=info.get("subject", ""),
|
|
192
|
+
issuer=info.get("issuer", ""),
|
|
193
|
+
not_before=info.get("notbefore", ""),
|
|
194
|
+
not_after=not_after_str,
|
|
195
|
+
serial=info.get("serial", ""),
|
|
196
|
+
is_ca=is_ca,
|
|
197
|
+
path=cert_path,
|
|
198
|
+
days_until_expiry=days_left,
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
log.warning("Could not parse cert %s: %s", cert_path, e)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── PKI Core ──────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def generate_ca(force: bool = False) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Genera la CA local (Certificate Authority).
|
|
210
|
+
Sólo se hace UNA VEZ. La clave privada nunca sale de la Pi.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
force: si True, regenera aunque ya exista (DESTRUYE la PKI existente)
|
|
214
|
+
"""
|
|
215
|
+
_ensure_dirs()
|
|
216
|
+
|
|
217
|
+
if CA_KEY_PATH.exists() and CA_CERT_PATH.exists() and not force:
|
|
218
|
+
log.info("CA already exists at %s — skipping. Use force=True to regenerate.", CA_CERT_PATH)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if force:
|
|
222
|
+
log.warning("Regenerating CA — all existing certificates will be invalidated!")
|
|
223
|
+
|
|
224
|
+
log.info("Generating CA key (%d bits)...", KEY_SIZE)
|
|
225
|
+
_run([
|
|
226
|
+
"openssl", "genrsa",
|
|
227
|
+
"-out", str(CA_KEY_PATH),
|
|
228
|
+
str(KEY_SIZE),
|
|
229
|
+
])
|
|
230
|
+
# Permisos restrictivos en la clave privada
|
|
231
|
+
CA_KEY_PATH.chmod(0o600)
|
|
232
|
+
|
|
233
|
+
log.info("Generating CA self-signed certificate (valid %d days)...", CA_VALIDITY_DAYS)
|
|
234
|
+
# Config file completo — requerido por OpenSSL 3.0 en Debian/Raspberry Pi OS
|
|
235
|
+
# El approach con -subj + -extensions falla en OpenSSL 3.0 sin -config
|
|
236
|
+
ca_ext_file = CERTS_DIR / "ca_ext.cnf"
|
|
237
|
+
ca_ext_file.write_text(
|
|
238
|
+
"[req]\n"
|
|
239
|
+
"default_bits = 4096\n"
|
|
240
|
+
"prompt = no\n"
|
|
241
|
+
"default_md = sha256\n"
|
|
242
|
+
"distinguished_name = dn\n"
|
|
243
|
+
"x509_extensions = v3_ca\n"
|
|
244
|
+
"\n"
|
|
245
|
+
"[dn]\n"
|
|
246
|
+
"C = AR\n"
|
|
247
|
+
"ST = Cordoba\n"
|
|
248
|
+
"L = Cordoba\n"
|
|
249
|
+
"O = DoSync Local PKI\n"
|
|
250
|
+
"OU = Certificate Authority\n"
|
|
251
|
+
"CN = DoSync CA\n"
|
|
252
|
+
"\n"
|
|
253
|
+
"[v3_ca]\n"
|
|
254
|
+
"subjectKeyIdentifier = hash\n"
|
|
255
|
+
"authorityKeyIdentifier = keyid:always,issuer\n"
|
|
256
|
+
"basicConstraints = critical,CA:TRUE\n"
|
|
257
|
+
"keyUsage = critical,digitalSignature,cRLSign,keyCertSign\n"
|
|
258
|
+
)
|
|
259
|
+
_run([
|
|
260
|
+
"openssl", "req",
|
|
261
|
+
"-new", "-x509",
|
|
262
|
+
"-key", str(CA_KEY_PATH),
|
|
263
|
+
"-out", str(CA_CERT_PATH),
|
|
264
|
+
"-days", str(CA_VALIDITY_DAYS),
|
|
265
|
+
"-config", str(ca_ext_file),
|
|
266
|
+
])
|
|
267
|
+
ca_ext_file.unlink(missing_ok=True)
|
|
268
|
+
|
|
269
|
+
log.info("CA generated successfully:")
|
|
270
|
+
log.info(" Key: %s", CA_KEY_PATH)
|
|
271
|
+
log.info(" Cert: %s", CA_CERT_PATH)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def generate_hub_cert(
|
|
275
|
+
hub_ip: str = "127.0.0.1",
|
|
276
|
+
hub_hostname: str = "localhost",
|
|
277
|
+
force: bool = False,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Genera el certificado del hub, firmado por la CA local.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
hub_ip: IP del hub (para el SAN)
|
|
284
|
+
hub_hostname: hostname del hub (para el SAN)
|
|
285
|
+
force: regenerar aunque ya exista
|
|
286
|
+
"""
|
|
287
|
+
_ensure_dirs()
|
|
288
|
+
|
|
289
|
+
if not CA_KEY_PATH.exists():
|
|
290
|
+
raise RuntimeError("CA not found. Run generate_ca() first.")
|
|
291
|
+
|
|
292
|
+
if HUB_CERT_PATH.exists() and not force:
|
|
293
|
+
log.info("Hub cert already exists — skipping. Use force=True to regenerate.")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
log.info("Generating hub key (%d bits)...", KEY_SIZE)
|
|
297
|
+
_run([
|
|
298
|
+
"openssl", "genrsa",
|
|
299
|
+
"-out", str(HUB_KEY_PATH),
|
|
300
|
+
str(KEY_SIZE),
|
|
301
|
+
])
|
|
302
|
+
HUB_KEY_PATH.chmod(0o600)
|
|
303
|
+
|
|
304
|
+
log.info("Generating hub CSR...")
|
|
305
|
+
_run([
|
|
306
|
+
"openssl", "req",
|
|
307
|
+
"-new",
|
|
308
|
+
"-key", str(HUB_KEY_PATH),
|
|
309
|
+
"-out", str(HUB_CSR_PATH),
|
|
310
|
+
"-subj", HUB_SUBJECT,
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
# SAN config file completo — requerido por OpenSSL 3.0
|
|
314
|
+
ext_file = CERTS_DIR / "hub_ext.cnf"
|
|
315
|
+
ext_file.write_text(
|
|
316
|
+
"[req]\n"
|
|
317
|
+
"distinguished_name = dn\n"
|
|
318
|
+
"req_extensions = v3_req\n"
|
|
319
|
+
"prompt = no\n"
|
|
320
|
+
"[dn]\n"
|
|
321
|
+
"CN = dosync-hub\n"
|
|
322
|
+
"[v3_req]\n"
|
|
323
|
+
f"subjectAltName = IP:{hub_ip},IP:127.0.0.1,DNS:{hub_hostname},DNS:localhost\n"
|
|
324
|
+
"keyUsage = digitalSignature,keyEncipherment\n"
|
|
325
|
+
"extendedKeyUsage = serverAuth\n"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
log.info("Signing hub certificate with CA (valid %d days)...", CERT_VALIDITY_DAYS)
|
|
329
|
+
_run([
|
|
330
|
+
"openssl", "x509",
|
|
331
|
+
"-req",
|
|
332
|
+
"-in", str(HUB_CSR_PATH),
|
|
333
|
+
"-CA", str(CA_CERT_PATH),
|
|
334
|
+
"-CAkey", str(CA_KEY_PATH),
|
|
335
|
+
"-CAcreateserial",
|
|
336
|
+
"-out", str(HUB_CERT_PATH),
|
|
337
|
+
"-days", str(CERT_VALIDITY_DAYS),
|
|
338
|
+
"-extfile", str(ext_file),
|
|
339
|
+
"-extensions", "v3_req",
|
|
340
|
+
])
|
|
341
|
+
|
|
342
|
+
# Limpiar archivos intermedios
|
|
343
|
+
HUB_CSR_PATH.unlink(missing_ok=True)
|
|
344
|
+
ext_file.unlink(missing_ok=True)
|
|
345
|
+
|
|
346
|
+
log.info("Hub certificate generated:")
|
|
347
|
+
log.info(" Key: %s", HUB_KEY_PATH)
|
|
348
|
+
log.info(" Cert: %s", HUB_CERT_PATH)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def issue_adapter_cert(
|
|
352
|
+
name: str,
|
|
353
|
+
adapter_ip: str = "127.0.0.1",
|
|
354
|
+
force: bool = False,
|
|
355
|
+
) -> tuple[Path, Path]:
|
|
356
|
+
"""
|
|
357
|
+
Emite un certificado para un adapter externo.
|
|
358
|
+
El adapter usa este certificado para autenticarse con el hub (mTLS).
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
name: nombre del adapter (ej: "gpio", "shelly-01")
|
|
362
|
+
adapter_ip: IP del adapter (para el SAN)
|
|
363
|
+
force: regenerar aunque ya exista
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
(key_path, cert_path)
|
|
367
|
+
"""
|
|
368
|
+
_ensure_dirs()
|
|
369
|
+
|
|
370
|
+
if not CA_KEY_PATH.exists():
|
|
371
|
+
raise RuntimeError("CA not found. Run generate_ca() first.")
|
|
372
|
+
|
|
373
|
+
key_path = ADAPTERS_DIR / f"{name}.key"
|
|
374
|
+
cert_path = ADAPTERS_DIR / f"{name}.crt"
|
|
375
|
+
csr_path = ADAPTERS_DIR / f"{name}.csr"
|
|
376
|
+
|
|
377
|
+
if cert_path.exists() and not force:
|
|
378
|
+
log.info("Cert for adapter '%s' already exists — skipping.", name)
|
|
379
|
+
return key_path, cert_path
|
|
380
|
+
|
|
381
|
+
subject = (
|
|
382
|
+
f"/C=AR/ST=Cordoba/L=Cordoba"
|
|
383
|
+
f"/O=DoSync Protocol"
|
|
384
|
+
f"/OU=Adapter"
|
|
385
|
+
f"/CN=dosync-adapter-{name}"
|
|
386
|
+
)
|
|
387
|
+
san = f"subjectAltName=IP:{adapter_ip},IP:127.0.0.1\nkeyUsage=digitalSignature\nextendedKeyUsage=clientAuth"
|
|
388
|
+
ext_file = ADAPTERS_DIR / f"{name}_ext.cnf"
|
|
389
|
+
ext_file.write_text(f"[v3_req]\n{san}\n")
|
|
390
|
+
|
|
391
|
+
log.info("Generating key for adapter '%s'...", name)
|
|
392
|
+
_run(["openssl", "genrsa", "-out", str(key_path), "2048"])
|
|
393
|
+
key_path.chmod(0o600)
|
|
394
|
+
|
|
395
|
+
log.info("Generating CSR for adapter '%s'...", name)
|
|
396
|
+
_run(["openssl", "req", "-new", "-key", str(key_path), "-out", str(csr_path), "-subj", subject])
|
|
397
|
+
|
|
398
|
+
log.info("Signing adapter cert with CA...")
|
|
399
|
+
_run([
|
|
400
|
+
"openssl", "x509", "-req",
|
|
401
|
+
"-in", str(csr_path),
|
|
402
|
+
"-CA", str(CA_CERT_PATH),
|
|
403
|
+
"-CAkey", str(CA_KEY_PATH),
|
|
404
|
+
"-CAcreateserial",
|
|
405
|
+
"-out", str(cert_path),
|
|
406
|
+
"-days", str(CERT_VALIDITY_DAYS),
|
|
407
|
+
"-extfile", str(ext_file),
|
|
408
|
+
"-extensions", "v3_req",
|
|
409
|
+
])
|
|
410
|
+
|
|
411
|
+
csr_path.unlink(missing_ok=True)
|
|
412
|
+
ext_file.unlink(missing_ok=True)
|
|
413
|
+
|
|
414
|
+
log.info("Adapter cert issued: %s", cert_path)
|
|
415
|
+
return key_path, cert_path
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def verify_chain(cert_path: Path) -> bool:
|
|
419
|
+
"""Verifica que un certificado fue firmado por la CA local."""
|
|
420
|
+
if not CA_CERT_PATH.exists():
|
|
421
|
+
return False
|
|
422
|
+
result = _run(
|
|
423
|
+
["openssl", "verify", "-CAfile", str(CA_CERT_PATH), str(cert_path)],
|
|
424
|
+
check=False,
|
|
425
|
+
)
|
|
426
|
+
return result.returncode == 0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ── Setup completo ────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
def setup(
|
|
432
|
+
hub_ip: str = "127.0.0.1",
|
|
433
|
+
hub_hostname: str = "localhost",
|
|
434
|
+
issue_gpio: bool = True,
|
|
435
|
+
force: bool = False,
|
|
436
|
+
) -> PKIStatus:
|
|
437
|
+
"""
|
|
438
|
+
Configura la PKI completa de DoSync en una sola llamada.
|
|
439
|
+
|
|
440
|
+
Genera:
|
|
441
|
+
1. CA local
|
|
442
|
+
2. Certificado del hub
|
|
443
|
+
3. Certificado para gpio_adapter (opcional)
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
hub_ip: IP del hub en la red local
|
|
447
|
+
hub_hostname: hostname del hub
|
|
448
|
+
issue_gpio: si True, emite también el cert para gpio_adapter
|
|
449
|
+
force: regenerar todo aunque ya exista
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
PKIStatus con el estado final de la PKI
|
|
453
|
+
"""
|
|
454
|
+
if not _openssl_available():
|
|
455
|
+
raise RuntimeError("openssl not found. Install with: apt-get install openssl")
|
|
456
|
+
|
|
457
|
+
log.info("=== DoSync PKI Setup ===")
|
|
458
|
+
generate_ca(force=force)
|
|
459
|
+
generate_hub_cert(hub_ip=hub_ip, hub_hostname=hub_hostname, force=force)
|
|
460
|
+
|
|
461
|
+
if issue_gpio:
|
|
462
|
+
issue_adapter_cert("gpio", adapter_ip=hub_ip, force=force)
|
|
463
|
+
|
|
464
|
+
status = get_status()
|
|
465
|
+
if status.is_ready:
|
|
466
|
+
log.info("PKI setup complete. Hub ready for TLS/mTLS.")
|
|
467
|
+
log.info("")
|
|
468
|
+
log.info("To start the hub with HTTPS:")
|
|
469
|
+
log.info(" uvicorn server:app --ssl-keyfile %s --ssl-certfile %s", HUB_KEY_PATH, HUB_CERT_PATH)
|
|
470
|
+
log.info("")
|
|
471
|
+
log.info("To start with mTLS (requires client certs):")
|
|
472
|
+
log.info(" uvicorn server:app --ssl-keyfile %s --ssl-certfile %s --ssl-ca-certs %s",
|
|
473
|
+
HUB_KEY_PATH, HUB_CERT_PATH, CA_CERT_PATH)
|
|
474
|
+
else:
|
|
475
|
+
log.error("PKI setup failed: %s", status.errors)
|
|
476
|
+
|
|
477
|
+
return status
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def get_status() -> PKIStatus:
|
|
481
|
+
"""Retorna el estado actual de la PKI."""
|
|
482
|
+
status = PKIStatus()
|
|
483
|
+
|
|
484
|
+
status.ca_exists = CA_CERT_PATH.exists()
|
|
485
|
+
status.hub_cert_exists = HUB_CERT_PATH.exists()
|
|
486
|
+
|
|
487
|
+
if status.ca_exists:
|
|
488
|
+
status.ca_info = _cert_info(CA_CERT_PATH)
|
|
489
|
+
|
|
490
|
+
if status.hub_cert_exists:
|
|
491
|
+
status.hub_info = _cert_info(HUB_CERT_PATH)
|
|
492
|
+
if status.hub_info and not verify_chain(HUB_CERT_PATH):
|
|
493
|
+
status.errors.append("Hub cert is not signed by the local CA")
|
|
494
|
+
|
|
495
|
+
# Certs de adapters
|
|
496
|
+
if ADAPTERS_DIR.exists():
|
|
497
|
+
for cert_file in ADAPTERS_DIR.glob("*.crt"):
|
|
498
|
+
info = _cert_info(cert_file)
|
|
499
|
+
if info:
|
|
500
|
+
if not verify_chain(cert_file):
|
|
501
|
+
status.errors.append(f"Adapter cert {cert_file.name} not signed by local CA")
|
|
502
|
+
if info.is_expired:
|
|
503
|
+
status.errors.append(f"Adapter cert {cert_file.name} has expired")
|
|
504
|
+
elif info.is_expiring_soon:
|
|
505
|
+
log.warning("Adapter cert %s expires in %d days", cert_file.name, info.days_until_expiry)
|
|
506
|
+
status.adapter_certs.append(info)
|
|
507
|
+
|
|
508
|
+
if status.hub_info:
|
|
509
|
+
if status.hub_info.is_expired:
|
|
510
|
+
status.errors.append("Hub certificate has expired")
|
|
511
|
+
elif status.hub_info.is_expiring_soon:
|
|
512
|
+
log.warning("Hub cert expires in %d days — renew soon", status.hub_info.days_until_expiry)
|
|
513
|
+
|
|
514
|
+
return status
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def renew_hub_cert(hub_ip: str = "127.0.0.1", hub_hostname: str = "localhost") -> None:
|
|
518
|
+
"""Renueva el certificado del hub manteniendo la misma CA."""
|
|
519
|
+
log.info("Renewing hub certificate...")
|
|
520
|
+
generate_hub_cert(hub_ip=hub_ip, hub_hostname=hub_hostname, force=True)
|
|
521
|
+
log.info("Hub certificate renewed. Restart the hub to apply.")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def renew_adapter_cert(name: str, adapter_ip: str = "127.0.0.1") -> tuple[Path, Path]:
|
|
525
|
+
"""Renueva el certificado de un adapter."""
|
|
526
|
+
log.info("Renewing cert for adapter '%s'...", name)
|
|
527
|
+
return issue_adapter_cert(name=name, adapter_ip=adapter_ip, force=True)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ── Detección automática de IP ────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
def detect_hub_ip() -> str:
|
|
533
|
+
"""
|
|
534
|
+
Intenta detectar la IP local de la Pi en la red.
|
|
535
|
+
Fallback a 127.0.0.1 si no puede determinarla.
|
|
536
|
+
"""
|
|
537
|
+
import socket
|
|
538
|
+
try:
|
|
539
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
540
|
+
s.connect(("8.8.8.8", 80))
|
|
541
|
+
ip = s.getsockname()[0]
|
|
542
|
+
s.close()
|
|
543
|
+
return ip
|
|
544
|
+
except Exception:
|
|
545
|
+
return "127.0.0.1"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
def _cmd_setup(args):
|
|
551
|
+
hub_ip = args.ip or detect_hub_ip()
|
|
552
|
+
print(f"Hub IP detected: {hub_ip}")
|
|
553
|
+
status = setup(hub_ip=hub_ip, hub_hostname=args.hostname, force=args.force)
|
|
554
|
+
_print_status(status)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _cmd_issue(args):
|
|
558
|
+
key, cert = issue_adapter_cert(args.name, adapter_ip=args.ip or "127.0.0.1", force=args.force)
|
|
559
|
+
print(f"Issued cert for adapter '{args.name}':")
|
|
560
|
+
print(f" Key: {key}")
|
|
561
|
+
print(f" Cert: {cert}")
|
|
562
|
+
print(f"\nDistribute to the adapter process. The adapter must present this cert")
|
|
563
|
+
print(f"when connecting to the hub (mTLS client authentication).")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _cmd_verify(args):
|
|
567
|
+
status = get_status()
|
|
568
|
+
_print_status(status)
|
|
569
|
+
if status.errors:
|
|
570
|
+
sys.exit(1)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _cmd_info(args):
|
|
574
|
+
path = Path(args.cert)
|
|
575
|
+
info = _cert_info(path)
|
|
576
|
+
if not info:
|
|
577
|
+
print(f"Could not read cert: {path}")
|
|
578
|
+
sys.exit(1)
|
|
579
|
+
print(f"Certificate: {path}")
|
|
580
|
+
print(f" Subject: {info.subject}")
|
|
581
|
+
print(f" Issuer: {info.issuer}")
|
|
582
|
+
print(f" Valid from: {info.not_before}")
|
|
583
|
+
print(f" Valid until: {info.not_after}")
|
|
584
|
+
print(f" Days remaining: {info.days_until_expiry}")
|
|
585
|
+
print(f" Is CA: {info.is_ca}")
|
|
586
|
+
chain_ok = verify_chain(path)
|
|
587
|
+
print(f" Chain valid: {chain_ok}")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _cmd_renew(args):
|
|
591
|
+
if args.target == "hub":
|
|
592
|
+
renew_hub_cert(hub_ip=detect_hub_ip())
|
|
593
|
+
print("Hub certificate renewed. Restart the hub.")
|
|
594
|
+
else:
|
|
595
|
+
renew_adapter_cert(args.target)
|
|
596
|
+
print(f"Adapter '{args.target}' certificate renewed.")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _print_status(status: PKIStatus):
|
|
600
|
+
print("\n=== DoSync PKI Status ===")
|
|
601
|
+
print(f" CA exists: {'✓' if status.ca_exists else '✗'}")
|
|
602
|
+
print(f" Hub cert exists: {'✓' if status.hub_cert_exists else '✗'}")
|
|
603
|
+
if status.ca_info:
|
|
604
|
+
print(f" CA expires: {status.ca_info.not_after} ({status.ca_info.days_until_expiry} days)")
|
|
605
|
+
if status.hub_info:
|
|
606
|
+
print(f" Hub cert expires: {status.hub_info.not_after} ({status.hub_info.days_until_expiry} days)")
|
|
607
|
+
if status.adapter_certs:
|
|
608
|
+
print(f" Adapter certs: {len(status.adapter_certs)}")
|
|
609
|
+
for a in status.adapter_certs:
|
|
610
|
+
exp = f"{a.days_until_expiry}d" if not a.is_expired else "EXPIRED"
|
|
611
|
+
print(f" - {a.path.stem:<20} {exp}")
|
|
612
|
+
if status.errors:
|
|
613
|
+
print(f"\n Errors:")
|
|
614
|
+
for e in status.errors:
|
|
615
|
+
print(f" ✗ {e}")
|
|
616
|
+
else:
|
|
617
|
+
print(f"\n PKI is {'READY' if status.is_ready else 'INCOMPLETE'}")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def main():
|
|
621
|
+
import argparse
|
|
622
|
+
parser = argparse.ArgumentParser(
|
|
623
|
+
description="DoSync PKI management — Layer 2 security",
|
|
624
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
625
|
+
epilog="""
|
|
626
|
+
Examples:
|
|
627
|
+
# Full setup (first time)
|
|
628
|
+
python3 -m dosync.security setup
|
|
629
|
+
|
|
630
|
+
# Setup with explicit IP
|
|
631
|
+
python3 -m dosync.security setup --ip 192.168.100.109
|
|
632
|
+
|
|
633
|
+
# Issue cert for a new adapter
|
|
634
|
+
python3 -m dosync.security issue --name shelly-01 --ip 192.168.100.50
|
|
635
|
+
|
|
636
|
+
# Verify PKI health
|
|
637
|
+
python3 -m dosync.security verify
|
|
638
|
+
|
|
639
|
+
# Show cert details
|
|
640
|
+
python3 -m dosync.security info --cert certs/hub.crt
|
|
641
|
+
|
|
642
|
+
# Renew hub cert
|
|
643
|
+
python3 -m dosync.security renew hub
|
|
644
|
+
|
|
645
|
+
# Renew adapter cert
|
|
646
|
+
python3 -m dosync.security renew gpio
|
|
647
|
+
""",
|
|
648
|
+
)
|
|
649
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
650
|
+
|
|
651
|
+
p_setup = sub.add_parser("setup", help="Generate full PKI (CA + hub cert + gpio cert)")
|
|
652
|
+
p_setup.add_argument("--ip", help="Hub IP address (auto-detected if omitted)")
|
|
653
|
+
p_setup.add_argument("--hostname", default="dosync-hub", help="Hub hostname")
|
|
654
|
+
p_setup.add_argument("--force", action="store_true", help="Regenerate even if exists")
|
|
655
|
+
p_setup.set_defaults(func=_cmd_setup)
|
|
656
|
+
|
|
657
|
+
p_issue = sub.add_parser("issue", help="Issue a cert for an adapter")
|
|
658
|
+
p_issue.add_argument("--name", required=True, help="Adapter name (e.g. gpio, shelly-01)")
|
|
659
|
+
p_issue.add_argument("--ip", help="Adapter IP address")
|
|
660
|
+
p_issue.add_argument("--force", action="store_true", help="Regenerate even if exists")
|
|
661
|
+
p_issue.set_defaults(func=_cmd_issue)
|
|
662
|
+
|
|
663
|
+
p_verify = sub.add_parser("verify", help="Verify PKI health")
|
|
664
|
+
p_verify.set_defaults(func=_cmd_verify)
|
|
665
|
+
|
|
666
|
+
p_info = sub.add_parser("info", help="Show certificate details")
|
|
667
|
+
p_info.add_argument("--cert", required=True, help="Path to .crt file")
|
|
668
|
+
p_info.set_defaults(func=_cmd_info)
|
|
669
|
+
|
|
670
|
+
p_renew = sub.add_parser("renew", help="Renew a certificate")
|
|
671
|
+
p_renew.add_argument("target", help="'hub' or adapter name")
|
|
672
|
+
p_renew.set_defaults(func=_cmd_renew)
|
|
673
|
+
|
|
674
|
+
args = parser.parse_args()
|
|
675
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
676
|
+
args.func(args)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
if __name__ == "__main__":
|
|
680
|
+
main()
|