letsbe-hub/app/routes/activation.py

108 lines
3.3 KiB
Python

"""Instance activation endpoint.
This is the PUBLIC endpoint that client instances call to validate their license
and activate with the Hub.
"""
import hashlib
import secrets
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.base import utc_now
from app.models.instance import Instance
from app.schemas.instance import ActivationRequest, ActivationResponse
router = APIRouter(prefix="/api/v1/instances", tags=["Activation"])
@router.post("/activate", response_model=ActivationResponse)
async def activate_instance(
request: ActivationRequest,
db: AsyncSessionDep,
) -> ActivationResponse:
"""
Activate an instance with its license key.
Called by local_bootstrap.sh before running migrations.
Returns:
- 200 + ActivationResponse on success
- 400 with error details on failure
Privacy guarantee:
- Only receives license_key and instance_id
- Never receives sensitive client data
"""
# Find instance by instance_id
result = await db.execute(
select(Instance).where(Instance.instance_id == request.instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "Instance not found", "code": "instance_not_found"},
)
# Validate license key using constant-time comparison
provided_hash = hashlib.sha256(request.license_key.encode()).hexdigest()
if not secrets.compare_digest(provided_hash, instance.license_key_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "Invalid license key", "code": "invalid_license"},
)
# Check license status
if instance.license_status == "suspended":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License suspended", "code": "suspended"},
)
if instance.license_status == "revoked":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License revoked", "code": "revoked"},
)
# Check expiry
now = utc_now()
if instance.license_expires_at and instance.license_expires_at < now:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License expired", "code": "expired"},
)
# Update activation state
if instance.activated_at is None:
instance.activated_at = now
instance.last_activation_at = now
instance.activation_count += 1
instance.status = "active"
# Generate hub_api_key if not already set
hub_api_key: str
if instance.hub_api_key_hash:
# Key was pre-generated, client should use existing key
hub_api_key = "USE_EXISTING"
else:
# Generate new hub_api_key
hub_api_key = f"hk_{secrets.token_hex(24)}"
instance.hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
await db.commit()
return ActivationResponse(
status="ok",
instance_id=instance.instance_id,
hub_api_key=hub_api_key,
config={
"telemetry_enabled": True,
"telemetry_interval_seconds": 60,
},
)