*/ public function liveness(): array { return [ 'status' => 'ok', 'app' => (string) $this->config->get('app.name', 'Gateway local NVR'), 'env' => (string) $this->config->get('app.env', 'production'), 'generated_at' => gmdate('c'), ]; } /** * @return array */ public function readiness(bool $strict = false): array { $this->directories->ensure(); $this->metadata->boot(); $report = $this->report($strict); $state = $this->stateStore->load(); $cameras = $this->cameraRows(); $storage = $this->storage->collect(); return [ 'status' => ($report['operational_ready'] ?? false) ? 'ok' : 'degraded', 'app' => (string) $this->config->get('app.name', 'Gateway local NVR'), 'env' => (string) $this->config->get('app.env', 'production'), 'gateway_id' => (string) $this->config->get('app.gateway_id', ''), 'registered' => (bool) ($state['registered'] ?? false), 'platform_base_url' => (string) $this->config->get('platform.base_url', ''), 'metadata_driver' => (string) $this->config->get('database.driver', 'sqlite'), 'storage' => [ 'total_bytes' => (int) ($storage['total_bytes'] ?? 0), 'free_bytes' => (int) ($storage['free_bytes'] ?? 0), ], 'cameras' => [ 'total' => count($cameras), 'online' => count(array_filter($cameras, static fn (array $row): bool => strtolower((string) ($row['connectivity_status'] ?? 'offline')) === 'online')), 'offline' => count(array_filter($cameras, static fn (array $row): bool => strtolower((string) ($row['connectivity_status'] ?? 'offline')) !== 'online')), ], 'checks' => $report['checks'] ?? [], 'blockers' => $report['blockers'] ?? [], 'warnings' => $report['warnings'] ?? [], 'generated_at' => gmdate('c'), ]; } /** * @return array{ok:bool,operational_ready:bool,checks:list,blockers:list,warnings:list,app_env:string} */ public function report(bool $strict = false): array { $appEnv = (string) $this->config->get('app.env', 'production'); $productionSeverity = $this->severityForProductionSensitiveCheck($strict, $appEnv); $state = $this->stateStore->load(); $mediaSignature = MediaSignatureConfiguration::fromEnvironment(); $checks = []; foreach ($this->directories->all() as $directory) { $checks[] = $this->check( 'runtime_dir_' . md5($directory), is_dir($directory) && is_writable($directory), sprintf('Diretório runtime precisa existir e ser gravável: %s', $directory), ); } foreach ($this->captureDirectories() as $label => $directory) { $checks[] = $this->check( 'capture_dir_' . $label, $this->isWritableDirectory($directory), sprintf('Diretório de captura precisa existir e ser gravável: %s', $label), ); } $checks[] = $this->check('state_file_writable', $this->isWritableDirectory(dirname($this->stateFilePath())), 'O diretório do state file precisa existir e ser gravável.'); $checks[] = $this->check('state_file_json_integrity', ...$this->stateFileJsonIntegrity()); $checks[] = $this->check('platform_base_url', $this->validPlatformBaseUrl($productionSeverity === 'blocker'), 'PLATFORM_BASE_URL ausente, inválida ou apontando para placeholder.', $productionSeverity); $checks[] = $this->check('camera_credentials_key_configured', trim((string) $this->config->get('cameras.credentials_key', '')) !== '', 'CAMERA_CREDENTIALS_KEY ausente; o gateway não deve depender de fallback implícito para segredos de câmera em produção.', $productionSeverity); $checks[] = $this->check('media_signature_configuration', $mediaSignature->isValid(), 'Configuração de assinatura de mídia inválida. ' . $mediaSignature->errorSummary(), $productionSeverity); $checks[] = $this->check('ffmpeg_available', $this->commandAvailable((string) $this->config->get('capture.ffmpeg', '/usr/bin/ffmpeg')), 'FFmpeg não encontrado ou não executável.', $productionSeverity); $checks[] = $this->check('ffprobe_available', $this->commandAvailable((string) $this->config->get('capture.ffprobe', '/usr/bin/ffprobe')), 'FFprobe não encontrado ou não executável.', $productionSeverity); $sqlite = $this->sqliteIntegrity(); $checks[] = $this->check('sqlite_integrity', $sqlite['ok'], $sqlite['detail'], $sqlite['severity']); $registration = $this->gatewayRegistrationConsistency($state); $checks[] = $this->check('gateway_registration_consistency', $registration['ok'], $registration['detail'], $registration['severity']); $activationReadiness = $this->gatewayActivationBootstrapInputs($state); $checks[] = $this->check('gateway_registration_bootstrap_inputs', $activationReadiness['ok'], $activationReadiness['detail'], $activationReadiness['severity']); foreach ($this->licensePolicyChecks($state) as $licenseCheck) { $checks[] = $licenseCheck; } $blockers = $this->messages($checks, 'blocker'); $warnings = $this->messages($checks, 'warning'); $operationalReady = $this->operationallyReady($checks); if ($blockers !== []) { $this->logger->critical('gateway.boot_checklist.blocked', [ 'strict' => $strict, 'blockers' => $blockers, 'warnings' => $warnings, ]); } return [ 'ok' => $blockers === [], 'operational_ready' => $operationalReady, 'checks' => $checks, 'blockers' => $blockers, 'warnings' => $warnings, 'app_env' => $appEnv, ]; } public function prometheus(bool $strict = false): string { $readiness = $this->readiness($strict); $storage = is_array($readiness['storage'] ?? null) ? $readiness['storage'] : []; $cameras = is_array($readiness['cameras'] ?? null) ? $readiness['cameras'] : []; $labels = '{app="' . addslashes((string) ($readiness['app'] ?? 'gateway')) . '",env="' . addslashes((string) ($readiness['env'] ?? 'production')) . '"}'; $gauges = [ 'nvr_gateway_up' => 1, 'nvr_gateway_ready' => ($readiness['status'] ?? 'degraded') === 'ok' ? 1 : 0, 'nvr_gateway_registered' => ($readiness['registered'] ?? false) ? 1 : 0, 'nvr_gateway_boot_blockers' => count((array) ($readiness['blockers'] ?? [])), 'nvr_gateway_boot_warnings' => count((array) ($readiness['warnings'] ?? [])), 'nvr_gateway_cameras_total' => (int) ($cameras['total'] ?? 0), 'nvr_gateway_cameras_online' => (int) ($cameras['online'] ?? 0), 'nvr_gateway_cameras_offline' => (int) ($cameras['offline'] ?? 0), 'nvr_gateway_storage_total_bytes' => (int) ($storage['total_bytes'] ?? 0), 'nvr_gateway_storage_free_bytes' => (int) ($storage['free_bytes'] ?? 0), ]; $lines = []; foreach ($gauges as $name => $value) { $lines[] = '# TYPE ' . $name . ' gauge'; $lines[] = $name . $labels . ' ' . $value; } return implode(" ", $lines) . " "; } /** * @return list> */ private function cameraRows(): array { return array_values(array_filter($this->metadata->bucket('cameras'), static fn (mixed $row): bool => is_array($row))); } /** * @return array */ private function captureDirectories(): array { return [ 'snapshots' => $this->absolute((string) $this->config->get('capture.snapshots_root', './storage/snapshots')), 'recordings' => $this->absolute((string) $this->config->get('capture.recordings_root', './storage/recordings')), 'tmp' => $this->absolute((string) $this->config->get('capture.tmp_root', './storage/tmp')), ]; } private function stateFilePath(): string { return $this->absolute((string) $this->config->get('app.state_file', './storage/cache/gateway-state.json')); } private function absolute(string $path): string { if (str_starts_with($path, '/')) { return $path; } return GATEWAY_BASE_PATH . '/' . ltrim($path, './'); } private function isWritableDirectory(string $path): bool { if (! is_dir($path) && ! @mkdir($path, 0775, true) && ! is_dir($path)) { return false; } return is_writable($path); } /** * @return array{ok:bool,detail:string,severity:string} */ private function sqliteIntegrity(): array { $driver = strtolower((string) $this->config->get('database.driver', 'sqlite')); if ($driver !== 'sqlite') { return ['ok' => true, 'detail' => 'SQLite local não aplicável neste driver.', 'severity' => 'warning']; } if (! extension_loaded('pdo_sqlite')) { return ['ok' => false, 'detail' => 'pdo_sqlite ausente; integridade do SQLite não pôde ser validada e o metadata store cairá para o fallback em arquivo.', 'severity' => 'warning']; } $path = $this->absolute((string) $this->config->get('database.sqlite_path', './storage/database/gateway.sqlite')); if (! is_file($path) || filesize($path) === 0) { return ['ok' => false, 'detail' => 'SQLite do gateway ainda não foi materializado; o bootstrap pode criá-lo sob demanda.', 'severity' => 'warning']; } try { $pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $result = $pdo->query('PRAGMA quick_check')->fetchColumn(); return [ 'ok' => strtolower((string) $result) === 'ok', 'detail' => strtolower((string) $result) === 'ok' ? 'SQLite do gateway íntegro.' : 'SQLite do gateway retornou inconsistência no PRAGMA quick_check.', 'severity' => 'blocker', ]; } catch (Throwable $exception) { return ['ok' => false, 'detail' => 'Não foi possível validar o SQLite do gateway: ' . $exception->getMessage(), 'severity' => 'blocker']; } } /** * @param array $state * @return array{ok:bool,detail:string,severity:string} */ private function gatewayRegistrationConsistency(array $state): array { $configuredGatewayId = trim((string) $this->config->get('app.gateway_id', '')); $stateGatewayId = trim((string) ($state['gateway_id'] ?? '')); $registered = (bool) ($state['registered'] ?? false); $gatewayToken = trim((string) ($state['gateway_token'] ?? '')); $gatewayCode = trim((string) ($state['gateway_code'] ?? '')); if ($configuredGatewayId === '') { return ['ok' => false, 'detail' => 'GATEWAY_ID ausente no ambiente.', 'severity' => 'blocker']; } if (! $registered) { return ['ok' => true, 'detail' => 'Gateway ainda não registrado; sem divergência estrutural local.', 'severity' => 'warning']; } if ($stateGatewayId !== '' && $stateGatewayId !== $configuredGatewayId) { return ['ok' => false, 'detail' => sprintf('State file aponta gateway_id=%s, mas o ambiente está configurado como %s.', $stateGatewayId, $configuredGatewayId), 'severity' => 'blocker']; } if ($gatewayToken === '' || $gatewayCode === '') { return ['ok' => false, 'detail' => 'Gateway marcado como registrado, porém sem gateway_token/gateway_code completos no state file.', 'severity' => 'blocker']; } return ['ok' => true, 'detail' => 'Registro local do gateway consistente com a configuração ativa.', 'severity' => 'blocker']; } /** * @param array $state * @return array{ok:bool,detail:string,severity:string} */ private function gatewayActivationBootstrapInputs(array $state): array { $registered = (bool) ($state['registered'] ?? false); $autoRegister = (bool) $this->config->get('app.auto_register_on_boot', true); $activationCode = trim((string) ($_ENV['GATEWAY_ACTIVATION_CODE'] ?? $_SERVER['GATEWAY_ACTIVATION_CODE'] ?? '')); $gatewayToken = trim((string) ($state['gateway_token'] ?? '')); if ($registered || ! $autoRegister) { return ['ok' => true, 'detail' => 'Bootstrap do vínculo remoto está coerente com o estado atual.', 'severity' => 'warning']; } if ($activationCode === '' && $gatewayToken === '') { return ['ok' => false, 'detail' => 'Gateway não registrado e sem GATEWAY_ACTIVATION_CODE/state token para o bootstrap automático.', 'severity' => 'warning']; } return ['ok' => true, 'detail' => 'Gateway pronto para bootstrap automático com activation code ou token local configurado.', 'severity' => 'warning']; } /** * @param array $state * @return list */ private function licensePolicyChecks(array $state): array { $policy = $this->licensePolicy->subsystemPolicyFromState($state); $registered = (bool) ($policy['registered'] ?? false); $gatewayTokenPresent = (bool) ($policy['gateway_token_present'] ?? false); if (! $registered || ! $gatewayTokenPresent) { return [ $this->check( 'license_recording_policy', false, 'Gravacao local aguardando ativacao completa do gateway.', 'warning', ), ]; } $recordingAllowed = (bool) ($policy['recording_allowed'] ?? false); $remoteAllowed = (bool) ($policy['remote_bridge_allowed'] ?? false); return [ $this->check( 'license_recording_policy', $recordingAllowed, $recordingAllowed ? 'Licenca permite gravacao local.' : 'Gravacao local bloqueada pela licenca: ' . (string) ($policy['recording_block_reason'] ?? 'blocked_by_license'), 'blocker', ), $this->check( 'license_remote_access_policy', $remoteAllowed, $remoteAllowed ? 'Licenca permite bridge remoto.' : 'Bridge remoto bloqueado pela licenca: ' . (string) ($policy['remote_block_reason'] ?? 'remote_access_blocked'), 'warning', ), ]; } /** * @return array{0:bool,1:string,2:string} */ private function stateFileJsonIntegrity(): array { $path = $this->stateFilePath(); if (! is_file($path)) { return [false, 'State file local ainda não materializado; o bootstrap poderá criá-lo no primeiro vínculo.', 'warning']; } $raw = (string) file_get_contents($path); if (trim($raw) === '') { return [false, 'State file local existe, mas está vazio.', 'warning']; } $decoded = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) { return [false, 'State file do gateway está corrompido ou inválido.', 'blocker']; } return [true, 'State file do gateway possui JSON válido.', 'blocker']; } private function validPlatformBaseUrl(bool $strict): bool { $baseUrl = trim((string) $this->config->get('platform.base_url', '')); if ($baseUrl === '') { return false; } $parts = parse_url($baseUrl); if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return false; } if (! $strict) { return true; } $host = strtolower((string) $parts['host']); $path = '/' . trim((string) ($parts['path'] ?? ''), '/'); $localApplianceBase = in_array($host, ['localhost', '127.0.0.1', '::1'], true) && ($path === '/nvr' || str_starts_with($path, '/nvr/')); if (preg_match('/(^|\.)example\.(com|org|net)$/', $host) === 1) { return false; } if ($host === '0.0.0.0' || (in_array($host, ['localhost', '127.0.0.1', '::1'], true) && ! $localApplianceBase)) { return false; } return ! str_contains($host, 'placeholder'); } private function commandAvailable(string $command): bool { $command = trim($command); if ($command === '') { return false; } if (preg_match('/^(?:[A-Za-z]:[\/\\\\]|[\/\\\\])/', $command) === 1) { if (is_file($command) && is_executable($command)) { return true; } $command = basename(str_replace('\\', '/', $command)); } if (str_contains($command, '/') || str_contains($command, '\\')) { return is_file($command) && is_executable($command); } $extensions = ['']; if (PHP_OS_FAMILY === 'Windows') { $extensions = array_values(array_unique(array_filter(array_merge( ['.exe', '.bat', '.cmd', '.com', ''], explode(';', (string) getenv('PATHEXT')), )))); } foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $path) { foreach ($extensions as $extension) { $candidate = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $command; if ($extension !== '' && ! str_ends_with(strtolower($candidate), strtolower($extension))) { $candidate .= $extension; } if (is_file($candidate) && is_executable($candidate)) { return true; } } } return false; } private function severityForProductionSensitiveCheck(bool $strict, string $appEnv): string { return ($strict || ! in_array($appEnv, ['local', 'testing'], true)) ? 'blocker' : 'warning'; } /** * @param list $checks * @return list */ private function messages(array $checks, string $severity): array { $messages = []; foreach ($checks as $check) { if ($check['ok'] || $check['severity'] !== $severity) { continue; } $messages[] = $check['detail']; } return $messages; } /** * @param list $checks */ private function operationallyReady(array $checks): bool { $requiredPrefixes = ['runtime_dir_', 'capture_dir_']; $requiredNames = [ 'state_file_writable', 'state_file_json_integrity', 'platform_base_url', 'camera_credentials_key_configured', 'media_signature_configuration', 'ffmpeg_available', 'ffprobe_available', 'sqlite_integrity', 'gateway_registration_consistency', 'license_recording_policy', ]; foreach ($checks as $check) { foreach ($requiredPrefixes as $prefix) { if (str_starts_with($check['name'], $prefix) && ! $check['ok']) { return false; } } if (in_array($check['name'], $requiredNames, true) && ! $check['ok'] && $check['severity'] !== 'warning') { return false; } } return true; } /** * @return array{name:string,ok:bool,severity:string,detail:string} */ private function check(string $name, bool $ok, string $detail, string $severity = 'blocker'): array { return [ 'name' => $name, 'ok' => $ok, 'severity' => $severity, 'detail' => $detail, ]; } }