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/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()