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/manage.py ADDED
@@ -0,0 +1,913 @@
1
+ """
2
+ DoSync — Management CLI
3
+ =======================
4
+ Herramienta de administración del hub DoSync.
5
+
6
+ Uso:
7
+ PYTHONPATH=. python3 manage.py keys list
8
+ PYTHONPATH=. python3 manage.py keys create --label "mi-app"
9
+ PYTHONPATH=. python3 manage.py keys reset
10
+ PYTHONPATH=. python3 manage.py keys revoke <key_preview>
11
+ PYTHONPATH=. python3 manage.py db stats
12
+ PYTHONPATH=. python3 manage.py db clean
13
+ """
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ import time
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+
25
+
26
+ # ── Colores ───────────────────────────────────────────────────────────────────
27
+
28
+ class C:
29
+ GREEN = "\033[92m"
30
+ RED = "\033[91m"
31
+ YELLOW = "\033[93m"
32
+ BLUE = "\033[94m"
33
+ BOLD = "\033[1m"
34
+ DIM = "\033[2m"
35
+ RESET = "\033[0m"
36
+
37
+ def ok(msg): print(f" {C.GREEN}✓{C.RESET} {msg}")
38
+ def err(msg): print(f" {C.RED}✗{C.RESET} {msg}")
39
+ def warn(msg): print(f" {C.YELLOW}!{C.RESET} {msg}")
40
+ def info(msg): print(f" {C.BLUE}·{C.RESET} {msg}")
41
+ def header(msg): print(f"\n{C.BOLD}{msg}{C.RESET}")
42
+
43
+
44
+ # ── DB path helper ────────────────────────────────────────────────────────────
45
+
46
+ def get_db(db_path: str = "dosync.db"):
47
+ from dosync.db import DoSyncDB
48
+ db = DoSyncDB(db_path)
49
+ db.init()
50
+ return db
51
+
52
+ def get_auth(db):
53
+ from dosync.auth import AuthManager
54
+ return AuthManager(db, enabled=True)
55
+
56
+
57
+ # ── Keys commands ─────────────────────────────────────────────────────────────
58
+
59
+ def keys_list(args):
60
+ header("API Keys")
61
+ db = get_db(args.db)
62
+ auth = get_auth(db)
63
+ keys = auth.list_keys()
64
+
65
+ if not keys:
66
+ warn("No API keys registered.")
67
+ print(" Run: python3 manage.py keys create\n")
68
+ return
69
+
70
+ print(f" {'Preview':<20} {'Label':<20} {'Created':<22} {'Last used'}")
71
+ print(f" {'-'*20} {'-'*20} {'-'*22} {'-'*20}")
72
+ for k in keys:
73
+ created = datetime.fromtimestamp(k["created_at"]).strftime("%Y-%m-%d %H:%M")
74
+ last = datetime.fromtimestamp(k["last_used_at"]).strftime("%Y-%m-%d %H:%M") \
75
+ if k["last_used_at"] else "never"
76
+ print(f" {C.DIM}{k['key_preview']:<20}{C.RESET} "
77
+ f"{k['label']:<20} {created:<22} {last}")
78
+ print()
79
+
80
+
81
+ def keys_create(args):
82
+ header("Create API Key")
83
+ db = get_db(args.db)
84
+ auth = get_auth(db)
85
+ token = auth.generate_key(args.label)
86
+
87
+ print()
88
+ print(f" {C.BOLD}{C.GREEN}New API key generated:{C.RESET}")
89
+ print()
90
+ print(f" {C.BOLD}{token}{C.RESET}")
91
+ print()
92
+ warn("Save this token — it will NOT be shown again.")
93
+ print()
94
+ info(f"Label: {args.label}")
95
+ info("Usage: Authorization: Bearer <token>")
96
+ print()
97
+
98
+
99
+ def keys_reset(args):
100
+ header("Reset API Keys")
101
+ warn("This will DELETE all existing keys and generate a new one.")
102
+ print()
103
+
104
+ if not args.yes:
105
+ confirm = input(" Type 'yes' to confirm: ").strip().lower()
106
+ if confirm != "yes":
107
+ err("Cancelled.")
108
+ return
109
+
110
+ # Delete all keys directly via sqlite
111
+ conn = sqlite3.connect(args.db)
112
+ deleted = conn.execute("DELETE FROM api_keys").rowcount
113
+ conn.commit()
114
+ conn.close()
115
+
116
+ db = get_db(args.db)
117
+ auth = get_auth(db)
118
+ token = auth.generate_key("recovery")
119
+
120
+ print()
121
+ ok(f"Deleted {deleted} existing key(s).")
122
+ print()
123
+ print(f" {C.BOLD}{C.GREEN}New recovery key:{C.RESET}")
124
+ print()
125
+ print(f" {C.BOLD}{token}{C.RESET}")
126
+ print()
127
+ warn("Save this token — it will NOT be shown again.")
128
+ warn("Restart the hub server to apply changes.")
129
+ print()
130
+
131
+
132
+ def keys_revoke(args):
133
+ header("Revoke API Key")
134
+ db = get_db(args.db)
135
+ auth = get_auth(db)
136
+ keys = auth.list_keys()
137
+
138
+ # Find key by preview
139
+ matching = [k for k in keys if k["key_preview"].startswith(args.preview)]
140
+
141
+ if not matching:
142
+ err(f"No key found matching preview: {args.preview}")
143
+ return
144
+
145
+ if len(matching) > 1:
146
+ err("Multiple keys match that preview. Be more specific.")
147
+ for k in matching:
148
+ info(f"{k['key_preview']} — {k['label']}")
149
+ return
150
+
151
+ key = matching[0]
152
+
153
+ if not args.yes:
154
+ confirm = input(
155
+ f" Revoke key '{key['key_preview']}' ({key['label']})? [yes/N]: "
156
+ ).strip().lower()
157
+ if confirm != "yes":
158
+ err("Cancelled.")
159
+ return
160
+
161
+ # Get full hash from DB to delete
162
+ conn = sqlite3.connect(args.db)
163
+ rows = conn.execute("SELECT key_hash FROM api_keys").fetchall()
164
+ conn.close()
165
+
166
+ deleted = False
167
+ for row in rows:
168
+ if row[0].startswith(args.preview.replace("...", "")):
169
+ auth.delete_key(row[0])
170
+ deleted = True
171
+ break
172
+
173
+ if deleted:
174
+ ok(f"Key '{key['key_preview']}' ({key['label']}) revoked.")
175
+ else:
176
+ err("Could not find key hash to delete.")
177
+ print()
178
+
179
+
180
+ # ── DB commands ───────────────────────────────────────────────────────────────
181
+
182
+ def db_stats(args):
183
+ header("Database Stats")
184
+ db = get_db(args.db)
185
+ s = db.stats()
186
+
187
+ info(f"Path: {s['db_path']}")
188
+ info(f"Size: {s['db_size_kb']} KB")
189
+ info(f"Devices: {s['devices']}")
190
+ info(f"Audit entries: {s['audit_entries']}")
191
+
192
+ # Count keys
193
+ conn = sqlite3.connect(args.db)
194
+ keys = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0]
195
+ pres = conn.execute("SELECT COUNT(*) FROM presence_signals").fetchone()[0]
196
+ prof = conn.execute("SELECT COUNT(*) FROM family_profile").fetchone()[0]
197
+ conn.close()
198
+
199
+ info(f"API keys: {keys}")
200
+ info(f"Presence sig.: {pres}")
201
+ info(f"Family profile:{'yes' if prof else 'no'}")
202
+ print()
203
+
204
+
205
+ def db_clean(args):
206
+ header("Clean Database")
207
+ warn("This removes ALL data: devices, audit log, presence signals, family profile.")
208
+ warn("API keys are kept so you don't lose access.")
209
+ print()
210
+
211
+ if not args.yes:
212
+ confirm = input(" Type 'yes' to confirm: ").strip().lower()
213
+ if confirm != "yes":
214
+ err("Cancelled.")
215
+ return
216
+
217
+ conn = sqlite3.connect(args.db)
218
+ conn.execute("DELETE FROM devices")
219
+ conn.execute("DELETE FROM audit_log")
220
+ conn.execute("DELETE FROM presence_signals")
221
+ conn.execute("DELETE FROM family_profile")
222
+ conn.commit()
223
+ conn.close()
224
+
225
+ ok("Database cleaned. API keys preserved.")
226
+ warn("Restart the hub server to apply changes.")
227
+ print()
228
+
229
+
230
+ def db_devices(args):
231
+ header("Registered Devices")
232
+ db = get_db(args.db)
233
+ devices = db.load_devices()
234
+
235
+ if not devices:
236
+ warn("No devices registered.")
237
+ return
238
+
239
+ for d in devices:
240
+ adapter = d.get("adapter", "simulated")
241
+ ip = d.get("adapter_config", {}).get("ip", "") if d.get("adapter_config") else ""
242
+ tags = ", ".join(d.get("tags", []))
243
+ print(f" {C.BOLD}{d['device_id']}{C.RESET}")
244
+ info(f"Name: {d['device_name']}")
245
+ info(f"Adapter: {adapter}" + (f" @ {ip}" if ip else ""))
246
+ info(f"Tags: {tags}")
247
+ print()
248
+
249
+
250
+ # ── Main ──────────────────────────────────────────────────────────────────────
251
+
252
+ def main():
253
+ parser = argparse.ArgumentParser(
254
+ description="DoSync Management CLI",
255
+ formatter_class=argparse.RawDescriptionHelpFormatter,
256
+ epilog="""
257
+ Commands:
258
+ keys list List all API keys
259
+ keys create [--label NAME] Generate a new API key
260
+ keys reset [--yes] Delete all keys and generate a new one
261
+ keys revoke <preview> [--yes] Revoke a specific key
262
+
263
+ db stats Show database statistics
264
+ db clean [--yes] Remove all data (keeps API keys)
265
+ db devices List all registered devices
266
+
267
+ Examples:
268
+ python3 manage.py keys list
269
+ python3 manage.py keys create --label "dashboard"
270
+ python3 manage.py keys reset --yes
271
+ python3 manage.py keys revoke R0hwCgkd
272
+ python3 manage.py db stats
273
+ """,
274
+ )
275
+ parser.add_argument("--db", default="dosync.db",
276
+ help="Path to database (default: dosync.db)")
277
+
278
+ sub = parser.add_subparsers(dest="group")
279
+
280
+ # keys
281
+ keys_parser = sub.add_parser("keys", help="Manage API keys")
282
+ keys_sub = keys_parser.add_subparsers(dest="command")
283
+
284
+ keys_sub.add_parser("list", help="List all API keys")
285
+
286
+ p_create = keys_sub.add_parser("create", help="Generate a new API key")
287
+ p_create.add_argument("--label", default="default", help="Label for this key")
288
+
289
+ p_reset = keys_sub.add_parser("reset", help="Delete all keys and generate new one")
290
+ p_reset.add_argument("--yes", action="store_true", help="Skip confirmation")
291
+
292
+ p_revoke = keys_sub.add_parser("revoke", help="Revoke a specific key")
293
+ p_revoke.add_argument("preview", help="Key preview (first 8 chars)")
294
+ p_revoke.add_argument("--yes", action="store_true", help="Skip confirmation")
295
+
296
+ # db
297
+ db_parser = sub.add_parser("db", help="Database management")
298
+ db_sub = db_parser.add_subparsers(dest="command")
299
+
300
+ db_sub.add_parser("stats", help="Show database statistics")
301
+ db_sub.add_parser("devices", help="List all registered devices")
302
+
303
+ p_clean = db_sub.add_parser("clean", help="Remove all data (keeps API keys)")
304
+ p_clean.add_argument("--yes", action="store_true", help="Skip confirmation")
305
+
306
+ db_sub.add_parser("audit-reset", help="Reset broken audit log chain (creates backup first)")
307
+ p_abak = db_sub.add_parser("audit-backup", help="Back up the audit log to a JSON file (does not modify the log)")
308
+ p_abak.add_argument("--out", default=None, help="Backup file path (default: audit_backup_<ts>.json)")
309
+ p_aver = db_sub.add_parser("audit-verify", help="Verify the audit log SHA-256 chain (live DB, a backup file, or an archive segment)")
310
+ p_aver.add_argument("--file", default=None, help="Verify a backup file instead of the live DB")
311
+ p_aver.add_argument("--segment", default=None, help="Verify an archive segment file standalone (prints its sha256 for cross-checking against the live chain's audit_archived entry)")
312
+ p_arst = db_sub.add_parser("audit-restore", help="Restore the audit log from a backup file")
313
+ p_arst.add_argument("--file", required=True, help="Backup file to restore from")
314
+ p_arst.add_argument("--force", action="store_true", help="Overwrite a non-empty audit log")
315
+
316
+ p_arc = db_sub.add_parser(
317
+ "audit-archive",
318
+ help="Archive the oldest chain entries to an anchored segment file (dry-run; --apply with the hub STOPPED)")
319
+ p_arc.add_argument("--keep", type=int, default=2000,
320
+ help="How many recent entries stay in the live DB (default 2000)")
321
+ p_arc.add_argument("--out", help="Segment file path (default: audit_segment_g<N>_<ts>.json)")
322
+ p_arc.add_argument("--apply", action="store_true",
323
+ help="Write the archive (stop the hub first)")
324
+
325
+ p_mig = db_sub.add_parser(
326
+ "migrate-sensor-kind",
327
+ help="Add SensorSpec.kind to persisted manifests (dry-run; --apply with the hub STOPPED)")
328
+ p_mig.add_argument("--apply", action="store_true",
329
+ help="Write the patches (stop the hub first)")
330
+
331
+ # ── certs subcommand ───────────────────────────────────────────────────────
332
+ certs_parser = sub.add_parser("certs", help="TLS certificate management")
333
+ certs_sub = certs_parser.add_subparsers(dest="command")
334
+
335
+ certs_sub.add_parser("status", help="Show TLS certificate status and expiry dates")
336
+
337
+ p_rotate = certs_sub.add_parser("rotate", help="Renew hub TLS certificate (CA unchanged)")
338
+ p_rotate.add_argument("--ip", default=None, help="Hub IP address (auto-detected if omitted)")
339
+ p_rotate.add_argument("--force", action="store_true", help="Rotate even if cert is not near expiry")
340
+ p_rotate.add_argument("--restart", action="store_true", default=True,
341
+ help="Restart hub service after rotation (default: true)")
342
+ p_rotate.add_argument("--no-restart", dest="restart", action="store_false")
343
+
344
+ p_rotate_adapter = certs_sub.add_parser("rotate-adapter", help="Renew an adapter TLS certificate")
345
+ p_rotate_adapter.add_argument("name", help="Adapter name (e.g. gpio, wiz)")
346
+ p_rotate_adapter.add_argument("--ip", default="127.0.0.1", help="Adapter IP address")
347
+
348
+ args = parser.parse_args()
349
+
350
+ if not Path(args.db).exists() and args.group != "keys":
351
+ err(f"Database not found: {args.db}")
352
+ info("Start the hub server first to create the database.")
353
+ sys.exit(1)
354
+
355
+ # Dispatch
356
+ if args.group == "keys":
357
+ if args.command == "list": keys_list(args)
358
+ elif args.command == "create": keys_create(args)
359
+ elif args.command == "reset": keys_reset(args)
360
+ elif args.command == "revoke": keys_revoke(args)
361
+ else: keys_parser.print_help()
362
+
363
+ elif args.group == "db":
364
+ if args.command == "stats": db_stats(args)
365
+ elif args.command == "devices": db_devices(args)
366
+ elif args.command == "clean": db_clean(args)
367
+ elif args.command == "audit-reset": db_audit_reset(args)
368
+ elif args.command == "audit-backup": db_audit_backup(args)
369
+ elif args.command == "migrate-sensor-kind": db_migrate_sensor_kind(args)
370
+ elif args.command == "audit-archive": db_audit_archive(args)
371
+ elif args.command == "audit-verify": db_audit_verify(args)
372
+ elif args.command == "audit-restore": db_audit_restore(args)
373
+
374
+ elif args.group == "certs":
375
+ if args.command == "status": certs_status(args)
376
+ elif args.command == "rotate": certs_rotate(args)
377
+ elif args.command == "rotate-adapter": certs_rotate_adapter(args)
378
+ else: certs_parser.print_help()
379
+
380
+ else:
381
+ parser.print_help()
382
+
383
+
384
+ def _sensor_kind_patches(manifest: dict) -> list[tuple[str, str, str]]:
385
+ """Compute the kind patches one persisted manifest needs.
386
+
387
+ Returns [(sensor_id, old_kind, new_kind), ...] — empty if nothing to change.
388
+
389
+ The rules come from the ADAPTERS, the single source of truth for what each
390
+ sensor kind should be — not from a table duplicated here that would drift:
391
+ * WiZ bulbs (adapter "wiz" / manufacturer "Philips WiZ"): brightness and
392
+ state describe the lamp → device_state.
393
+ * HA bridge entities (device_id "ha-<domain>-…"): per-sensor kinds exactly
394
+ as HA_DOMAIN_MAP now declares them (climate keeps current_temp as
395
+ environment — it measures the room).
396
+ * Everything else: untouched. This migration only ADDS "kind" keys; it
397
+ never removes or rewrites anything an operator declared.
398
+ """
399
+ caps = manifest.get("capabilities", {})
400
+ sensors = caps.get("sensors", [])
401
+ if not sensors:
402
+ return []
403
+
404
+ device_id = manifest.get("device_id", "")
405
+ wanted: dict[str, str] = {}
406
+
407
+ if manifest.get("adapter") == "wiz" or manifest.get("manufacturer") == "Philips WiZ":
408
+ wanted = {"brightness": "device_state", "state": "device_state"}
409
+ elif device_id.startswith("ha-"):
410
+ from dosync.adapters.homeassistant import HA_DOMAIN_MAP
411
+ domain = device_id.split("-", 2)[1] if device_id.count("-") >= 2 else ""
412
+ spec = HA_DOMAIN_MAP.get(domain)
413
+ if spec:
414
+ wanted = {sn.id: sn.kind for sn in spec["sensors"]}
415
+
416
+ patches = []
417
+ for sn in sensors:
418
+ new = wanted.get(sn.get("id"))
419
+ old = sn.get("kind", "environment")
420
+ if new and old != new:
421
+ patches.append((sn["id"], old, new))
422
+ return patches
423
+
424
+
425
+ def db_migrate_sensor_kind(args):
426
+ """SENSOR-KIND data migration (2026-07-17).
427
+
428
+ Persisted manifests predate SensorSpec.kind, so on restore every sensor
429
+ defaults to "environment" — including a lamp's brightness. Re-registration
430
+ through the adapters would fix it, but the API path is LOSSY by design
431
+ (GET /v1/devices strips adapter_config, which holds the lamp IPs), and
432
+ discovery generates wiz-auto-<ip> ids that would duplicate room-named
433
+ devices. This is a data migration, so it is done as one: hub stopped, the
434
+ manifest_json patched in place — only ADDING "kind" keys, everything else
435
+ byte-for-byte intact — hub restarted.
436
+
437
+ Dry-run by default; --apply writes. STOP THE HUB FIRST for --apply: SQLite
438
+ has one writer, and the hub's in-memory registry would diverge from the DB
439
+ until restart anyway.
440
+ """
441
+ db = get_db(args.db)
442
+ manifests = db.load_devices()
443
+
444
+ plan = []
445
+ for m in manifests:
446
+ patches = _sensor_kind_patches(m)
447
+ if patches:
448
+ plan.append((m, patches))
449
+
450
+ if not plan:
451
+ print("Sensor-kind migration\n Nothing to do — every manifest already declares its kinds.")
452
+ return
453
+
454
+ total = sum(len(p) for _, p in plan)
455
+ print(f"Sensor-kind migration — {len(plan)} device(s), {total} sensor(s) to patch\n")
456
+ for m, patches in plan:
457
+ print(f" {m['device_id']}")
458
+ for sid, old, new in patches:
459
+ print(f" {sid}: {old} -> {new}")
460
+
461
+ if not args.apply:
462
+ print("\nDry run — nothing written. Re-run with --apply (WITH THE HUB STOPPED) to write.")
463
+ return
464
+
465
+ import copy
466
+ with db._cursor() as cur:
467
+ for m, patches in plan:
468
+ patched = copy.deepcopy(m)
469
+ wanted = {sid: new for sid, _, new in patches}
470
+ for sn in patched["capabilities"]["sensors"]:
471
+ if sn.get("id") in wanted:
472
+ sn["kind"] = wanted[sn["id"]]
473
+ cur.execute(
474
+ "UPDATE devices SET manifest_json = ? WHERE device_id = ?",
475
+ (json.dumps(patched), patched["device_id"]),
476
+ )
477
+ print(f"\nApplied. Restart the hub; then re-run this command — it must report "
478
+ f"'Nothing to do' (the migration is idempotent).")
479
+
480
+
481
+ def db_audit_archive(args):
482
+ """AUDIT-ARCHIVE (2026-07-19): segment the chain with a hash anchor.
483
+
484
+ The live chain grows without bound (24k entries and roughly doubling every
485
+ few days at the reference deployment), all of it reloaded into memory at
486
+ every hub start. Archiving moves the oldest entries to a self-describing
487
+ SEGMENT file while keeping the cryptography honest end to end:
488
+
489
+ * the segment records the anchor it chains FROM, so it verifies standalone;
490
+ * the DB stores the new anchor (last archived hash), so the live chain
491
+ verifies from there instead of genesis;
492
+ * and the act of archiving leaves its OWN `audit_archived` entry in the
493
+ live chain, binding the segment file's SHA-256 — the same philosophy as
494
+ policy_modified binding the policy file: an operation this consequential
495
+ must itself be tamper-evident. A silently swapped archive file would
496
+ contradict the hash the chain remembers.
497
+
498
+ Consecutive generations interlock (segment N+1's anchor == segment N's
499
+ last_hash), so the FULL history remains verifiable by walking the segments
500
+ in order and then the live DB. Dry-run by default; --apply writes.
501
+ RUN WITH THE HUB STOPPED: the hub's in-memory chain would diverge until
502
+ restart, and SQLite has one writer.
503
+ """
504
+ from dosync import audit_backup as ab
505
+
506
+ keep = args.keep
507
+ if keep < 1:
508
+ print("audit-archive — --keep must be >= 1 (the chain head stays live)")
509
+ sys.exit(1)
510
+
511
+ db = get_db(args.db)
512
+ anchor = db.get_audit_anchor() or {}
513
+ start_anchor = anchor.get("anchor_prev_hash", ab.GENESIS)
514
+ generation = anchor.get("generations", 0) + 1
515
+
516
+ with db._cursor() as cur:
517
+ cur.execute("SELECT id, entry_json FROM audit_log ORDER BY timestamp, id")
518
+ rows = cur.fetchall()
519
+ entries = [json.loads(r[1]) for r in rows]
520
+
521
+ # Fail-loudly: refuse to archive a chain that does not verify. Archiving
522
+ # would freeze the corruption into a "trusted" segment.
523
+ if not ab.verify_entries(entries, start_anchor):
524
+ print("audit-archive — REFUSED\n The live chain does not verify from its anchor. "
525
+ "Investigate before archiving; archiving now would enshrine the corruption.")
526
+ sys.exit(1)
527
+
528
+ if len(entries) <= keep:
529
+ print(f"audit-archive\n Nothing to do — {len(entries)} entries, keep={keep}.")
530
+ return
531
+
532
+ cut = len(entries) - keep
533
+ archived, remaining = entries[:cut], entries[cut:]
534
+ out = args.out or f"audit_segment_g{generation}_{int(time.time())}.json"
535
+
536
+ print(f"audit-archive — generation {generation}")
537
+ print(f" Live entries: {len(entries)}")
538
+ print(f" To archive: {len(archived)} (through hash {archived[-1]['hash'][:16]}...)")
539
+ print(f" To keep live: {len(remaining)}")
540
+ print(f" Segment file: {out}")
541
+ if not args.apply:
542
+ print("\nDry run — nothing written. Re-run with --apply (WITH THE HUB STOPPED) to archive.")
543
+ return
544
+
545
+ manifest = ab.write_segment(archived, out, start_anchor, generation)
546
+ seg_sha = ab.file_sha256(out)
547
+
548
+ # The archival is itself a chain event: append audit_archived at the tail,
549
+ # computed EXACTLY like AuditLog.append (prev_hash, timestamp, sorted-json
550
+ # sha256) so the live chain stays verifiable.
551
+ tail_hash = remaining[-1]["hash"]
552
+ arch_entry = {
553
+ "type": "audit_archived",
554
+ "generation": generation,
555
+ "archived_count": len(archived),
556
+ "segment_first_hash": manifest["first_hash"],
557
+ "segment_last_hash": manifest["last_hash"],
558
+ "segment_file": os.path.basename(out),
559
+ "segment_sha256": seg_sha,
560
+ "prev_hash": tail_hash,
561
+ "timestamp": time.time(),
562
+ }
563
+ raw = json.dumps(arch_entry, sort_keys=True)
564
+ arch_entry["hash"] = hashlib.sha256(raw.encode()).hexdigest()
565
+
566
+ archived_ids = [r[0] for r in rows[:cut]]
567
+ with db._cursor() as cur:
568
+ cur.executemany("DELETE FROM audit_log WHERE id = ?",
569
+ [(i,) for i in archived_ids])
570
+ cur.execute("INSERT INTO audit_log (entry_json, hash, timestamp) VALUES (?, ?, ?)",
571
+ (json.dumps(arch_entry), arch_entry["hash"], arch_entry["timestamp"]))
572
+ db.set_audit_anchor({
573
+ "anchor_prev_hash": manifest["last_hash"],
574
+ "generations": generation,
575
+ "archived_total": anchor.get("archived_total", 0) + len(archived),
576
+ "last_archive_file": out,
577
+ "last_archive_sha256": seg_sha,
578
+ "archived_at": time.time(),
579
+ })
580
+
581
+ print(f"\nArchived. Segment sha256: {seg_sha[:32]}...")
582
+ print(f" The live chain now anchors at {manifest['last_hash'][:16]}... and carries an")
583
+ print(f" audit_archived entry binding the segment file. Restart the hub; then run")
584
+ print(f" 'db audit-verify' (live) and 'db audit-verify --segment {out}' to confirm both.")
585
+
586
+
587
+ def db_audit_backup(args):
588
+ """Back up the audit log to a self-describing JSON file. Read-only."""
589
+ from dosync import audit_backup
590
+ db = get_db(args.db)
591
+ entries = db.load_audit_log()
592
+ out = args.out or f"audit_backup_{int(time.time())}.json"
593
+ _anchor = db.get_audit_anchor()
594
+ try:
595
+ manifest = audit_backup.write_backup(
596
+ entries, out,
597
+ anchor_prev_hash=(_anchor or {}).get("anchor_prev_hash", audit_backup.GENESIS))
598
+ except OSError as e:
599
+ # This runs unattended from a systemd timer: a Python traceback in the
600
+ # journal tells an operator nothing actionable. Say what failed and why.
601
+ print(f"Audit backup — FAILED\n Cannot write {out}: {e.strerror}")
602
+ print(" Check the directory exists and is writable by this user.")
603
+ sys.exit(1)
604
+ ok = manifest["chain_valid_at_backup"]
605
+ print("Audit backup")
606
+ print(f" Entries: {manifest['count']}")
607
+ print(f" Chain valid: {'yes' if ok else 'NO — chain is broken (backup still written for review)'}")
608
+ print(f" payload sha256:{manifest['payload_sha256'][:32]}...")
609
+ print(f" Written to: {out}")
610
+ if not ok:
611
+ print(" WARNING: the live chain does not verify. Investigate before trusting this log.")
612
+
613
+
614
+ def db_audit_verify(args):
615
+ """Verify the SHA-256 chain of the live audit log, or of a backup file."""
616
+ from dosync import audit_backup
617
+ anchor_prev = audit_backup.GENESIS
618
+ anchor_note = ""
619
+ if getattr(args, "segment", None):
620
+ try:
621
+ doc = audit_backup.read_segment(args.segment)
622
+ except ValueError as e:
623
+ print(f"Audit verify — FAILED\n {e}")
624
+ sys.exit(1)
625
+ entries = doc["entries"]
626
+ m = doc["manifest"]
627
+ anchor_prev = m["anchor_prev_hash"]
628
+ source = f"archive segment {args.segment} (generation {m['generation']})"
629
+ anchor_note = (f" Anchors from: {anchor_prev[:16]}...\n"
630
+ f" File sha256: {audit_backup.file_sha256(args.segment)[:32]}... "
631
+ f"(cross-check against the audit_archived entry in the live chain)")
632
+ elif args.file:
633
+ try:
634
+ doc = audit_backup.read_backup(args.file) # also checks file-level checksum
635
+ except ValueError as e:
636
+ print(f"Audit verify — FAILED\n {e}")
637
+ sys.exit(1)
638
+ entries = doc["entries"]
639
+ anchor_prev = doc.get("anchor_prev_hash", audit_backup.GENESIS)
640
+ source = f"backup file {args.file}"
641
+ else:
642
+ db = get_db(args.db)
643
+ entries = db.load_audit_log()
644
+ db_anchor = db.get_audit_anchor()
645
+ if db_anchor:
646
+ anchor_prev = db_anchor.get("anchor_prev_hash", audit_backup.GENESIS)
647
+ anchor_note = (f" Anchored: generation {db_anchor.get('generations')}, "
648
+ f"{db_anchor.get('archived_total')} entries archived "
649
+ f"(latest: {db_anchor.get('last_archive_file')})")
650
+ source = "live database"
651
+ ok = audit_backup.verify_entries(entries, anchor_prev)
652
+ print("Audit verify")
653
+ print(f" Source: {source}")
654
+ print(f" Entries: {len(entries)}")
655
+ if anchor_note:
656
+ print(anchor_note)
657
+ print(f" Chain valid: {'yes ✓' if ok else 'NO ✗ — tamper or corruption detected'}")
658
+ sys.exit(0 if ok else 1)
659
+
660
+
661
+ def db_audit_restore(args):
662
+ """Restore the audit log from a backup file (refuses to clobber unless --force)."""
663
+ from dosync import audit_backup
664
+ try:
665
+ doc = audit_backup.read_backup(args.file)
666
+ except ValueError as e:
667
+ print(f"Audit restore — ABORTED\n {e}")
668
+ sys.exit(1)
669
+ entries = doc["entries"]
670
+ if not audit_backup.verify_entries(entries):
671
+ print("Audit restore — ABORTED\n The backup's own chain does not verify; refusing to restore a broken log.")
672
+ sys.exit(1)
673
+
674
+ db = get_db(args.db)
675
+ existing = db.audit_count()
676
+ if existing > 0 and not args.force:
677
+ print(f"Audit restore — ABORTED\n The audit log already has {existing} entries. "
678
+ "Use --force to overwrite (the current log is NOT auto-backed-up; run "
679
+ "'audit-backup' first if you want to keep it).")
680
+ sys.exit(1)
681
+
682
+ import sqlite3 as _sq
683
+ conn = _sq.connect(args.db)
684
+ try:
685
+ conn.execute("DELETE FROM audit_log")
686
+ for entry in entries:
687
+ conn.execute(
688
+ "INSERT INTO audit_log (entry_json, hash, timestamp) VALUES (?, ?, ?)",
689
+ (json.dumps(entry), entry.get("hash", ""), entry.get("timestamp", time.time())),
690
+ )
691
+ conn.commit()
692
+ finally:
693
+ conn.close()
694
+
695
+ # re-verify what actually landed in the DB
696
+ db2 = get_db(args.db)
697
+ ok = audit_backup.verify_entries(db2.load_audit_log())
698
+ print("Audit restore")
699
+ print(f" Restored: {len(entries)} entries from {args.file}")
700
+ print(f" Chain valid: {'yes ✓' if ok else 'NO ✗ — restore produced a broken chain!'}")
701
+ sys.exit(0 if ok else 1)
702
+
703
+
704
+ def db_audit_reset(args):
705
+ """
706
+ Reset the audit log chain after detecting integrity violations.
707
+
708
+ This command:
709
+ 1. Exports the current (broken) audit log to a JSON backup file
710
+ 2. Clears the audit_log table
711
+ 3. Creates a new first entry that documents the reset event,
712
+ preserving accountability for the reset operation itself
713
+
714
+ IMPORTANT: This operation is itself auditable. The reset entry records
715
+ the reason, the number of previous entries, and the timestamp.
716
+ The backup file preserves all previous entries for external review.
717
+
718
+ Use this ONLY when the audit chain was broken by an external cause
719
+ (e.g., a test hub writing to the production DB). Never use it to
720
+ conceal legitimate audit entries.
721
+ """
722
+ import json
723
+ import hashlib
724
+
725
+ db_path = args.db
726
+ if not Path(db_path).exists():
727
+ print(f" Error: database not found: {db_path}")
728
+ sys.exit(1)
729
+
730
+ conn = sqlite3.connect(db_path)
731
+ cur = conn.cursor()
732
+
733
+ # Count existing entries
734
+ cur.execute("SELECT COUNT(*) FROM audit_log")
735
+ count = cur.fetchone()[0]
736
+
737
+ # Export to backup
738
+ backup_path = f"audit_log_backup_{int(time.time())}.json"
739
+ cur.execute("SELECT entry_json, hash, timestamp FROM audit_log ORDER BY rowid")
740
+ rows = cur.fetchall()
741
+ backup = [{"entry": json.loads(r[0]), "hash": r[1], "timestamp": r[2]} for r in rows]
742
+ backup_json = json.dumps(backup, indent=2, sort_keys=True)
743
+ with open(backup_path, "w") as f:
744
+ f.write(backup_json)
745
+ backup_sha256 = hashlib.sha256(backup_json.encode()).hexdigest()
746
+ print(f" Backed up {count} entries to {backup_path}")
747
+ print(f" Backup SHA-256: {backup_sha256}")
748
+
749
+ # Confirm
750
+ print(f" This will clear {count} audit log entries and start a new chain.")
751
+ confirm = input(" Type YES to confirm: ").strip()
752
+ if confirm != "YES":
753
+ print(" Aborted.")
754
+ conn.close()
755
+ return
756
+
757
+ # Clear audit log
758
+ cur.execute("DELETE FROM audit_log")
759
+
760
+ # Create reset entry — must match AuditLog.append() format:
761
+ # entry_json includes prev_hash and hash INSIDE the JSON dict
762
+ now = time.time()
763
+ genesis_hash = hashlib.sha256(b"dosync-audit-genesis").hexdigest()
764
+ reset_entry = {
765
+ "type": "audit_log_reset",
766
+ "reason": "Chain integrity violation — see DESIGN-PRINCIPLES.md",
767
+ "previous_entries": count,
768
+ "backup_file": backup_path,
769
+ "backup_sha256": backup_sha256,
770
+ "timestamp": now,
771
+ "prev_hash": genesis_hash,
772
+ }
773
+ # Hash is calculated over the entry WITHOUT the hash field (same as AuditLog.append)
774
+ raw = json.dumps(reset_entry, sort_keys=True)
775
+ new_hash = hashlib.sha256(f"{genesis_hash}{raw}".encode()).hexdigest()
776
+ reset_entry["hash"] = new_hash
777
+ entry_json = json.dumps(reset_entry, sort_keys=True)
778
+
779
+ cur.execute(
780
+ "INSERT INTO audit_log (entry_json, hash, timestamp) VALUES (?, ?, ?)",
781
+ (entry_json, new_hash, now)
782
+ )
783
+ conn.commit()
784
+ conn.close()
785
+
786
+ print(f" {C.GREEN}Audit log reset.{C.RESET} New chain started with 1 entry.")
787
+ print(f" Previous entries backed up to: {backup_path}")
788
+ print(f" Restart the hub to pick up the new chain.")
789
+
790
+
791
+
792
+
793
+ # ── Certs commands ─────────────────────────────────────────────────────────────
794
+
795
+ def certs_status(args):
796
+ """Show TLS certificate status and expiry for CA, hub, and all adapters."""
797
+ try:
798
+ from dosync.security import get_status
799
+ except ImportError as e:
800
+ err(f"Could not import dosync.security: {e}")
801
+ sys.exit(1)
802
+
803
+ header("TLS Certificate Status")
804
+ status = get_status()
805
+
806
+ if not status.ca_exists:
807
+ err("CA certificate not found — run: python3 -m dosync.security setup")
808
+ return
809
+
810
+ ca = status.ca_info
811
+ if ca:
812
+ expiry = f"{ca.days_until_expiry}d remaining" if not ca.is_expired else "EXPIRED"
813
+ flag = C.RED if ca.is_expired else (C.YELLOW if ca.is_expiring_soon else C.GREEN)
814
+ ok(f"CA cert expires {ca.not_after} ({flag}{expiry}{C.RESET})")
815
+ else:
816
+ warn("CA cert found but could not read details")
817
+
818
+ hub = status.hub_info
819
+ if hub:
820
+ expiry = f"{hub.days_until_expiry}d remaining" if not hub.is_expired else "EXPIRED"
821
+ flag = C.RED if hub.is_expired else (C.YELLOW if hub.is_expiring_soon else C.GREEN)
822
+ ok(f"Hub cert expires {hub.not_after} ({flag}{expiry}{C.RESET})")
823
+ else:
824
+ warn("Hub cert not found")
825
+
826
+ for adapter in status.adapter_certs:
827
+ expiry = f"{adapter.days_until_expiry}d remaining" if not adapter.is_expired else "EXPIRED"
828
+ flag = C.RED if adapter.is_expired else (C.YELLOW if adapter.is_expiring_soon else C.GREEN)
829
+ ok(f"Adapter [{adapter.subject:<20}] expires {adapter.not_after} ({flag}{expiry}{C.RESET})")
830
+
831
+ if status.errors:
832
+ print()
833
+ for e in status.errors:
834
+ err(e)
835
+ elif status.is_ready:
836
+ print()
837
+ ok("All certificates are valid.")
838
+
839
+
840
+ def certs_rotate(args):
841
+ """Renew the hub TLS certificate. The CA is not changed."""
842
+ try:
843
+ from dosync.security import renew_hub_cert, detect_hub_ip, get_status
844
+ except ImportError as e:
845
+ err(f"Could not import dosync.security: {e}")
846
+ sys.exit(1)
847
+
848
+ header("Hub Certificate Rotation")
849
+
850
+ status = get_status()
851
+ hub = status.hub_info
852
+ if hub and not hub.is_expiring_soon and not hub.is_expired and not args.force:
853
+ ok(f"Hub cert is valid for {hub.days_until_expiry} more days — rotation not needed.")
854
+ info("Use --force to rotate anyway.")
855
+ return
856
+
857
+ ip = args.ip or detect_hub_ip()
858
+ info(f"Hub IP: {ip}")
859
+ info("Renewing hub certificate (CA unchanged)...")
860
+
861
+ try:
862
+ renew_hub_cert(hub_ip=ip)
863
+ ok("Hub certificate renewed.")
864
+ except Exception as e:
865
+ err(f"Rotation failed: {e}")
866
+ sys.exit(1)
867
+
868
+ if args.restart:
869
+ info("Restarting hub service...")
870
+ import subprocess
871
+ result = subprocess.run(
872
+ ["sudo", "systemctl", "restart", "dosync"],
873
+ capture_output=True, text=True
874
+ )
875
+ if result.returncode == 0:
876
+ ok("Hub service restarted.")
877
+ else:
878
+ warn(f"Could not restart service automatically: {result.stderr.strip()}")
879
+ info("Run manually: sudo systemctl restart dosync")
880
+ else:
881
+ warn("Restart skipped. Run: sudo systemctl restart dosync")
882
+
883
+ print()
884
+ ok("Rotation complete. Clients that trust the CA cert do not need updating.")
885
+ info("The CA cert has not changed — no client redistribution required.")
886
+
887
+
888
+ def certs_rotate_adapter(args):
889
+ """Renew a specific adapter TLS certificate."""
890
+ try:
891
+ from dosync.security import renew_adapter_cert
892
+ except ImportError as e:
893
+ err(f"Could not import dosync.security: {e}")
894
+ sys.exit(1)
895
+
896
+ header(f"Adapter Certificate Rotation — {args.name}")
897
+ info(f"Adapter: {args.name} IP: {args.ip}")
898
+
899
+ try:
900
+ cert_path, key_path = renew_adapter_cert(name=args.name, adapter_ip=args.ip)
901
+ ok(f"Adapter cert renewed: {cert_path}")
902
+ ok(f"Adapter key: {key_path}")
903
+ except Exception as e:
904
+ err(f"Rotation failed: {e}")
905
+ sys.exit(1)
906
+
907
+ print()
908
+ ok("Adapter cert rotation complete.")
909
+ info("Restart the adapter process to apply the new certificate.")
910
+
911
+
912
+ if __name__ == "__main__":
913
+ main()