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
+10
View File
@@ -0,0 +1,10 @@
# AR Electronics License Server — environment variables
# Copy this file to .env and fill in values before starting the server.
# Database — SQLite (default) or PostgreSQL
DATABASE_URL=sqlite:///./ar_licenses.db
# DATABASE_URL=postgresql://user:password@localhost:5432/ar_licenses
# Admin API key — change this to a strong random value in production!
# Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))"
ADMIN_API_KEY=change-me-in-production
+1
View File
@@ -0,0 +1 @@
# AR Electronics License Server package
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# license_server/database.py — SQLAlchemy engine + session factory
# =============================================================================
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# Default: SQLite in same directory. Set DATABASE_URL env var for PostgreSQL.
DATABASE_URL = os.getenv(
"DATABASE_URL",
"sqlite:///./ar_licenses.db",
)
# connect_args only needed for SQLite (allows multi-threaded use by FastAPI)
_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=_connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
+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"}
+51
View File
@@ -0,0 +1,51 @@
# =============================================================================
# license_server/models.py — SQLAlchemy ORM models
# =============================================================================
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
def _uuid() -> str:
return str(uuid.uuid4())
class License(Base):
"""One row per serial number issued by AR Electronics."""
__tablename__ = "licenses"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
serial: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
vessel: Mapped[str] = mapped_column(String(120), default="")
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
notes: Mapped[str] = mapped_column(String(500), default="")
class Activation(Base):
"""One row per successful hardware activation of a serial number."""
__tablename__ = "activations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
activation_id: Mapped[str] = mapped_column(String(36), unique=True, default=_uuid, index=True)
serial: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
hardware_id: Mapped[str] = mapped_column(String(64), nullable=False)
app_version: Mapped[str] = mapped_column(String(20), default="")
platform: Mapped[str] = mapped_column(String(20), default="")
hostname: Mapped[str] = mapped_column(String(120), default="")
activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
vessel_slot: Mapped[int] = mapped_column(Integer, default=1)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
licensed_to: Mapped[str] = mapped_column(String(120), default="")
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
pydantic>=2.7.0
python-dotenv>=1.0.0
+69
View File
@@ -0,0 +1,69 @@
# =============================================================================
# license_server/schemas.py — Pydantic v2 request/response schemas
# =============================================================================
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Activation
# ---------------------------------------------------------------------------
class ActivationRequest(BaseModel):
serial: str = Field(..., pattern=r"^AR-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$")
hardware_id: str = Field(..., min_length=16, max_length=64)
app_version: str = Field(default="", max_length=20)
platform: str = Field(default="", max_length=20)
hostname: str = Field(default="", max_length=120)
class ActivationResponse(BaseModel):
activation_id: str
vessel_slot: int
licensed_to: str
activated_at: datetime
expires_at: Optional[datetime] = None
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class ValidateResponse(BaseModel):
serial: str
active: bool
hardware_id: str
activation_id: str
vessel_slot: int
licensed_to: str
activated_at: datetime
last_seen_at: datetime
# ---------------------------------------------------------------------------
# Admin list
# ---------------------------------------------------------------------------
class LicenseAdminRecord(BaseModel):
serial: str
vessel: str
issued_at: datetime
is_active: bool
activations: int
class ActivationAdminRecord(BaseModel):
activation_id: str
serial: str
hardware_id: str
app_version: str
platform: str
hostname: str
activated_at: datetime
last_seen_at: datetime
vessel_slot: int
revoked: bool
licensed_to: str