"""ZeroTier reconciliation service — polling loop to sync state with the controller.""" import logging from datetime import datetime, timezone from gatehouse_app.extensions import db from gatehouse_app.models import ( Device, DeviceNetworkMembership, ActivationSession, ZeroTierMembership, PortalNetwork, UserNetworkApproval, ) from gatehouse_app.services import zerotier_api_service as zt from gatehouse_app.utils.constants import ( ActivationEndReason, MembershipState, ApprovalState, ) logger = logging.getLogger(__name__) def reconcile_expired_activations() -> int: """Find expired activation sessions and deactivate their memberships. Returns the number of sessions expired. """ now = datetime.now(timezone.utc) expired = ActivationSession.query.filter( ActivationSession.expires_at < now, ActivationSession.ended_at.is_(None), ActivationSession.deleted_at.is_(None), ).all() count = 0 for session in expired: try: _expire_session(session) count += 1 except Exception as exc: logger.error(f"[Reconciliation] Failed to expire session {session.id}: {exc}") if count > 0: logger.info(f"[Reconciliation] Expired {count} activation sessions.") return count def reconcile_network(portal_network_id: str) -> dict: """Full reconciliation for one portal network. Returns a dict with counts of actions taken. """ network = PortalNetwork.query.get(portal_network_id) if not network or not network.is_active: return {"skipped": True, "reason": "network_inactive_or_deleted"} zerotier_network_id = network.zerotier_network_id actions = { "zt_members_checked": 0, "zt_members_added": 0, "authorized": 0, "deauthorized": 0, "join_seen_updated": 0, "unknown_members": [], } # Get current ZT members try: zt_members = {m.node_id: m for m in zt.list_members(zerotier_network_id)} except Exception as exc: logger.error(f"[Reconciliation] Failed to list ZT members for {zerotier_network_id}: {exc}") actions["error"] = str(exc) return actions actions["zt_members_checked"] = len(zt_members) # Get our portal memberships for this network our_memberships = { m.device.node_id: m for m in DeviceNetworkMembership.query.filter( DeviceNetworkMembership.portal_network_id == portal_network_id, DeviceNetworkMembership.deleted_at.is_(None), ).all() if m.device and m.device.deleted_at is None } # Reconcile each portal membership for node_id, membership in our_memberships.items(): zt_member = zt_members.pop(node_id, None) device = membership.device if not zt_member: # Member not seen in ZT yet continue actions["join_seen_updated"] += 1 # Update observed ZT membership _sync_zt_membership(membership, zt_member) # Sync authorization state if membership.state == MembershipState.ACTIVE_AUTHORIZED: if not zt_member.is_authorized: # We think it's active but ZT says it's not — re-authorize try: zt.authorize_member(zerotier_network_id, node_id) actions["authorized"] += 1 except Exception as exc: logger.warning(f"[Reconciliation] Re-authorize failed for {node_id}: {exc}") else: if zt_member.is_authorized: # We think it's not authorized but ZT says it is — deauthorize # (could be manual override in ZT console) try: zt.deauthorize_member(zerotier_network_id, node_id) actions["deauthorized"] += 1 except Exception as exc: logger.warning(f"[Reconciliation] Deauthorize failed for {node_id}: {exc}") # Unknown ZT members not in our portal actions["unknown_members"] = list(zt_members.keys()) logger.info( f"[Reconciliation] Network {zerotier_network_id}: " f"checked={actions['zt_members_checked']} " f"authorized={actions['authorized']} " f"deauthorized={actions['deauthorized']} " f"unknown={len(actions['unknown_members'])}" ) return actions def reconcile_all() -> dict: """Run reconciliation on all active portal networks. Returns a summary dict. """ networks = PortalNetwork.query.filter( PortalNetwork.is_active.is_(True), PortalNetwork.deleted_at.is_(None), ).all() results = {"networks_processed": 0, "errors": 0} for network in networks: try: result = reconcile_network(network.id) if "error" in result: results["errors"] += 1 else: results["networks_processed"] += 1 except Exception as exc: logger.error(f"[Reconciliation] Failed to reconcile network {network.id}: {exc}") results["errors"] += 1 deleted_result = reconcile_deleted_memberships() results["deleted_memberships"] = deleted_result.get("deleted", 0) results["delete_errors"] = deleted_result.get("errors", 0) logger.info( f"[Reconciliation] Complete: {results['networks_processed']} networks processed, " f"{results['errors']} errors, {results.get('deleted_memberships', 0)} memberships purged." ) return results def reconcile_deleted_memberships() -> dict: """Find soft-deleted memberships and hard-delete them after ZeroTier cleanup. Only processes memberships whose ZeroTier members are already de-authorized (the de-authorize step happened in revoke_membership_soft). This function removes the member from ZeroTier entirely and then hard-deletes the DB record. """ deleted = DeviceNetworkMembership.query.filter( DeviceNetworkMembership.deleted_at.isnot(None), ).all() if not deleted: return {"deleted": 0, "errors": 0} results = {"deleted": 0, "errors": 0} for membership in deleted: try: device = Device.query.get(membership.device_id) network = PortalNetwork.query.get(membership.portal_network_id) if not device or not network: db.session.delete(membership) db.session.commit() results["deleted"] += 1 continue try: zt.delete_network_member(network.zerotier_network_id, device.node_id) logger.info(f"[Reconciliation] Deleted {device.node_id} from ZT network {network.zerotier_network_id}") except Exception as zt_exc: logger.warning( f"[Reconciliation] ZT delete failed for {device.node_id} " f"on {network.zerotier_network_id}: {zt_exc}" ) db.session.delete(membership) db.session.commit() results["deleted"] += 1 except Exception as exc: logger.error(f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}") results["errors"] += 1 if results["deleted"] > 0: logger.info(f"[Reconciliation] Purged {results['deleted']} memberships.") return results def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None: """Update the ZeroTierMembership cache record from a ZT API response.""" device = membership.device network = membership.portal_network zt_membership = ZeroTierMembership.query.filter( ZeroTierMembership.zerotier_network_id == network.zerotier_network_id, ZeroTierMembership.node_id == device.node_id, ZeroTierMembership.deleted_at.is_(None), ).first() if not zt_membership: zt_membership = ZeroTierMembership( organization_id=membership.organization_id, device_network_membership_id=membership.id, zerotier_network_id=network.zerotier_network_id, node_id=device.node_id, ) zt_membership.member_seen = True zt_membership.authorized = zt_member.is_authorized zt_membership.last_synced_at = datetime.now(timezone.utc) zt_membership.raw_controller_payload = zt_member.raw if zt_member.last_seen and zt_member.last_seen > 0: zt_membership.join_seen_at = datetime.fromtimestamp( zt_member.last_seen / 1000, tz=timezone.utc ) zt_membership.save() # Update membership join_seen flag if not membership.join_seen: membership.join_seen = True membership.state = MembershipState.JOINED_DEAUTHORIZED membership.save() def _expire_session(session: ActivationSession) -> None: """Expire an activation session and deauthorize the membership in ZT.""" session.ended_at = datetime.now(timezone.utc) session.end_reason = ActivationEndReason.EXPIRED session.save() membership = DeviceNetworkMembership.query.get(session.device_network_membership_id) if membership: membership.state = MembershipState.ACTIVATION_EXPIRED membership.currently_authorized = False membership.save() device = Device.query.get(membership.device_id) network = PortalNetwork.query.get(membership.portal_network_id) if device and network: try: zt.deauthorize_member(network.zerotier_network_id, device.node_id) # Update ZT membership cache zt_membership = ZeroTierMembership.query.filter( ZeroTierMembership.zerotier_network_id == network.zerotier_network_id, ZeroTierMembership.node_id == device.node_id, ZeroTierMembership.deleted_at.is_(None), ).first() if zt_membership: zt_membership.authorized = False zt_membership.last_synced_at = datetime.now(timezone.utc) zt_membership.save() except Exception as exc: logger.warning( f"[_expire_session] Failed to deauthorize {device.node_id} " f"on {network.zerotier_network_id}: {exc}" ) from gatehouse_app.services.audit_service import AuditService AuditService.log_action( action="zt.activation.expired", user_id=session.user_id, organization_id=session.organization_id, resource_type="activation_session", resource_id=session.id, metadata={"membership_id": session.device_network_membership_id}, description="Activation session expired", success=True, )