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