feat(installer): J6412 USB installer + AR Electronics license activation server

installer/:
  - build_usb.py: dev tool — builds Flutter + AR-ECDIS, assembles USB pendrive
    image with serial.key, autorun.inf, and START_INSTALLER.bat
  - serial_generator.py: generates AR-XXXX-XXXX-XXXX serial numbers (48-bit
    entropy), logs to CSV for CRM integration
  - src/license.py: hardware fingerprint (Windows MachineGuid + primary MAC),
    serial validation, online activation POST, local cache with 30-day offline
    grace period
  - src/sysconfig.py: HKCU autostart registry entries, .lnk shortcuts (desktop
    + Start Menu via WScript.Shell), firewall rules (netsh), COM port detection
  - src/install.py: tkinter installer GUI — 9 sequential steps with per-step
    progress indicators, threaded execution, error dialogs, and silent mode

license_server/ (FastAPI service — deploy to arelectronics.com VPS):
  - POST /api/v1/activate: first activation accepted; same-HW re-activation
    refreshes heartbeat; different-HW rejected with 409
  - GET  /api/v1/validate/{serial}: heartbeat endpoint to refresh offline cache
  - Admin endpoints (X-Admin-Key): issue, list, revoke licenses
  - SQLAlchemy models: License (serial registry) + Activation (per-machine rows)
  - SQLite default, PostgreSQL-ready via DATABASE_URL env var

AR_electronics — AR-Autopilot Project
This commit is contained in:
2026-05-24 01:36:24 -04:00
parent abe9b764c7
commit de25dcee57
12 changed files with 1712 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
# =============================================================================
# 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"}