# ============================================================================= # license_server/main.py — AR Electronics License Server # ============================================================================= # # FastAPI REST service that manages serial number activations for all # AR Electronics products deployed on J6412 mini PCs. # # Endpoints (public): # POST /api/v1/activate — activate a serial on a machine # GET /api/v1/validate/{serial} — check activation status # # Endpoints (admin — require X-Admin-Key header): # GET /api/v1/admin/licenses — list all issued licenses # GET /api/v1/admin/activations — list all activations # POST /api/v1/admin/issue — issue a new serial number # DELETE /api/v1/admin/revoke/{serial} — revoke a license # # Run: # uvicorn license_server.main:app --host 0.0.0.0 --port 8888 --reload # # Environment variables: # DATABASE_URL — SQLAlchemy URL (default: sqlite:///./ar_licenses.db) # ADMIN_API_KEY — Secret key for /admin/* endpoints # ============================================================================= from __future__ import annotations import os import uuid from datetime import datetime, timezone from fastapi import Depends, FastAPI, Header, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from sqlalchemy.orm import Session from .database import Base, engine, get_db from .models import Activation, License from .schemas import ( ActivationRequest, ActivationAdminRecord, ActivationResponse, LicenseAdminRecord, ValidateResponse, ) # ── Create tables on startup ───────────────────────────────────────────────── Base.metadata.create_all(bind=engine) # ── App ────────────────────────────────────────────────────────────────────── app = FastAPI( title="AR Electronics License Server", description="Serial-number activation and validation for J6412 deployments.", version="1.0.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST", "DELETE"], allow_headers=["*"], ) ADMIN_KEY = os.getenv("ADMIN_API_KEY", "change-me-in-production") # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- def require_admin(x_admin_key: str = Header(...)): if x_admin_key != ADMIN_KEY: raise HTTPException(status_code=403, detail="Invalid admin key.") # --------------------------------------------------------------------------- # Admin request schemas (defined here — too small for schemas.py) # --------------------------------------------------------------------------- class IssueRequest(BaseModel): serial: str vessel: str = "" notes: str = "" # --------------------------------------------------------------------------- # Public — Activation # --------------------------------------------------------------------------- @app.post("/api/v1/activate", response_model=ActivationResponse, tags=["Public"]) def activate(request: ActivationRequest, db: Session = Depends(get_db)): """ Activate a serial number on a specific hardware machine. - First activation: accepted, creates a new Activation row. - Same hardware re-activating the same serial: accepted, updates last_seen. - Different hardware trying to activate an already-activated serial: rejected. """ serial = request.serial.upper() # Verify the serial exists and is not revoked lic = db.query(License).filter(License.serial == serial).first() if lic is None: raise HTTPException(status_code=404, detail="Serial number not found.") if not lic.is_active: raise HTTPException(status_code=403, detail="This license has been revoked.") hw_id = request.hardware_id # Check for an existing activation existing = ( db.query(Activation) .filter(Activation.serial == serial, Activation.revoked == False) # noqa: E712 .first() ) if existing is not None: if existing.hardware_id == hw_id: # Same machine re-activating — refresh heartbeat existing.last_seen_at = datetime.now(timezone.utc) existing.app_version = request.app_version or existing.app_version db.commit() db.refresh(existing) return ActivationResponse( activation_id = existing.activation_id, vessel_slot = existing.vessel_slot, licensed_to = existing.licensed_to, activated_at = existing.activated_at, ) else: raise HTTPException( status_code=409, detail=( "Este número de serie ya está activado en otro equipo. " "Contacte a AR Electronics para transferir la licencia." ), ) # New activation activation = Activation( activation_id = str(uuid.uuid4()), serial = serial, hardware_id = hw_id, app_version = request.app_version, platform = request.platform, hostname = request.hostname, vessel_slot = 1, licensed_to = lic.vessel, ) db.add(activation) db.commit() db.refresh(activation) return ActivationResponse( activation_id = activation.activation_id, vessel_slot = activation.vessel_slot, licensed_to = activation.licensed_to, activated_at = activation.activated_at, ) # --------------------------------------------------------------------------- # Public — Validation # --------------------------------------------------------------------------- @app.get("/api/v1/validate/{serial}", response_model=ValidateResponse, tags=["Public"]) def validate( serial: str, hardware_id: str = Query(..., min_length=16), db: Session = Depends(get_db), ): """ Check whether a (serial, hardware_id) pair is currently active. Called by the installed app on each boot to refresh its offline cache. """ serial = serial.upper() activation = ( db.query(Activation) .filter( Activation.serial == serial, Activation.hardware_id == hardware_id, Activation.revoked == False, # noqa: E712 ) .first() ) if activation is None: raise HTTPException(status_code=404, detail="No active activation found.") activation.last_seen_at = datetime.now(timezone.utc) db.commit() return ValidateResponse( serial = activation.serial, active = True, hardware_id = activation.hardware_id, activation_id = activation.activation_id, vessel_slot = activation.vessel_slot, licensed_to = activation.licensed_to, activated_at = activation.activated_at, last_seen_at = activation.last_seen_at, ) # --------------------------------------------------------------------------- # Admin — Issue new serial # --------------------------------------------------------------------------- @app.post("/api/v1/admin/issue", tags=["Admin"], dependencies=[Depends(require_admin)]) def issue_license(request: IssueRequest, db: Session = Depends(get_db)): """Register a pre-generated serial number in the database.""" serial = request.serial.upper() if db.query(License).filter(License.serial == serial).first(): raise HTTPException(status_code=409, detail="Serial already in database.") lic = License(serial=serial, vessel=request.vessel, notes=request.notes, is_active=True) db.add(lic) db.commit() return {"status": "issued", "serial": lic.serial} # --------------------------------------------------------------------------- # Admin — List licenses # --------------------------------------------------------------------------- @app.get("/api/v1/admin/licenses", response_model=list[LicenseAdminRecord], tags=["Admin"], dependencies=[Depends(require_admin)]) def list_licenses(db: Session = Depends(get_db)): licenses = db.query(License).order_by(License.issued_at.desc()).all() result = [] for lic in licenses: count = db.query(Activation).filter( Activation.serial == lic.serial, Activation.revoked == False # noqa: E712 ).count() result.append( LicenseAdminRecord( serial = lic.serial, vessel = lic.vessel, issued_at = lic.issued_at, is_active = lic.is_active, activations = count, ) ) return result # --------------------------------------------------------------------------- # Admin — List activations # --------------------------------------------------------------------------- @app.get("/api/v1/admin/activations", response_model=list[ActivationAdminRecord], tags=["Admin"], dependencies=[Depends(require_admin)]) def list_activations(db: Session = Depends(get_db)): rows = db.query(Activation).order_by(Activation.activated_at.desc()).all() return [ ActivationAdminRecord( activation_id = r.activation_id, serial = r.serial, hardware_id = r.hardware_id, app_version = r.app_version, platform = r.platform, hostname = r.hostname, activated_at = r.activated_at, last_seen_at = r.last_seen_at, vessel_slot = r.vessel_slot, revoked = r.revoked, licensed_to = r.licensed_to, ) for r in rows ] # --------------------------------------------------------------------------- # Admin — Revoke # --------------------------------------------------------------------------- @app.delete("/api/v1/admin/revoke/{serial}", tags=["Admin"], dependencies=[Depends(require_admin)]) def revoke_license(serial: str, db: Session = Depends(get_db)): """Revoke a license — all activations are invalidated immediately.""" serial = serial.upper() lic = db.query(License).filter(License.serial == serial).first() if lic is None: raise HTTPException(status_code=404, detail="Serial not found.") lic.is_active = False db.query(Activation).filter(Activation.serial == serial).update({"revoked": True}) db.commit() return {"status": "revoked", "serial": serial} # --------------------------------------------------------------------------- # Health check # --------------------------------------------------------------------------- @app.get("/health", tags=["System"]) def health(): return {"status": "ok", "service": "AR Electronics License Server"}