registration.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980
  1. """
  2. Satellite Registration and Blacklist Management
  3. This module implements satellite registration file management and blacklist functionality
  4. for the Trixy satellite management system. It provides MAC-based registration file
  5. storage and comprehensive blacklist management as specified in CLAUDE.md.
  6. Key Features:
  7. - MAC-based registration file management (JSON format)
  8. - Satellite registration validation and persistence
  9. - Blacklist management with reason tracking
  10. - Thread-safe operations for concurrent access
  11. - Integration with satellite manager
  12. - Comprehensive logging and error handling
  13. - Automatic file backup and recovery
  14. """
  15. import json
  16. import threading
  17. import shutil
  18. from typing import Dict, List, Optional, Any, Set, Tuple
  19. from dataclasses import dataclass, field
  20. from datetime import datetime, timezone
  21. from pathlib import Path
  22. from enum import Enum
  23. import os
  24. import uuid
  25. from .satellite import SatelliteInfo, SatelliteCapability
  26. def pprint(message: str) -> None:
  27. """Registration system logging function."""
  28. print(f"[REGISTRATION] {message}")
  29. class RegistrationStatus(Enum):
  30. """Status of satellite registration."""
  31. PENDING = "pending"
  32. APPROVED = "approved"
  33. REJECTED = "rejected"
  34. EXPIRED = "expired"
  35. REVOKED = "revoked"
  36. @dataclass
  37. class SatelliteRegistration:
  38. """
  39. Satellite registration record with complete information.
  40. This class stores all information about a satellite registration,
  41. including approval status, timestamps, and metadata.
  42. """
  43. satellite_info: SatelliteInfo
  44. status: RegistrationStatus = RegistrationStatus.PENDING
  45. registered_by: Optional[str] = None
  46. approved_by: Optional[str] = None
  47. approved_at: Optional[datetime] = None
  48. created_at: Optional[datetime] = field(default_factory=lambda: datetime.now(timezone.utc))
  49. updated_at: Optional[datetime] = field(default_factory=lambda: datetime.now(timezone.utc))
  50. expires_at: Optional[datetime] = None
  51. notes: str = ""
  52. metadata: Dict[str, Any] = field(default_factory=dict)
  53. def to_dict(self) -> Dict[str, Any]:
  54. """Convert registration to dictionary representation."""
  55. return {
  56. "satellite_info": self.satellite_info.to_dict(),
  57. "status": self.status.value,
  58. "registered_by": self.registered_by,
  59. "approved_by": self.approved_by,
  60. "approved_at": self.approved_at.isoformat() if self.approved_at else None,
  61. "created_at": self.created_at.isoformat() if self.created_at else None,
  62. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  63. "expires_at": self.expires_at.isoformat() if self.expires_at else None,
  64. "notes": self.notes,
  65. "metadata": self.metadata,
  66. }
  67. @classmethod
  68. def from_dict(cls, data: Dict[str, Any]) -> 'SatelliteRegistration':
  69. """Create registration from dictionary representation."""
  70. return cls(
  71. satellite_info=SatelliteInfo.from_dict(data["satellite_info"]),
  72. status=RegistrationStatus(data.get("status", "pending")),
  73. registered_by=data.get("registered_by"),
  74. approved_by=data.get("approved_by"),
  75. approved_at=datetime.fromisoformat(data["approved_at"]) if data.get("approved_at") else None,
  76. created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
  77. updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
  78. expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None,
  79. notes=data.get("notes", ""),
  80. metadata=data.get("metadata", {}),
  81. )
  82. def update_timestamp(self) -> None:
  83. """Update the updated_at timestamp."""
  84. self.updated_at = datetime.now(timezone.utc)
  85. def approve(self, approved_by: Optional[str] = None, notes: str = "") -> None:
  86. """Approve this registration."""
  87. self.status = RegistrationStatus.APPROVED
  88. self.approved_by = approved_by
  89. self.approved_at = datetime.now(timezone.utc)
  90. if notes:
  91. self.notes = notes
  92. self.update_timestamp()
  93. def reject(self, reason: str = "") -> None:
  94. """Reject this registration."""
  95. self.status = RegistrationStatus.REJECTED
  96. if reason:
  97. self.notes = reason
  98. self.update_timestamp()
  99. def revoke(self, reason: str = "") -> None:
  100. """Revoke this registration."""
  101. self.status = RegistrationStatus.REVOKED
  102. if reason:
  103. self.notes = reason
  104. self.update_timestamp()
  105. def is_active(self) -> bool:
  106. """Check if registration is currently active."""
  107. if self.status != RegistrationStatus.APPROVED:
  108. return False
  109. if self.expires_at and datetime.now(timezone.utc) > self.expires_at:
  110. return False
  111. return True
  112. def is_expired(self) -> bool:
  113. """Check if registration has expired."""
  114. return (self.expires_at is not None and
  115. datetime.now(timezone.utc) > self.expires_at)
  116. @dataclass
  117. class BlacklistEntry:
  118. """
  119. Blacklist entry with reason and metadata.
  120. This class stores information about blacklisted MAC addresses,
  121. including the reason for blacklisting and tracking information.
  122. """
  123. mac_address: str
  124. reason: str = "manual_blacklist"
  125. blacklisted_by: Optional[str] = None
  126. blacklisted_at: Optional[datetime] = field(default_factory=lambda: datetime.now(timezone.utc))
  127. expires_at: Optional[datetime] = None
  128. notes: str = ""
  129. metadata: Dict[str, Any] = field(default_factory=dict)
  130. def to_dict(self) -> Dict[str, Any]:
  131. """Convert blacklist entry to dictionary representation."""
  132. return {
  133. "mac_address": self.mac_address,
  134. "reason": self.reason,
  135. "blacklisted_by": self.blacklisted_by,
  136. "blacklisted_at": self.blacklisted_at.isoformat() if self.blacklisted_at else None,
  137. "expires_at": self.expires_at.isoformat() if self.expires_at else None,
  138. "notes": self.notes,
  139. "metadata": self.metadata,
  140. }
  141. @classmethod
  142. def from_dict(cls, data: Dict[str, Any]) -> 'BlacklistEntry':
  143. """Create blacklist entry from dictionary representation."""
  144. return cls(
  145. mac_address=data["mac_address"],
  146. reason=data.get("reason", "manual_blacklist"),
  147. blacklisted_by=data.get("blacklisted_by"),
  148. blacklisted_at=datetime.fromisoformat(data["blacklisted_at"]) if data.get("blacklisted_at") else None,
  149. expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None,
  150. notes=data.get("notes", ""),
  151. metadata=data.get("metadata", {}),
  152. )
  153. def is_active(self) -> bool:
  154. """Check if blacklist entry is currently active."""
  155. if self.expires_at and datetime.now(timezone.utc) > self.expires_at:
  156. return False
  157. return True
  158. def is_expired(self) -> bool:
  159. """Check if blacklist entry has expired."""
  160. return (self.expires_at is not None and
  161. datetime.now(timezone.utc) > self.expires_at)
  162. class RegistrationError(Exception):
  163. """Base exception for registration system errors."""
  164. pass
  165. class RegistrationValidationError(RegistrationError):
  166. """Raised when registration validation fails."""
  167. pass
  168. class RegistrationFileError(RegistrationError):
  169. """Raised when registration file operations fail."""
  170. pass
  171. class BlacklistError(RegistrationError):
  172. """Raised when blacklist operations fail."""
  173. pass
  174. class RegistrationManager:
  175. """
  176. Manages satellite registration files and validation.
  177. This class handles the creation, validation, and persistence of
  178. satellite registration records. It maintains thread-safe access
  179. to registration files and provides comprehensive validation.
  180. """
  181. def __init__(
  182. self,
  183. registration_dir: Optional[str] = None,
  184. backup_dir: Optional[str] = None,
  185. auto_backup: bool = True,
  186. max_backups: int = 10
  187. ):
  188. """
  189. Initialize the registration manager.
  190. Args:
  191. registration_dir: Directory for registration files
  192. backup_dir: Directory for backup files
  193. auto_backup: Enable automatic backups
  194. max_backups: Maximum number of backup files to keep
  195. """
  196. self._registration_dir = Path(registration_dir or "config/satellites/registrations")
  197. self._backup_dir = Path(backup_dir or "config/satellites/backups")
  198. self._auto_backup = auto_backup
  199. self._max_backups = max_backups
  200. # Ensure directories exist
  201. self._registration_dir.mkdir(parents=True, exist_ok=True)
  202. if self._auto_backup:
  203. self._backup_dir.mkdir(parents=True, exist_ok=True)
  204. # Thread safety
  205. self._lock = threading.RLock()
  206. # Cache for loaded registrations
  207. self._registrations_cache: Dict[str, SatelliteRegistration] = {}
  208. self._cache_dirty = True
  209. pprint(f"RegistrationManager initialized (dir: {self._registration_dir})")
  210. def create_registration(
  211. self,
  212. satellite_info: SatelliteInfo,
  213. registered_by: Optional[str] = None,
  214. notes: str = "",
  215. auto_approve: bool = False,
  216. expires_in_days: Optional[int] = None
  217. ) -> SatelliteRegistration:
  218. """
  219. Create a new satellite registration.
  220. Args:
  221. satellite_info: Complete satellite information
  222. registered_by: User who created the registration
  223. notes: Additional notes
  224. auto_approve: Automatically approve registration
  225. expires_in_days: Days until registration expires
  226. Returns:
  227. SatelliteRegistration: The created registration
  228. Raises:
  229. RegistrationValidationError: If validation fails
  230. """
  231. with self._lock:
  232. # Validate satellite info
  233. self._validate_satellite_info(satellite_info)
  234. # Check if already registered
  235. existing = self.get_registration(satellite_info.mac_address)
  236. if existing and existing.is_active():
  237. raise RegistrationValidationError(
  238. f"Satellite {satellite_info.mac_address} is already registered"
  239. )
  240. # Calculate expiration
  241. expires_at = None
  242. if expires_in_days:
  243. from datetime import timedelta
  244. expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
  245. # Create registration
  246. registration = SatelliteRegistration(
  247. satellite_info=satellite_info,
  248. status=RegistrationStatus.APPROVED if auto_approve else RegistrationStatus.PENDING,
  249. registered_by=registered_by,
  250. approved_by=registered_by if auto_approve else None,
  251. approved_at=datetime.now(timezone.utc) if auto_approve else None,
  252. expires_at=expires_at,
  253. notes=notes
  254. )
  255. # Save to file
  256. self._save_registration(registration)
  257. # Update cache
  258. self._registrations_cache[satellite_info.mac_address.lower()] = registration
  259. status_text = "approved" if auto_approve else "pending"
  260. pprint(f"Registration created: {satellite_info.alias_name} ({satellite_info.mac_address}) - {status_text}")
  261. return registration
  262. def get_registration(self, mac_address: str) -> Optional[SatelliteRegistration]:
  263. """
  264. Get registration for a MAC address.
  265. Args:
  266. mac_address: MAC address to look up
  267. Returns:
  268. Optional[SatelliteRegistration]: Registration if found
  269. """
  270. with self._lock:
  271. mac_address = mac_address.lower()
  272. # Check cache first
  273. if not self._cache_dirty and mac_address in self._registrations_cache:
  274. return self._registrations_cache[mac_address]
  275. # Load from file
  276. registration = self._load_registration(mac_address)
  277. # Update cache
  278. if registration:
  279. self._registrations_cache[mac_address] = registration
  280. return registration
  281. def update_registration(
  282. self,
  283. mac_address: str,
  284. updates: Dict[str, Any],
  285. updated_by: Optional[str] = None
  286. ) -> bool:
  287. """
  288. Update an existing registration.
  289. Args:
  290. mac_address: MAC address of registration to update
  291. updates: Dictionary of fields to update
  292. updated_by: User making the update
  293. Returns:
  294. bool: True if updated successfully
  295. """
  296. with self._lock:
  297. registration = self.get_registration(mac_address)
  298. if not registration:
  299. return False
  300. # Backup before updating
  301. if self._auto_backup:
  302. self._create_backup(registration)
  303. # Apply updates
  304. for key, value in updates.items():
  305. if key == "status" and isinstance(value, str):
  306. registration.status = RegistrationStatus(value)
  307. elif key == "satellite_info" and isinstance(value, dict):
  308. registration.satellite_info = SatelliteInfo.from_dict(value)
  309. elif hasattr(registration, key):
  310. setattr(registration, key, value)
  311. # Update metadata
  312. registration.metadata["updated_by"] = updated_by
  313. registration.update_timestamp()
  314. # Save to file
  315. self._save_registration(registration)
  316. # Update cache
  317. self._registrations_cache[mac_address.lower()] = registration
  318. pprint(f"Registration updated: {mac_address}")
  319. return True
  320. def approve_registration(
  321. self,
  322. mac_address: str,
  323. approved_by: Optional[str] = None,
  324. notes: str = ""
  325. ) -> bool:
  326. """
  327. Approve a pending registration.
  328. Args:
  329. mac_address: MAC address to approve
  330. approved_by: User approving the registration
  331. notes: Approval notes
  332. Returns:
  333. bool: True if approved successfully
  334. """
  335. with self._lock:
  336. registration = self.get_registration(mac_address)
  337. if not registration:
  338. return False
  339. if registration.status != RegistrationStatus.PENDING:
  340. pprint(f"Cannot approve registration {mac_address}: status is {registration.status.value}")
  341. return False
  342. # Backup before approving
  343. if self._auto_backup:
  344. self._create_backup(registration)
  345. # Approve
  346. registration.approve(approved_by, notes)
  347. # Save to file
  348. self._save_registration(registration)
  349. # Update cache
  350. self._registrations_cache[mac_address.lower()] = registration
  351. pprint(f"Registration approved: {mac_address} by {approved_by}")
  352. return True
  353. def reject_registration(
  354. self,
  355. mac_address: str,
  356. reason: str = "manual_rejection"
  357. ) -> bool:
  358. """
  359. Reject a registration.
  360. Args:
  361. mac_address: MAC address to reject
  362. reason: Reason for rejection
  363. Returns:
  364. bool: True if rejected successfully
  365. """
  366. with self._lock:
  367. registration = self.get_registration(mac_address)
  368. if not registration:
  369. return False
  370. # Backup before rejecting
  371. if self._auto_backup:
  372. self._create_backup(registration)
  373. # Reject
  374. registration.reject(reason)
  375. # Save to file
  376. self._save_registration(registration)
  377. # Update cache
  378. self._registrations_cache[mac_address.lower()] = registration
  379. pprint(f"Registration rejected: {mac_address} - {reason}")
  380. return True
  381. def revoke_registration(
  382. self,
  383. mac_address: str,
  384. reason: str = "manual_revocation"
  385. ) -> bool:
  386. """
  387. Revoke an active registration.
  388. Args:
  389. mac_address: MAC address to revoke
  390. reason: Reason for revocation
  391. Returns:
  392. bool: True if revoked successfully
  393. """
  394. with self._lock:
  395. registration = self.get_registration(mac_address)
  396. if not registration:
  397. return False
  398. # Backup before revoking
  399. if self._auto_backup:
  400. self._create_backup(registration)
  401. # Revoke
  402. registration.revoke(reason)
  403. # Save to file
  404. self._save_registration(registration)
  405. # Update cache
  406. self._registrations_cache[mac_address.lower()] = registration
  407. pprint(f"Registration revoked: {mac_address} - {reason}")
  408. return True
  409. def delete_registration(self, mac_address: str) -> bool:
  410. """
  411. Permanently delete a registration.
  412. Args:
  413. mac_address: MAC address to delete
  414. Returns:
  415. bool: True if deleted successfully
  416. """
  417. with self._lock:
  418. mac_address = mac_address.lower()
  419. registration = self.get_registration(mac_address)
  420. if registration and self._auto_backup:
  421. self._create_backup(registration)
  422. # Remove file
  423. filename = f"{mac_address.replace(':', '_')}.json"
  424. file_path = self._registration_dir / filename
  425. try:
  426. if file_path.exists():
  427. file_path.unlink()
  428. pprint(f"Registration deleted: {mac_address}")
  429. # Remove from cache
  430. if mac_address in self._registrations_cache:
  431. del self._registrations_cache[mac_address]
  432. return True
  433. except Exception as e:
  434. pprint(f"Error deleting registration {mac_address}: {e}")
  435. return False
  436. def get_all_registrations(self) -> List[SatelliteRegistration]:
  437. """Get all registrations."""
  438. with self._lock:
  439. self._refresh_cache()
  440. return list(self._registrations_cache.values())
  441. def get_registrations_by_status(self, status: RegistrationStatus) -> List[SatelliteRegistration]:
  442. """Get registrations by status."""
  443. with self._lock:
  444. registrations = self.get_all_registrations()
  445. return [reg for reg in registrations if reg.status == status]
  446. def get_active_registrations(self) -> List[SatelliteRegistration]:
  447. """Get all active (approved and not expired) registrations."""
  448. with self._lock:
  449. registrations = self.get_all_registrations()
  450. return [reg for reg in registrations if reg.is_active()]
  451. def get_expired_registrations(self) -> List[SatelliteRegistration]:
  452. """Get all expired registrations."""
  453. with self._lock:
  454. registrations = self.get_all_registrations()
  455. return [reg for reg in registrations if reg.is_expired()]
  456. def cleanup_expired_registrations(self) -> int:
  457. """
  458. Clean up expired registrations.
  459. Returns:
  460. int: Number of registrations cleaned up
  461. """
  462. with self._lock:
  463. expired = self.get_expired_registrations()
  464. cleaned = 0
  465. for registration in expired:
  466. if registration.status == RegistrationStatus.APPROVED:
  467. registration.status = RegistrationStatus.EXPIRED
  468. registration.update_timestamp()
  469. self._save_registration(registration)
  470. cleaned += 1
  471. if cleaned > 0:
  472. pprint(f"Cleaned up {cleaned} expired registrations")
  473. return cleaned
  474. def _validate_satellite_info(self, satellite_info: SatelliteInfo) -> None:
  475. """Validate satellite information."""
  476. if not satellite_info.satellite_id:
  477. raise RegistrationValidationError("Satellite ID is required")
  478. if not satellite_info.mac_address:
  479. raise RegistrationValidationError("MAC address is required")
  480. if not satellite_info.room_id:
  481. raise RegistrationValidationError("Room ID is required")
  482. if not satellite_info.alias_name:
  483. raise RegistrationValidationError("Alias name is required")
  484. # Validate MAC address format
  485. from . import validate_mac_address
  486. if not validate_mac_address(satellite_info.mac_address):
  487. raise RegistrationValidationError(f"Invalid MAC address format: {satellite_info.mac_address}")
  488. def _load_registration(self, mac_address: str) -> Optional[SatelliteRegistration]:
  489. """Load registration from file."""
  490. filename = f"{mac_address.replace(':', '_')}.json"
  491. file_path = self._registration_dir / filename
  492. if not file_path.exists():
  493. return None
  494. try:
  495. with open(file_path, 'r') as f:
  496. data = json.load(f)
  497. return SatelliteRegistration.from_dict(data)
  498. except Exception as e:
  499. pprint(f"Error loading registration {mac_address}: {e}")
  500. return None
  501. def _save_registration(self, registration: SatelliteRegistration) -> None:
  502. """Save registration to file."""
  503. mac_address = registration.satellite_info.mac_address.lower()
  504. filename = f"{mac_address.replace(':', '_')}.json"
  505. file_path = self._registration_dir / filename
  506. try:
  507. with open(file_path, 'w') as f:
  508. json.dump(registration.to_dict(), f, indent=2)
  509. except Exception as e:
  510. raise RegistrationFileError(f"Error saving registration {mac_address}: {e}")
  511. def _refresh_cache(self) -> None:
  512. """Refresh the registrations cache."""
  513. if not self._cache_dirty:
  514. return
  515. self._registrations_cache.clear()
  516. for file_path in self._registration_dir.glob("*.json"):
  517. try:
  518. with open(file_path, 'r') as f:
  519. data = json.load(f)
  520. registration = SatelliteRegistration.from_dict(data)
  521. mac_address = registration.satellite_info.mac_address.lower()
  522. self._registrations_cache[mac_address] = registration
  523. except Exception as e:
  524. pprint(f"Error loading registration from {file_path}: {e}")
  525. self._cache_dirty = False
  526. def _create_backup(self, registration: SatelliteRegistration) -> None:
  527. """Create a backup of a registration."""
  528. if not self._auto_backup:
  529. return
  530. mac_address = registration.satellite_info.mac_address.lower()
  531. timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
  532. filename = f"{mac_address.replace(':', '_')}_{timestamp}.json"
  533. backup_path = self._backup_dir / filename
  534. try:
  535. with open(backup_path, 'w') as f:
  536. json.dump(registration.to_dict(), f, indent=2)
  537. # Clean up old backups
  538. self._cleanup_old_backups(mac_address)
  539. except Exception as e:
  540. pprint(f"Error creating backup for {mac_address}: {e}")
  541. def _cleanup_old_backups(self, mac_address: str) -> None:
  542. """Clean up old backup files."""
  543. pattern = f"{mac_address.replace(':', '_')}_*.json"
  544. backup_files = list(self._backup_dir.glob(pattern))
  545. if len(backup_files) > self._max_backups:
  546. # Sort by modification time and remove oldest
  547. backup_files.sort(key=lambda f: f.stat().st_mtime)
  548. for old_backup in backup_files[:-self._max_backups]:
  549. try:
  550. old_backup.unlink()
  551. except Exception as e:
  552. pprint(f"Error removing old backup {old_backup}: {e}")
  553. class BlacklistManager:
  554. """
  555. Manages MAC address blacklist with comprehensive tracking.
  556. This class handles blacklisting of MAC addresses with detailed
  557. tracking of reasons, expiration, and metadata.
  558. """
  559. def __init__(
  560. self,
  561. blacklist_file: Optional[str] = None,
  562. backup_file: Optional[str] = None,
  563. auto_backup: bool = True
  564. ):
  565. """
  566. Initialize the blacklist manager.
  567. Args:
  568. blacklist_file: Path to blacklist file
  569. backup_file: Path to backup file
  570. auto_backup: Enable automatic backups
  571. """
  572. self._blacklist_file = Path(blacklist_file or "config/satellites/blacklist.json")
  573. self._backup_file = Path(backup_file or "config/satellites/blacklist_backup.json")
  574. self._auto_backup = auto_backup
  575. # Ensure directory exists
  576. self._blacklist_file.parent.mkdir(parents=True, exist_ok=True)
  577. # Thread safety
  578. self._lock = threading.RLock()
  579. # Storage
  580. self._blacklist: Dict[str, BlacklistEntry] = {}
  581. # Load existing blacklist
  582. self._load_blacklist()
  583. pprint(f"BlacklistManager initialized (file: {self._blacklist_file})")
  584. def add_to_blacklist(
  585. self,
  586. mac_address: str,
  587. reason: str = "manual_blacklist",
  588. blacklisted_by: Optional[str] = None,
  589. notes: str = "",
  590. expires_in_days: Optional[int] = None
  591. ) -> bool:
  592. """
  593. Add a MAC address to the blacklist.
  594. Args:
  595. mac_address: MAC address to blacklist
  596. reason: Reason for blacklisting
  597. blacklisted_by: User who added to blacklist
  598. notes: Additional notes
  599. expires_in_days: Days until blacklist expires
  600. Returns:
  601. bool: True if added successfully
  602. """
  603. with self._lock:
  604. mac_address = mac_address.lower()
  605. # Backup before modifying
  606. if self._auto_backup:
  607. self._create_backup()
  608. # Calculate expiration
  609. expires_at = None
  610. if expires_in_days:
  611. from datetime import timedelta
  612. expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
  613. # Create blacklist entry
  614. entry = BlacklistEntry(
  615. mac_address=mac_address,
  616. reason=reason,
  617. blacklisted_by=blacklisted_by,
  618. expires_at=expires_at,
  619. notes=notes
  620. )
  621. self._blacklist[mac_address] = entry
  622. # Save to file
  623. self._save_blacklist()
  624. pprint(f"MAC address blacklisted: {mac_address} - {reason}")
  625. return True
  626. def remove_from_blacklist(self, mac_address: str) -> bool:
  627. """
  628. Remove a MAC address from the blacklist.
  629. Args:
  630. mac_address: MAC address to remove
  631. Returns:
  632. bool: True if removed successfully
  633. """
  634. with self._lock:
  635. mac_address = mac_address.lower()
  636. if mac_address not in self._blacklist:
  637. return True
  638. # Backup before modifying
  639. if self._auto_backup:
  640. self._create_backup()
  641. del self._blacklist[mac_address]
  642. # Save to file
  643. self._save_blacklist()
  644. pprint(f"MAC address removed from blacklist: {mac_address}")
  645. return True
  646. def is_blacklisted(self, mac_address: str) -> bool:
  647. """Check if a MAC address is blacklisted."""
  648. with self._lock:
  649. mac_address = mac_address.lower()
  650. entry = self._blacklist.get(mac_address)
  651. if not entry:
  652. return False
  653. # Check if expired
  654. if entry.is_expired():
  655. # Automatically remove expired entries
  656. self.remove_from_blacklist(mac_address)
  657. return False
  658. return True
  659. def get_blacklist_entry(self, mac_address: str) -> Optional[BlacklistEntry]:
  660. """Get blacklist entry for a MAC address."""
  661. with self._lock:
  662. return self._blacklist.get(mac_address.lower())
  663. def get_all_blacklist_entries(self) -> List[BlacklistEntry]:
  664. """Get all blacklist entries."""
  665. with self._lock:
  666. return list(self._blacklist.values())
  667. def get_active_blacklist_entries(self) -> List[BlacklistEntry]:
  668. """Get all active (non-expired) blacklist entries."""
  669. with self._lock:
  670. return [entry for entry in self._blacklist.values() if entry.is_active()]
  671. def get_expired_blacklist_entries(self) -> List[BlacklistEntry]:
  672. """Get all expired blacklist entries."""
  673. with self._lock:
  674. return [entry for entry in self._blacklist.values() if entry.is_expired()]
  675. def cleanup_expired_entries(self) -> int:
  676. """
  677. Clean up expired blacklist entries.
  678. Returns:
  679. int: Number of entries cleaned up
  680. """
  681. with self._lock:
  682. expired = self.get_expired_blacklist_entries()
  683. for entry in expired:
  684. self.remove_from_blacklist(entry.mac_address)
  685. if expired:
  686. pprint(f"Cleaned up {len(expired)} expired blacklist entries")
  687. return len(expired)
  688. def get_blacklist_stats(self) -> Dict[str, Any]:
  689. """Get blacklist statistics."""
  690. with self._lock:
  691. entries = list(self._blacklist.values())
  692. active_entries = [e for e in entries if e.is_active()]
  693. expired_entries = [e for e in entries if e.is_expired()]
  694. # Count by reason
  695. reason_stats = {}
  696. for entry in entries:
  697. reason_stats[entry.reason] = reason_stats.get(entry.reason, 0) + 1
  698. return {
  699. "total_entries": len(entries),
  700. "active_entries": len(active_entries),
  701. "expired_entries": len(expired_entries),
  702. "reason_breakdown": reason_stats,
  703. "file_path": str(self._blacklist_file),
  704. "last_updated": datetime.now(timezone.utc).isoformat(),
  705. }
  706. def _load_blacklist(self) -> None:
  707. """Load blacklist from file."""
  708. if not self._blacklist_file.exists():
  709. return
  710. try:
  711. with open(self._blacklist_file, 'r') as f:
  712. data = json.load(f)
  713. # Handle both old format (list) and new format (dict)
  714. if isinstance(data, list):
  715. # Old format: list of MAC addresses
  716. for mac_address in data:
  717. entry = BlacklistEntry(
  718. mac_address=mac_address.lower(),
  719. reason="legacy_blacklist"
  720. )
  721. self._blacklist[mac_address.lower()] = entry
  722. elif isinstance(data, dict):
  723. if "entries" in data:
  724. # New format: entries list
  725. for entry_data in data["entries"]:
  726. entry = BlacklistEntry.from_dict(entry_data)
  727. self._blacklist[entry.mac_address.lower()] = entry
  728. elif "blacklisted_macs" in data:
  729. # Intermediate format: simple list
  730. for mac_address in data["blacklisted_macs"]:
  731. entry = BlacklistEntry(
  732. mac_address=mac_address.lower(),
  733. reason="legacy_blacklist"
  734. )
  735. self._blacklist[mac_address.lower()] = entry
  736. if self._blacklist:
  737. pprint(f"Loaded {len(self._blacklist)} blacklist entries")
  738. except Exception as e:
  739. pprint(f"Error loading blacklist: {e}")
  740. def _save_blacklist(self) -> None:
  741. """Save blacklist to file."""
  742. try:
  743. data = {
  744. "version": "1.0",
  745. "updated_at": datetime.now(timezone.utc).isoformat(),
  746. "entries": [entry.to_dict() for entry in self._blacklist.values()]
  747. }
  748. with open(self._blacklist_file, 'w') as f:
  749. json.dump(data, f, indent=2)
  750. except Exception as e:
  751. raise BlacklistError(f"Error saving blacklist: {e}")
  752. def _create_backup(self) -> None:
  753. """Create a backup of the blacklist."""
  754. if not self._auto_backup or not self._blacklist_file.exists():
  755. return
  756. try:
  757. shutil.copy2(self._blacklist_file, self._backup_file)
  758. except Exception as e:
  759. pprint(f"Error creating blacklist backup: {e}")
  760. # Factory functions
  761. def create_registration_manager(**kwargs) -> RegistrationManager:
  762. """Create a RegistrationManager instance."""
  763. return RegistrationManager(**kwargs)
  764. def create_blacklist_manager(**kwargs) -> BlacklistManager:
  765. """Create a BlacklistManager instance."""
  766. return BlacklistManager(**kwargs)
  767. # Module exports
  768. __all__ = [
  769. "SatelliteRegistration",
  770. "RegistrationManager",
  771. "RegistrationError",
  772. "RegistrationValidationError",
  773. "RegistrationFileError",
  774. "RegistrationStatus",
  775. "BlacklistEntry",
  776. "BlacklistManager",
  777. "BlacklistError",
  778. "create_registration_manager",
  779. "create_blacklist_manager",
  780. ]