<?php
// src/Controller/ApiController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Psr\Log\LoggerInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* API Controller for DS-C4RGO device registration and activation.
*
* Endpoints:
* POST /api/v1/reg - Device activation (new Rust client: client_code/email/license)
* POST /api/v1/reg/reset - Remove machine key (reset for re-registration)
* GET /api/v1/ping - Connectivity check
* POST /api/v1/reg/legacy - Legacy C++ activation (hkey only) - deprecated
*
* @Route("/api/v1", name="api_")
*/
class ApiController extends AbstractController
{
/** Max registration attempts per IP within the rate limit window. */
private const RATE_LIMIT_MAX_ATTEMPTS = 10;
/** Rate limit window in seconds. */
private const RATE_LIMIT_WINDOW_SECS = 300;
private LoggerInterface $logger;
private EntityManagerInterface $em;
public function __construct(LoggerInterface $logger, EntityManagerInterface $em)
{
$this->logger = $logger;
$this->em = $em;
}
// ─── Ping ────────────────────────────────────────────────────────
/**
* Connectivity check used by c4rgo-reg-ui before starting registration.
*
* @Route("/ping", name="ping", methods={"GET"})
*/
public function ping(Request $request): JsonResponse
{
$serviceUp = $this->checkServiceStatus();
$this->logger->info('Ping', [
'ip' => $request->getClientIp(),
'status' => $serviceUp ? 'ok' : 'degraded',
]);
return new JsonResponse([
'status' => $serviceUp ? 'ok' : 'degraded',
'message' => 'DS-C4RGO API is running',
'timestamp' => time(),
'service_available' => $serviceUp,
'version' => '1.0.0',
]);
}
// ─── Registration (new Rust client) ──────────────────────────────
/**
* Device activation endpoint.
*
* Called by c4rgo-activate init with:
* { "client_code", "email", "license", "cpu_serial", "mac_address" }
*
* Returns the passepartout key on success.
*
* @Route("/reg", name="register", methods={"POST"})
*/
public function register(Request $request, ValidatorInterface $validator): JsonResponse
{
$data = $this->parseJsonBody($request);
if ($data instanceof JsonResponse) {
return $data;
}
// --- Input validation ---
$constraints = new Assert\Collection([
'fields' => [
'client_code' => [
new Assert\NotBlank(),
new Assert\Type('string'),
new Assert\Length(['max' => 64]),
],
'email' => [
new Assert\NotBlank(),
new Assert\Email(),
new Assert\Length(['max' => 255]),
],
'license' => [
new Assert\NotBlank(),
new Assert\Type('string'),
new Assert\Length(['max' => 255]),
],
'cpu_serial' => [
new Assert\NotBlank(),
new Assert\Regex([
'pattern' => '/^[0-9a-fA-F]{8,32}$/',
'message' => 'cpu_serial must be a hex string (8-32 chars)',
]),
],
'mac_address' => [
new Assert\NotBlank(),
new Assert\Regex([
'pattern' => '/^[0-9a-fA-F]{12}$/',
'message' => 'mac_address must be 12 hex chars without separators',
]),
],
],
'allowExtraFields' => false,
]);
$validationError = $this->validateData($data, $constraints, $validator);
if ($validationError !== null) {
return $validationError;
}
// --- Rate limiting ---
// NOTE: Current implementation uses audit_log table for rate limiting.
// For better performance at scale, consider migrating to Symfony RateLimiter
// component with a Redis backend.
$rateLimitResponse = $this->checkRateLimit($request, 'reg');
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
// --- Business logic ---
try {
// 1. Find machine by client_code + license
$machine = $this->findMachine($data['client_code'], $data['license']);
if ($machine === null) {
$this->logAudit('register', $data, $request, 'INVALID_KEY');
return $this->errorResponse('INVALID_KEY',
'License not found or does not match client code', 403);
}
// 2. Verify email
if (!$this->emailMatches($machine, $data['email'])) {
$this->logAudit('register', $data, $request, 'INVALID_EMAIL');
return $this->errorResponse('INVALID_EMAIL',
'Email does not match license registration', 403);
}
// 3. Verify hardware identity (cpu_serial + mac_address)
$hwCheck = $this->verifyHardware($machine, $data['cpu_serial'], $data['mac_address']);
if ($hwCheck !== null) {
$this->logAudit('register', $data, $request, $hwCheck);
return $this->errorResponse($hwCheck,
'Device hardware does not match registered machine', 403);
}
// 4. Retrieve passepartout (skey)
$passepartout = $machine->getSkey();
if (empty($passepartout)) {
$this->logger->error('Passepartout (skey) is empty for machine', [
'machine_id' => $machine->getId(),
]);
return $this->errorResponse('SERVER_ERROR',
'Passepartout not available for this machine', 500);
}
// 5. Mark license as used and record activation
$this->recordActivation($machine, $data, $request);
// 6. Audit log
$this->logAudit('register', $data, $request, 'OK');
return new JsonResponse([
'success' => true,
'key' => $passepartout,
]);
} catch (\Exception $e) {
$this->logger->error('Registration failed unexpectedly', [
'error' => $e->getMessage(),
'client_code' => $data['client_code'] ?? '?',
]);
return $this->errorResponse('SERVER_ERROR',
'Registration failed due to internal error', 500);
}
}
// ─── Reset ───────────────────────────────────────────────────────
/**
* Reset endpoint: returns the passepartout so c4rgo-activate can remove
* the machine key (slot 2) and allow re-initialization.
*
* Called by c4rgo-activate reset with:
* { "cpu_serial", "mac_address", "operation": "reset" }
*
* @Route("/reg/reset", name="register_reset", methods={"POST"})
*/
public function reset(Request $request, ValidatorInterface $validator): JsonResponse
{
$data = $this->parseJsonBody($request);
if ($data instanceof JsonResponse) {
return $data;
}
$constraints = new Assert\Collection([
'fields' => [
'cpu_serial' => [
new Assert\NotBlank(),
new Assert\Regex(['pattern' => '/^[0-9a-fA-F]{8,32}$/']),
],
'mac_address' => [
new Assert\NotBlank(),
new Assert\Regex(['pattern' => '/^[0-9a-fA-F]{12}$/']),
],
'operation' => [
new Assert\NotBlank(),
new Assert\EqualTo(['value' => 'reset']),
],
],
'allowExtraFields' => false,
]);
$validationError = $this->validateData($data, $constraints, $validator);
if ($validationError !== null) {
return $validationError;
}
$rateLimitResponse = $this->checkRateLimit($request, 'reset');
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
try {
// Find machine by hardware parameters
$machine = $this->findMachineByHardware(
$data['cpu_serial'],
$data['mac_address']
);
if ($machine === null) {
$this->logAudit('reset', $data, $request, 'DEVICE_NOT_FOUND');
return $this->errorResponse('DEVICE_NOT_FOUND',
'No registered machine matches the provided hardware parameters', 403);
}
$passepartout = $machine->getSkey();
if (empty($passepartout)) {
return $this->errorResponse('SERVER_ERROR',
'Passepartout not available for this machine', 500);
}
$this->logAudit('reset', $data, $request, 'OK');
return new JsonResponse([
'success' => true,
'key' => $passepartout,
]);
} catch (\Exception $e) {
$this->logger->error('Reset failed unexpectedly', [
'error' => $e->getMessage(),
]);
return $this->errorResponse('SERVER_ERROR',
'Reset failed due to internal error', 500);
}
}
// ─── Legacy endpoint (C++ compatibility) ─────────────────────────
/**
* Legacy registration used by the old C++ c4rgoinit.
* Accepts { "hkey": "..." } and returns { "success": true, "key": "<passepartout>" }.
*
* @deprecated Use /reg with full credentials instead.
* @Route("/reg/legacy", name="register_legacy", methods={"POST"})
*/
public function registerLegacy(Request $request, ValidatorInterface $validator): JsonResponse
{
$data = $this->parseJsonBody($request);
if ($data instanceof JsonResponse) {
return $data;
}
$constraints = new Assert\Collection([
'fields' => [
'hkey' => [new Assert\NotBlank()],
],
'allowExtraFields' => false,
]);
$validationError = $this->validateData($data, $constraints, $validator);
if ($validationError !== null) {
return $validationError;
}
try {
$machine = $this->em->getRepository('App:Macchine')->findOneBy([
'HKey' => $data['hkey'],
]);
if (!$machine) {
return $this->errorResponse('DEVICE_NOT_FOUND',
'No machine matches the provided hardware key', 403);
}
$passepartout = $machine->getSkey();
if (empty($passepartout)) {
return $this->errorResponse('SERVER_ERROR',
'Passepartout not available', 500);
}
$this->logAudit('register_legacy', ['hkey' => '***'], $request, 'OK');
return new JsonResponse([
'success' => true,
'key' => $passepartout,
]);
} catch (\Exception $e) {
$this->logger->error('Legacy registration failed', [
'error' => $e->getMessage(),
]);
return $this->errorResponse('SERVER_ERROR',
'Registration failed due to internal error', 500);
}
}
// ─── Private helpers ─────────────────────────────────────────────
/**
* Parse and validate JSON body from the request.
*
* @return array|JsonResponse Parsed data or error response.
*/
private function parseJsonBody(Request $request)
{
$content = $request->getContent();
if (empty($content)) {
return $this->errorResponse('INVALID_JSON', 'Request body is empty', 400);
}
$data = json_decode($content, true);
if (!is_array($data)) {
return $this->errorResponse('INVALID_JSON', 'Invalid JSON data', 400);
}
return $data;
}
/**
* Validate request data against constraints.
*
* @return JsonResponse|null Error response or null if valid.
*/
private function validateData(array $data, Assert\Collection $constraints, ValidatorInterface $validator): ?JsonResponse
{
$violations = $validator->validate($data, $constraints);
if (count($violations) === 0) {
return null;
}
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getPropertyPath() . ': ' . $violation->getMessage();
}
return new JsonResponse([
'success' => false,
'error' => 'VALIDATION_ERROR',
'message' => 'Validation failed: ' . implode('; ', $errors),
], 400);
}
/**
* Build a standardized error JSON response (PRD-compliant).
*/
private function errorResponse(string $errorCode, string $message, int $httpStatus = 403): JsonResponse
{
return new JsonResponse([
'success' => false,
'error' => $errorCode,
'message' => $message,
], $httpStatus);
}
/**
* Find machine by client code and license (registration key).
*/
private function findMachine(string $clientCode, string $license): ?object
{
return $this->em->getRepository('App:Macchine')->findOneBy([
'CodCliente' => $clientCode,
'Chiave' => $license,
]);
}
/**
* Find machine by hardware parameters (for reset).
*/
private function findMachineByHardware(string $cpuSerial, string $macAddress): ?object
{
return $this->em->getRepository('App:Macchine')->findOneBy([
'CpuSerial' => strtolower($cpuSerial),
'MacAddress' => strtolower($macAddress),
]);
}
/**
* Verify that the provided email matches the machine record.
* Case-insensitive comparison.
*/
private function emailMatches(object $machine, string $email): bool
{
$registered = $machine->getEmail();
if (empty($registered)) {
return false;
}
return mb_strtolower($registered) === mb_strtolower($email);
}
/**
* Verify hardware identity of the device against the machine record.
*
* If the machine has no HW data stored yet (first activation), we accept
* any hardware and store it. On subsequent activations, we verify the match.
*
* @return string|null Error code on mismatch, null on success.
*/
private function verifyHardware(object $machine, string $cpuSerial, string $macAddress): ?string
{
$storedSerial = $machine->getCpuSerial();
$storedMac = $machine->getMacAddress();
// First activation: no HW data stored yet — accept and store
if (empty($storedSerial) && empty($storedMac)) {
$machine->setCpuSerial(strtolower($cpuSerial));
$machine->setMacAddress(strtolower($macAddress));
$this->em->flush();
$this->logger->info('Hardware parameters stored for first activation', [
'machine_id' => $machine->getId(),
'cpu_serial' => strtolower($cpuSerial),
'mac_address' => strtolower($macAddress),
]);
return null;
}
// Subsequent activation: verify match
if (strtolower($storedSerial) !== strtolower($cpuSerial)
|| strtolower($storedMac) !== strtolower($macAddress)
) {
$this->logger->warning('Hardware mismatch during activation', [
'machine_id' => $machine->getId(),
'expected_serial' => $storedSerial,
'got_serial' => strtolower($cpuSerial),
'expected_mac' => $storedMac,
'got_mac' => strtolower($macAddress),
]);
return 'DEVICE_MISMATCH';
}
return null;
}
/**
* Record a successful activation (update machine state).
*/
private function recordActivation(object $machine, array $data, Request $request): void
{
if (method_exists($machine, 'setLastActivation')) {
$machine->setLastActivation(new \DateTime());
}
if (method_exists($machine, 'setLastActivationIp')) {
$machine->setLastActivationIp($request->getClientIp());
}
$this->em->flush();
}
/**
* Simple IP-based rate limiting using audit_log table.
*
* NOTE: For better performance at scale, consider migrating to
* Symfony RateLimiter component with a Redis backend.
*
* @return JsonResponse|null Error response if rate limited, null otherwise.
*/
private function checkRateLimit(Request $request, string $operation): ?JsonResponse
{
$ip = $request->getClientIp();
$windowStart = time() - self::RATE_LIMIT_WINDOW_SECS;
try {
$conn = $this->em->getConnection();
$count = (int) $conn->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE ip_address = ? AND operation = ? AND created_at > ?',
[$ip, $operation, date('Y-m-d H:i:s', $windowStart)]
);
if ($count >= self::RATE_LIMIT_MAX_ATTEMPTS) {
$this->logger->warning('Rate limit exceeded', [
'ip' => $ip,
'operation' => $operation,
'count' => $count,
]);
return $this->errorResponse('RATE_LIMITED',
'Too many attempts, please try again later', 429);
}
} catch (\Exception $e) {
// If audit_log table doesn't exist yet, skip rate limiting
$this->logger->debug('Rate limit check skipped (table may not exist)', [
'error' => $e->getMessage(),
]);
}
return null;
}
/**
* Write an audit trail entry for registration/reset operations.
* Sensitive data (license, keys) is never stored in the audit log.
*/
private function logAudit(string $operation, array $data, Request $request, string $result): void
{
$this->logger->info('Audit: ' . $operation, [
'result' => $result,
'ip' => $request->getClientIp(),
'client_code' => $data['client_code'] ?? null,
'cpu_serial' => $data['cpu_serial'] ?? null,
'mac_address' => $data['mac_address'] ?? null,
]);
try {
$conn = $this->em->getConnection();
$conn->executeStatement(
'INSERT INTO audit_log (operation, result, ip_address, client_code, cpu_serial, mac_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())',
[
$operation,
$result,
$request->getClientIp(),
$data['client_code'] ?? null,
$data['cpu_serial'] ?? null,
$data['mac_address'] ?? null,
]
);
} catch (\Exception $e) {
// Audit failure must not break the operation
$this->logger->warning('Audit log write failed', ['error' => $e->getMessage()]);
}
}
/**
* Check if the database connection is alive.
*/
private function checkServiceStatus(): bool
{
try {
$this->em->getConnection()->executeQuery('SELECT 1');
return true;
} catch (\Exception $e) {
$this->logger->error('Service check failed', ['error' => $e->getMessage()]);
return false;
}
}
}