Files
gatehouse-api/gatehouse_app/services/zerotier_reconciliation_service.py
T
nexgen_mirrors 1789590167 feat(zerotier): add ZeroTier network governance module
Add comprehensive ZeroTier integration for managing network access:

- Portal networks: manager-created ZeroTier network bindings
- Device registration: user-owned ZeroTier node endpoints
- Approval workflows: request/approve/revoke network access
- Activation sessions: time-limited network authorization
- Kill switch: emergency access revocation
- Reconciliation job: sync portal state with ZeroTier controller

Includes ZeroTier client SDK supporting both Central and self-hosted
controller APIs, with full CRUD operations for networks and members.
2026-03-20 21:50:20 +10:30

304 lines
11 KiB
Python

"""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,
)