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:
@@ -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"}
|
||||
Reference in New Issue
Block a user