108 lines
3.3 KiB
Python
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,
|
||
|
|
},
|
||
|
|
)
|