From 5d96cdc930e602066202478762eead75d734fab8 Mon Sep 17 00:00:00 2001 From: Lucas Quaresma <15186323+lrqnet@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:42:40 -0500 Subject: [PATCH 1/2] Prepare NetKeep v1.0.7 collection reliability fixes --- .github/workflows/release.yml | 8 +- CHANGELOG.md | 36 +++- README.md | 14 +- .../Commands/MigrateGitIdentityCommand.php | 25 +-- app/Exceptions/GitRepositoryUnavailable.php | 13 ++ .../Controllers/ConfigurationController.php | 47 ++++- app/Http/Controllers/DeviceController.php | 18 +- .../Internal/OxidizedEventController.php | 3 + app/Jobs/DispatchCollections.php | 24 +++ app/Jobs/ReconcileCollectionRun.php | 47 +++++ app/Services/BackupReconciler.php | 123 +++++++++---- app/Services/GitHistory.php | 27 ++- app/Services/GitMirrorService.php | 10 +- app/Services/GitProcessFactory.php | 25 +++ app/Services/RestoreCoordinator.php | 41 +++-- compose.e2e.yaml | 4 +- compose.yaml | 8 +- docs/ADMINISTRATION.md | 24 ++- docs/ARCHITECTURE.md | 19 +- docs/DEVELOPMENT.md | 15 +- docs/INSTALL.md | 2 +- docs/RELEASE.md | 22 +-- docs/RESTORE.md | 5 +- docs/SECURITY_REVIEW_V1.0.7.md | 106 +++++++++++ docs/UPGRADE.md | 19 +- lang/en/netkeep.php | 4 + lang/es/netkeep.php | 4 + lang/pt_BR/netkeep.php | 4 + resources/js/components/app-sidebar.tsx | 4 +- resources/js/i18n/catalogs/inventory.ts | 38 +++- resources/js/pages/configurations/show.tsx | 67 +++++-- resources/js/pages/devices/edit.tsx | 25 ++- resources/js/pages/devices/index.tsx | 15 +- scripts/e2e-test.sh | 26 +-- scripts/release-preflight.sh | 2 +- tests/E2E/installation.setup.ts | 4 +- .../Feature/NetKeep/CollectionControlTest.php | 86 +++++++++ .../NetKeep/CollectionDiagnosticsTest.php | 32 ++++ tests/Feature/NetKeep/DeviceApprovalTest.php | 172 ++++++++++++++++++ tests/Feature/NetKeep/GitHistoryTest.php | 55 ++++++ .../Feature/NetKeep/GitMirrorServiceTest.php | 7 +- 41 files changed, 1072 insertions(+), 158 deletions(-) create mode 100644 app/Exceptions/GitRepositoryUnavailable.php create mode 100644 app/Jobs/DispatchCollections.php create mode 100644 app/Jobs/ReconcileCollectionRun.php create mode 100644 app/Services/GitProcessFactory.php create mode 100644 docs/SECURITY_REVIEW_V1.0.7.md create mode 100644 tests/Feature/NetKeep/DeviceApprovalTest.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c1e6ad..615fc49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,8 +79,8 @@ jobs: assert_absent "ghcr.io/lrqnet/netkeep:${VERSION}" assert_absent "docker.io/lrqnet/netkeep-updater:${VERSION}" assert_absent "ghcr.io/lrqnet/netkeep-updater:${VERSION}" - assert_absent "docker.io/lrqnet/netkeep-oxidized:0.37.0-r3" - assert_absent "ghcr.io/lrqnet/netkeep-oxidized:0.37.0-r3" + assert_absent "docker.io/lrqnet/netkeep-oxidized:0.37.0-r4" + assert_absent "ghcr.io/lrqnet/netkeep-oxidized:0.37.0-r4" netkeep-image: needs: preflight @@ -165,7 +165,7 @@ jobs: images: | ghcr.io/lrqnet/netkeep-oxidized docker.io/lrqnet/netkeep-oxidized - tags: type=raw,value=0.37.0-r3 + tags: type=raw,value=0.37.0-r4 - id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: @@ -268,7 +268,7 @@ jobs: mkdir -p dist sed \ -e "s#docker.io/lrqnet/netkeep:${VERSION}#docker.io/lrqnet/netkeep@${APP_DIGEST}#g" \ - -e "s#docker.io/lrqnet/netkeep-oxidized:0.37.0-r3#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g" \ + -e "s#docker.io/lrqnet/netkeep-oxidized:0.37.0-r4#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g" \ -e "s#docker.io/lrqnet/netkeep-updater:${VERSION}#docker.io/lrqnet/netkeep-updater@${UPDATER_DIGEST}#g" \ compose.yaml > dist/compose.yaml grep -q "docker.io/lrqnet/netkeep@sha256:" dist/compose.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a91139..4676034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ Todas as mudanças relevantes serão registradas aqui. O projeto segue ## [Unreleased] +## [1.0.7] - 2026-07-24 + +### Fixed + +- indisponibilidade do serviço SSH durante a aprovação agora retorna uma + orientação segura, mantém o equipamento pendente e registra a tentativa sem + expor destino ou saída técnica, em vez de responder com erro 500; +- leitura do histórico Git compartilhado entre os contêineres passa a confiar + somente no caminho configurado para o repositório, evitando que ownerships + distintos ocultem versões e o conteúdo já coletado; +- sucesso do motor só conclui a coleta depois da confirmação do commit no Git; + falhas de acesso ou persistência agora deixam a execução em falha com motivo + seguro e retry, em vez de marcar o equipamento como saudável sem histórico; +- coletas manuais enfileiram o dispatcher antes do redirect e o hook + `post_store` agenda reconciliação direcionada, reduzindo a espera pelo ciclo + do scheduler sem depender de callbacks do worker HTTP persistente; +- lista de equipamentos diferencia os atalhos de configurações e coletas, e a + tela de histórico informa explicitamente indisponibilidade sem aparentar uma + primeira coleta vazia; +- rede do simulador E2E usa uma faixa dedicada configurável, evitando colisão + com instalações locais do Compose já ativas; +- restaurações bloqueiam novas conexões nos bancos envolvidos antes de encerrar + sessões e renomeá-los, eliminando a corrida com processos que tentem + reconectar durante a troca; +- cartão global de atualização usa as cores próprias da sidebar para manter + contraste WCAG AA quando uma nova versão está disponível. + +### Security + +- downloads e diffs de configuração usam `no-store`; falhas do Git retornam + somente mensagem traduzida e código estável, sem comando, path ou saída do + processo. + ## [1.0.6] - 2026-07-23 ### Added @@ -301,7 +334,8 @@ Todas as mudanças relevantes serão registradas aqui. O projeto segue - ambiente PostgreSQL do CI alinhado ao arquivo de teste e asserções de mensagens independentes do idioma configurado. -[Unreleased]: https://github.com/lrqnet/NetKeep/compare/v1.0.6...HEAD +[Unreleased]: https://github.com/lrqnet/NetKeep/compare/v1.0.7...HEAD +[1.0.7]: https://github.com/lrqnet/NetKeep/compare/v1.0.6...v1.0.7 [1.0.6]: https://github.com/lrqnet/NetKeep/compare/v1.0.5...v1.0.6 [1.0.5]: https://github.com/lrqnet/NetKeep/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/lrqnet/NetKeep/compare/v1.0.3...v1.0.4 diff --git a/README.md b/README.md index c1a562f..afcb385 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

AGPL-3.0 license - NetKeep v1.0.6 + NetKeep v1.0.7 Docker images for amd64 and arm64 Oxidized 0.37.0

@@ -105,13 +105,13 @@ administrative networks are strongly recommended. ## Quick installation -Create a directory, download the Compose file attached to the v1.0.6 release, +Create a directory, download the Compose file attached to the v1.0.7 release, and start the stack: ```bash sudo mkdir -p /opt/netkeep cd /opt/netkeep -sudo curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.6/compose.yaml +sudo curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml sudo docker compose up -d --wait sudo docker compose ps ``` @@ -165,12 +165,14 @@ with a separate five-minute session. 4. As an owner or administrator, verify its resolved destination, driver, credential, and SSH fingerprint, then approve it. 5. Run the first collection from the device page after reviewing the manual - collection warning. + collection warning. The request is dispatched immediately while the + persistent scheduler remains a fallback. 6. Open **Collections** on the device page to follow the safe timeline, inspect retries, and diagnose failures. A raw diagnostic is always manual, isolated, reauthenticated, audited, and may expose credentials. -7. Open the configuration history to inspect versions, compare changes, - search, or download a configuration. +7. Open **Configurations** from the device list to inspect permanent Git + versions, compare changes, search, or download a configuration. A collection + is only considered successful after NetKeep confirms its Git version. 8. Configure operational channels under **Notifications**. 9. Configure encrypted backups and private Git mirroring under **Data protection**. diff --git a/app/Console/Commands/MigrateGitIdentityCommand.php b/app/Console/Commands/MigrateGitIdentityCommand.php index 1a1eaf8..f01ff8d 100644 --- a/app/Console/Commands/MigrateGitIdentityCommand.php +++ b/app/Console/Commands/MigrateGitIdentityCommand.php @@ -3,9 +3,9 @@ namespace App\Console\Commands; use App\Models\Device; +use App\Services\GitProcessFactory; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; -use Symfony\Component\Process\Process; class MigrateGitIdentityCommand extends Command { @@ -13,7 +13,7 @@ class MigrateGitIdentityCommand extends Command protected $description = 'Migrates tracked device configuration paths to stable UUID identities'; - public function handle(): int + public function handle(GitProcessFactory $processes): int { $repository = rtrim((string) config('netkeep.oxidized.git_path'), '/'); if (! is_dir($repository.'/.git')) { @@ -24,19 +24,19 @@ public function handle(): int Device::withTrashed() ->with('group:id,name') ->orderBy('id') - ->chunkById(250, function ($devices) use ($repository, &$changed): void { + ->chunkById(250, function ($devices) use ($processes, $repository, &$changed): void { foreach ($devices as $device) { $destination = ($device->device_group_id ? 'group-'.$device->device_group_id : 'default') .'/'.$device->uuid; - if ($this->tracked($repository, $destination)) { + if ($this->tracked($processes, $repository, $destination)) { continue; } foreach ($this->legacyPaths($device->group?->name, $device->name, $device->device_group_id) as $legacy) { - if (! $this->tracked($repository, $legacy)) { + if (! $this->tracked($processes, $repository, $legacy)) { continue; } File::ensureDirectoryExists($repository.'/'.dirname($destination), 0770, true); - $this->git($repository, ['mv', '--', $legacy, $destination]); + $this->git($processes, $repository, ['mv', '--', $legacy, $destination]); $changed++; break; } @@ -44,7 +44,7 @@ public function handle(): int }, 'devices.id', 'id'); if ($changed > 0) { - $this->git($repository, ['commit', '-m', 'Migrate device identities to UUID']); + $this->git($processes, $repository, ['commit', '-m', 'Migrate device identities to UUID']); } $this->info("Migrated Git identities: {$changed}."); @@ -79,18 +79,21 @@ private function safeSegment(string $value): bool && ! str_contains($value, "\0"); } - private function tracked(string $repository, string $path): bool + private function tracked(GitProcessFactory $processes, string $repository, string $path): bool { - $process = new Process(['git', '-C', $repository, 'ls-files', '--error-unmatch', '--', $path]); + $process = $processes->make( + $repository, + ['ls-files', '--error-unmatch', '--', $path], + ); $process->setTimeout(10); return $process->run() === 0; } /** @param list $arguments */ - private function git(string $repository, array $arguments): void + private function git(GitProcessFactory $processes, string $repository, array $arguments): void { - $process = new Process(['git', '-C', $repository, ...$arguments]); + $process = $processes->make($repository, $arguments); $process->setTimeout(60); $process->mustRun(); } diff --git a/app/Exceptions/GitRepositoryUnavailable.php b/app/Exceptions/GitRepositoryUnavailable.php new file mode 100644 index 0000000..cb35617 --- /dev/null +++ b/app/Exceptions/GitRepositoryUnavailable.php @@ -0,0 +1,13 @@ +versions($device); + $content = $versions === [] ? '' : $history->content($device); + } catch (GitRepositoryUnavailable) { + Log::warning('configuration_history_unavailable'); + $versions = []; + $content = ''; + $unavailable = true; + } + return Inertia::render('configurations/show', [ 'device' => $device->load(['group:id,name', 'site:id,name']), - 'versions' => $history->versions($device), - 'content' => $history->content($device), + 'versions' => $versions, + 'content' => $content, + 'historyUnavailable' => $unavailable, ]); } public function download(Request $request, Device $device, GitHistory $history, AuditLogger $audit): HttpResponse { $revision = (string) $request->query('revision', 'HEAD'); - $content = $history->content($device, $revision); + try { + $content = $history->content($device, $revision); + } catch (GitRepositoryUnavailable) { + return $this->historyUnavailableResponse(); + } $audit->record('configuration.exported', $device, ['revision' => $revision]); $filename = Str::slug($device->name) ?: $device->uuid; return response($content, 200, [ 'Content-Type' => 'text/plain; charset=UTF-8', 'Content-Disposition' => 'attachment; filename="'.$filename.'-'.$revision.'.cfg"', + 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff', ]); } @@ -43,7 +62,25 @@ public function diff(Request $request, Device $device, GitHistory $history): Htt 'to' => ['required', 'regex:/^(HEAD|[a-f0-9]{7,40})$/'], ]); - return response($history->diff($device, $validated['from'], $validated['to'])) - ->header('Content-Type', 'text/plain; charset=UTF-8'); + try { + $diff = $history->diff($device, $validated['from'], $validated['to']); + } catch (GitRepositoryUnavailable) { + return $this->historyUnavailableResponse(); + } + + return response($diff, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + private function historyUnavailableResponse(): HttpResponse + { + return response(__('netkeep.config.history_unavailable'), 503, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + 'X-Content-Type-Options' => 'nosniff', + ]); } } diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php index b85d027..b55f98d 100644 --- a/app/Http/Controllers/DeviceController.php +++ b/app/Http/Controllers/DeviceController.php @@ -7,6 +7,7 @@ use App\Enums\DeviceApprovalStatus; use App\Enums\DeviceStatus; use App\Enums\SupportedLocale; +use App\Jobs\DispatchCollections; use App\Models\CredentialProfile; use App\Models\CustomModel; use App\Models\Device; @@ -92,6 +93,9 @@ public function edit(Device $device, Request $request): Response 'options' => $this->options(), 'canManage' => $request->user()->role->canManageInventory(), 'canApprove' => $request->user()->role->canManageSystem(), + 'initialTab' => $request->query('tab') === 'collections' + ? 'collections' + : 'configuration', ]); } @@ -190,6 +194,7 @@ public function collect( 'collection_run_uuid' => $run->uuid, 'force' => $force, ]); + DispatchCollections::dispatch(); return back()->with('success', __('netkeep.devices.collection_queued')); } @@ -215,6 +220,7 @@ public function forceCollect( 'collection_run_uuid' => $run->uuid, 'force' => true, ]); + DispatchCollections::dispatch(); return back()->with('warning', __('netkeep.devices.collection_force_queued')); } @@ -259,7 +265,17 @@ public function approve( $hostKey = null; $fingerprint = null; if ($device->transport === 'ssh') { - $scan = $scanner->scan($target, $device->port); + try { + $scan = $scanner->scan($target, $device->port); + } catch (\RuntimeException) { + $audit->record('device.approval_failed', $device, [ + 'reason' => 'ssh_host_key_unavailable', + 'transport' => $device->transport, + 'port' => $device->port, + ]); + + return back()->with('error', __('netkeep.devices.ssh_host_key_unavailable')); + } $hostKey = $scan['keys']; $fingerprint = $scan['fingerprint']; } diff --git a/app/Http/Controllers/Internal/OxidizedEventController.php b/app/Http/Controllers/Internal/OxidizedEventController.php index a4658c1..502ce42 100644 --- a/app/Http/Controllers/Internal/OxidizedEventController.php +++ b/app/Http/Controllers/Internal/OxidizedEventController.php @@ -5,6 +5,7 @@ use App\Enums\CollectionRunStatus; use App\Enums\CollectionTrigger; use App\Http\Controllers\Controller; +use App\Jobs\ReconcileCollectionRun; use App\Models\CollectionRun; use App\Models\CollectionRunEvent; use App\Models\Device; @@ -101,6 +102,8 @@ public function __invoke( ); if ($errorCode !== null) { $runs->fail($run, $errorCode, recordEvent: false); + } elseif ($data['event'] === 'post_store' && $run->trigger !== CollectionTrigger::Diagnostic) { + ReconcileCollectionRun::dispatch($run->id); } return response()->json(['accepted' => true, 'event_id' => $event->event_id], 202) diff --git a/app/Jobs/DispatchCollections.php b/app/Jobs/DispatchCollections.php new file mode 100644 index 0000000..8f295d4 --- /dev/null +++ b/app/Jobs/DispatchCollections.php @@ -0,0 +1,24 @@ +tick(); + } +} diff --git a/app/Jobs/ReconcileCollectionRun.php b/app/Jobs/ReconcileCollectionRun.php new file mode 100644 index 0000000..f5fe7f2 --- /dev/null +++ b/app/Jobs/ReconcileCollectionRun.php @@ -0,0 +1,47 @@ +runId; + } + + public function handle(BackupReconciler $reconciler, GitHistory $history): void + { + $run = CollectionRun::query()->with('device')->find($this->runId); + if (! $run || $run->status !== CollectionRunStatus::Running) { + return; + } + $storedAt = $run->events() + ->where('code', 'configuration_stored') + ->latest('occurred_at') + ->first() + ?->occurred_at; + if (! $storedAt) { + return; + } + + $reconciler->reconcileRun($run, $history, $storedAt, false); + } +} diff --git a/app/Services/BackupReconciler.php b/app/Services/BackupReconciler.php index 0ac6ec9..088283c 100644 --- a/app/Services/BackupReconciler.php +++ b/app/Services/BackupReconciler.php @@ -4,6 +4,7 @@ use App\Enums\CollectionRunStatus; use App\Enums\DeviceStatus; +use App\Exceptions\GitRepositoryUnavailable; use App\Jobs\SendAlert; use App\Models\BackupRun; use App\Models\CollectionRun; @@ -58,20 +59,25 @@ public function reconcile(GitHistory $history, OxidizedClient $oxidized): array continue; } - $latest = $history->versions($device, 1)[0] ?? null; if ($belongsToRunning && in_array($nodeStatus, ['success', 'done', 'unchanged', 'no_change'], true)) { - $this->recordCollectionResult( - $device, + $result = $this->reconcileRun( $running, - $latest, + $history, $engineEndedAt, - $created, - $recovered, ); + $created += $result['created']; + $failed += $result['failed']; + $recovered += $result['recovered']; continue; } + try { + $latest = $history->versions($device, 1)[0] ?? null; + } catch (GitRepositoryUnavailable) { + continue; + } + if (! $latest || BackupRun::query() ->where('device_id', $device->id) ->where('git_commit', $latest['hash']) @@ -133,28 +139,69 @@ public function reconcile(GitHistory $history, OxidizedClient $oxidized): array } /** - * @param array{hash:string,date:string,author:string,subject:string}|null $latest + * @return array{created:int,failed:int,recovered:int} */ - private function recordCollectionResult( - Device $device, + public function reconcileRun( CollectionRun $running, - ?array $latest, + GitHistory $history, CarbonInterface $engineEndedAt, - int &$created, - int &$recovered, - ): void { - if (BackupRun::query()->where('collection_run_id', $running->id)->exists()) { + bool $failWhenMissing = true, + ): array { + if ($running->status !== CollectionRunStatus::Running) { + return ['created' => 0, 'failed' => 0, 'recovered' => 0]; + } + if (BackupRun::query() + ->where('collection_run_id', $running->id) + ->whereNotNull('git_commit') + ->exists()) { app(CollectionRunService::class)->succeed($running); - return; + return ['created' => 0, 'failed' => 0, 'recovered' => 0]; + } + + try { + $latest = $history->versions($running->device, 1)[0] ?? null; + } catch (GitRepositoryUnavailable) { + app(CollectionRunService::class)->fail($running, 'configuration_history_unavailable'); + + return ['created' => 0, 'failed' => 1, 'recovered' => 0]; + } + if (! $latest) { + if ($failWhenMissing) { + app(CollectionRunService::class)->fail($running, 'configuration_not_persisted'); + } + + return ['created' => 0, 'failed' => $failWhenMissing ? 1 : 0, 'recovered' => 0]; } - $latestDate = $latest ? Carbon::parse($latest['date']) : null; - $commitBelongsToRun = $latest - && $latestDate - && $running->started_at + $result = $this->recordCollectionResult( + $running->device, + $running, + $latest, + $engineEndedAt, + ); + + return [ + 'created' => 1, + 'failed' => 0, + 'recovered' => $result['recovered'] ? 1 : 0, + ]; + } + + /** + * @param array{hash:string,date:string,author:string,subject:string} $latest + * @return array{recovered:bool} + */ + private function recordCollectionResult( + Device $device, + CollectionRun $running, + array $latest, + CarbonInterface $engineEndedAt, + ): array { + $latestDate = Carbon::parse($latest['date']); + $commitBelongsToRun = $running->started_at !== null && $latestDate->greaterThanOrEqualTo($running->started_at); - $commitWasRecorded = $latest && BackupRun::query() + $commitWasRecorded = BackupRun::query() ->where('device_id', $device->id) ->where('git_commit', $latest['hash']) ->exists(); @@ -162,21 +209,23 @@ private function recordCollectionResult( $changed = $commitBelongsToRun && ! $commitWasRecorded && $hadPreviousCommit; $wasFailing = $device->status === DeviceStatus::Failing; - BackupRun::query()->create([ - 'device_id' => $device->id, - 'collection_run_id' => $running->id, - 'status' => 'completed', - 'started_at' => $running->started_at, - 'finished_at' => $engineEndedAt, - 'git_commit' => $latest['hash'] ?? null, - 'changed' => $changed, - 'metadata' => array_filter([ - 'subject' => $commitBelongsToRun ? $latest['subject'] : null, - 'author' => $commitBelongsToRun ? $latest['author'] : null, - 'engine_status' => 'success', - 'configuration_changed' => $commitBelongsToRun && ! $commitWasRecorded, - ], static fn (mixed $value): bool => $value !== null), - ]); + BackupRun::query()->updateOrCreate( + ['collection_run_id' => $running->id], + [ + 'device_id' => $device->id, + 'status' => 'completed', + 'started_at' => $running->started_at, + 'finished_at' => $engineEndedAt, + 'git_commit' => $latest['hash'], + 'changed' => $changed, + 'metadata' => array_filter([ + 'subject' => $commitBelongsToRun ? $latest['subject'] : null, + 'author' => $commitBelongsToRun ? $latest['author'] : null, + 'engine_status' => 'success', + 'configuration_changed' => $commitBelongsToRun && ! $commitWasRecorded, + ], static fn (mixed $value): bool => $value !== null), + ], + ); $device->update([ 'status' => DeviceStatus::Healthy, 'last_backup_at' => $engineEndedAt, @@ -185,10 +234,8 @@ private function recordCollectionResult( 'last_error' => null, ]); app(CollectionRunService::class)->succeed($running); - $created++; if ($wasFailing) { - $recovered++; SendAlert::dispatch('recovery', 'netkeep.alerts.device_recovered', [ 'device_id' => $device->id, 'device' => $device->name, @@ -201,6 +248,8 @@ private function recordCollectionResult( 'git_commit' => $latest['hash'], ]); } + + return ['recovered' => $wasFailing]; } private function engineEndedAt(mixed $node): ?CarbonInterface diff --git a/app/Services/GitHistory.php b/app/Services/GitHistory.php index 836389b..0a8d277 100644 --- a/app/Services/GitHistory.php +++ b/app/Services/GitHistory.php @@ -2,18 +2,20 @@ namespace App\Services; +use App\Exceptions\GitRepositoryUnavailable; use App\Models\Device; -use Symfony\Component\Process\Process; class GitHistory { + public function __construct(private readonly GitProcessFactory $processes) {} + /** * @return array */ public function versions(Device $device, int $limit = 50): array { if (! is_dir(rtrim((string) config('netkeep.oxidized.git_path'), '/').'/.git')) { - return []; + throw new GitRepositoryUnavailable; } $path = $this->devicePath($device); @@ -27,8 +29,8 @@ public function versions(Device $device, int $limit = 50): array '--', $path, ]); - } catch (\Throwable) { - return []; + } catch (\Throwable $exception) { + throw new GitRepositoryUnavailable($exception); } return collect(explode("\n", trim($output))) @@ -46,8 +48,8 @@ public function content(Device $device, string $revision = 'HEAD'): string try { return $this->run(['show', $revision.':'.$this->devicePath($device)]); - } catch (\Throwable) { - return ''; + } catch (\Throwable $exception) { + throw new GitRepositoryUnavailable($exception); } } @@ -56,7 +58,11 @@ public function diff(Device $device, string $from, string $to): string $this->assertRevision($from); $this->assertRevision($to); - return $this->run(['diff', '--no-color', $from, $to, '--', $this->devicePath($device)]); + try { + return $this->run(['diff', '--no-color', $from, $to, '--', $this->devicePath($device)]); + } catch (\Throwable $exception) { + throw new GitRepositoryUnavailable($exception); + } } private function devicePath(Device $device): string @@ -67,11 +73,14 @@ private function devicePath(Device $device): string } /** - * @param array $arguments + * @param list $arguments */ private function run(array $arguments): string { - $process = new Process(['git', '-C', (string) config('netkeep.oxidized.git_path'), ...$arguments]); + $process = $this->processes->make( + (string) config('netkeep.oxidized.git_path'), + $arguments, + ); $process->setTimeout(15); $process->mustRun(); diff --git a/app/Services/GitMirrorService.php b/app/Services/GitMirrorService.php index 17ad1c0..d1f584c 100644 --- a/app/Services/GitMirrorService.php +++ b/app/Services/GitMirrorService.php @@ -10,7 +10,10 @@ class GitMirrorService { - public function __construct(private readonly OutboundUrlGuard $urls) {} + public function __construct( + private readonly OutboundUrlGuard $urls, + private readonly GitProcessFactory $processes, + ) {} public function mirror(BackupDestination $destination): void { @@ -50,7 +53,10 @@ public function mirror(BackupDestination $destination): void $environment['GIT_CONFIG_VALUE_1'] = "{$target['host']}:{$target['port']}:{$target['address']}"; } - $process = new Process(['git', '-C', $repository, 'push', '--mirror', $target['url']]); + $process = $this->processes->make( + $repository, + ['push', '--mirror', $target['url']], + ); $process->setEnv($environment); $process->setTimeout(1800); $process->mustRun(); diff --git a/app/Services/GitProcessFactory.php b/app/Services/GitProcessFactory.php new file mode 100644 index 0000000..364f23d --- /dev/null +++ b/app/Services/GitProcessFactory.php @@ -0,0 +1,25 @@ + $arguments + */ + public function make(string $repository, array $arguments): Process + { + $repository = rtrim($repository, '/'); + + return new Process([ + 'git', + '-c', + "safe.directory={$repository}", + '-C', + $repository, + ...$arguments, + ]); + } +} diff --git a/app/Services/RestoreCoordinator.php b/app/Services/RestoreCoordinator.php index 8bbf65d..59ae3a0 100644 --- a/app/Services/RestoreCoordinator.php +++ b/app/Services/RestoreCoordinator.php @@ -449,17 +449,14 @@ private function swapDatabase(string $current, string $temporary, string $uuid): { $rollback = 'netkeep_previous_'.substr(str_replace('-', '', $uuid), 0, 16); DB::disconnect(); - $this->adminSql( - 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN (' - ."'".str_replace("'", "''", $current)."','".str_replace("'", "''", $temporary)."'" - .') AND pid <> pg_backend_pid();', - ); + $this->blockConnections([$current, $temporary]); $this->adminSql( 'ALTER DATABASE '.$this->identifier($current).' RENAME TO '.$this->identifier($rollback).';', ); $this->adminSql( 'ALTER DATABASE '.$this->identifier($temporary).' RENAME TO '.$this->identifier($current).';', ); + $this->allowConnections($current); return $rollback; } @@ -471,12 +468,8 @@ private function rollbackState(string $uuid, array $state): void $rollback = (string) ($state['rollback_database'] ?? ''); if ($rollback !== '') { $failed = 'netkeep_failed_'.substr(str_replace('-', '', $uuid), 0, 16); - $this->adminSql( - 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN (' - ."'".str_replace("'", "''", $state['current_database'])."','" - .str_replace("'", "''", $rollback)."'" - .') AND pid <> pg_backend_pid();', - ); + DB::disconnect(); + $this->blockConnections([(string) $state['current_database'], $rollback]); $this->adminSql( 'ALTER DATABASE '.$this->identifier($state['current_database']).' RENAME TO ' .$this->identifier($failed).';', @@ -485,6 +478,7 @@ private function rollbackState(string $uuid, array $state): void 'ALTER DATABASE '.$this->identifier($rollback).' RENAME TO ' .$this->identifier($state['current_database']).';', ); + $this->allowConnections((string) $state['current_database']); $this->dropDatabase($failed); } $this->restoreDirectories((array) ($state['previous_paths'] ?? [])); @@ -650,6 +644,31 @@ private function dropDatabase(string $database): void $this->adminSql('DROP DATABASE IF EXISTS '.$this->identifier($database).';'); } + private function allowConnections(string $database): void + { + $this->adminSql( + 'ALTER DATABASE '.$this->identifier($database).' WITH ALLOW_CONNECTIONS true;', + ); + } + + /** @param list $databases */ + private function blockConnections(array $databases): void + { + foreach ($databases as $database) { + $this->adminSql( + 'ALTER DATABASE '.$this->identifier($database).' WITH ALLOW_CONNECTIONS false;', + ); + } + $names = implode(',', array_map( + fn (string $database): string => "'".str_replace("'", "''", $database)."'", + $databases, + )); + $this->adminSql( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity ' + ."WHERE datname IN ({$names}) AND pid <> pg_backend_pid();", + ); + } + private function identifier(string $value): string { if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/', $value)) { diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 2b08d20..fead061 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -80,7 +80,7 @@ services: context: tests/Fixtures/device-simulator networks: device_access: - ipv4_address: 172.31.250.10 + ipv4_address: '${NETKEEP_E2E_DEVICE_IP:-10.254.250.10}' sandbox_device_access: sandbox: @@ -93,4 +93,4 @@ networks: device_access: ipam: config: - - subnet: 172.31.250.0/24 + - subnet: '${NETKEEP_E2E_DEVICE_SUBNET:-10.254.250.0/24}' diff --git a/compose.yaml b/compose.yaml index 46c76b5..76b98a3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ name: netkeep x-netkeep-image: &netkeep-image - image: ${NETKEEP_IMAGE:-docker.io/lrqnet/netkeep:1.0.6} + image: ${NETKEEP_IMAGE:-docker.io/lrqnet/netkeep:1.0.7} x-netkeep-environment: &netkeep-environment APP_ENV: production @@ -251,7 +251,7 @@ services: - egress oxidized: - image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r3} + image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r4} user: '30000:30000' restart: unless-stopped read_only: true @@ -287,7 +287,7 @@ services: - device_access sandbox: - image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r3} + image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r4} command: ['/usr/local/bin/netkeep-sandbox-controller'] user: '30000:30000' restart: unless-stopped @@ -364,7 +364,7 @@ services: - internal updater: - image: ${NETKEEP_UPDATER_IMAGE:-docker.io/lrqnet/netkeep-updater:1.0.6} + image: ${NETKEEP_UPDATER_IMAGE:-docker.io/lrqnet/netkeep-updater:1.0.7} restart: unless-stopped group_add: - '20000' diff --git a/docs/ADMINISTRATION.md b/docs/ADMINISTRATION.md index 70d38ca..7177c2c 100644 --- a/docs/ADMINISTRATION.md +++ b/docs/ADMINISTRATION.md @@ -58,7 +58,9 @@ timeouts antes de aumentar conexões. A coleta manual mostra destino, transporte, driver, estado, última coleta e próximo horário permitido. Operadores sempre respeitam o cooldown de cinco minutos. Proprietário e administrador podem forçar somente após reautenticação -e confirmação do risco. +e confirmação do risco. Depois da confirmação, o NetKeep aciona o dispatcher +sem aguardar o próximo minuto do scheduler. A fila continua persistente e o +scheduler permanece como recuperação caso o job imediato não seja processado. ## Aprovação de equipamentos @@ -74,6 +76,11 @@ desativados. Proprietário ou administrador deve conferir e aprovar: Mudanças nesses valores revogam automaticamente a aprovação e pausam coletas. Uma alteração de chave SSH nunca é aceita silenciosamente. +Se o NetKeep não conseguir alcançar o serviço SSH ou obter a chave do host, a +aprovação falha com uma mensagem segura e o equipamento permanece desativado e +pendente. Verifique rota, firewall, porta e serviço SSH antes de tentar +novamente. A verificação da chave nunca é ignorada. + ## Histórico e diagnóstico por equipamento A tela de edição separa **Configuração** e **Coletas**. Todos os papéis podem @@ -113,7 +120,20 @@ docker compose exec app php artisan netkeep:prune-collection-diagnostics As categorias seguras conhecidas cobrem autenticação, conexão recusada, timeout de conexão, limite total da coleta, prompt não detectado, mudança de -chave SSH, erro de driver e falha do motor. +chave SSH, erro de driver, falha do motor, histórico Git indisponível e +configuração não persistida. + +O sucesso informado pelo motor não é suficiente para marcar o equipamento como +saudável. O hook `post_store` solicita a verificação direcionada do repositório +e o reconciliador periódico funciona como fallback. A execução só termina com +sucesso quando uma versão permanente é confirmada no Git. Se o repositório +compartilhado não puder ser lido ou não contiver uma versão, a execução falha +com uma categoria segura e segue a política normal de retry. + +Na lista de equipamentos, **Configurações** abre o histórico Git permanente e +**Coletas** abre diretamente a timeline operacional. Se o histórico estiver +indisponível, a página mostra um alerta explícito e desativa o download, sem +exibir comando, caminho do volume ou saída do Git. ## Atualizações do NetKeep diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 381c7f8..3a1f908 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -92,6 +92,13 @@ O UUID é a identidade técnica no motor e no Git. Nome e grupo são apenas apresentação, evitando colisões. O painel estima a duração mínima do ciclo e alerta quando ela não cabe no menor intervalo configurado. +Pedidos manuais persistem a execução e enfileiram o dispatcher antes do +redirect. Essa gravação curta não depende de callbacks de encerramento do +worker HTTP persistente. O tick periódico continua como fallback. O acesso da +aplicação ao repositório compartilhado usa `safe.directory` limitado ao path +canônico configurado em cada processo Git; não existe confiança global ou +wildcard. + Equipamentos novos ou sincronizados entram desativados e pendentes. Somente proprietário ou administrador aprova destino, DNS resolvido, porta, transporte, credencial, driver e fingerprint SSH. Alterar qualquer um desses campos revoga @@ -105,7 +112,10 @@ O reporter compilado na imagem derivada do Oxidized usa somente as variáveis oficiais dos hooks `node_success`, `node_fail` e `post_store` e envia JSON de no máximo 8 KiB para `POST /internal/oxidized/events`. Um `node_fail` pode encerrar a execução imediatamente; o sucesso continua sendo confirmado pelo -reconciliador de status e Git. +reconciliador de status e Git. `post_store` agenda uma reconciliação direcionada +e o ciclo periódico cobre indisponibilidade transitória da fila. A execução só +fica saudável depois de confirmar uma versão no Git; repositório inacessível ou +ausência de versão produz falha sanitizada e retry. Os endpoints JSON e SSE usam o mesmo serializer por papel. Operador e leitor nunca recebem `technical_message`, contexto ou referência do motor. O cursor do @@ -202,5 +212,8 @@ contêiner e deve ser tratado como controle do motor. O serviço `recovery` prepara o dump em banco temporário, valida manifesto, hashes, limites e chaves e preserva banco e diretórios atuais. A troca só ocorre -depois dessa validação. Um health check malsucedido executa rollback automático. -O painel apenas prepara uploads; nunca recebe privilégios de recuperação. +depois dessa validação. Antes do rename, os bancos envolvidos recusam novas +conexões e suas sessões residuais são encerradas, impedindo reconexões de +processos durante a troca. Somente o banco que assume o nome ativo volta a +aceitar conexões. Um health check malsucedido executa rollback automático. O +painel apenas prepara uploads; nunca recebe privilégios de recuperação. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e13e777..1f21fac 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -103,7 +103,10 @@ O ambiente E2E é separado da instalação local e usa o projeto Compose Esses serviços estão exclusivamente em `compose.e2e.yaml`. Eles não fazem parte do `compose.yaml` distribuído nas releases e não são instalados nos -servidores dos usuários. +servidores dos usuários. Se a faixa privada padrão do simulador conflitar com +outra rede Docker local, defina `NETKEEP_E2E_DEVICE_SUBNET` e +`NETKEEP_E2E_DEVICE_IP` com uma subnet e um endereço livres antes de executar o +script. Instale o Chromium do Playwright e execute a jornada usada em pull requests: @@ -119,10 +122,12 @@ O teste parte de volumes vazios e percorre: 2. confirmação de senha, credencial e equipamento; 3. fingerprint SSH, aprovação e coleta pelo Oxidized; 4. commit da configuração no repositório Git; -5. Webhook, Telegram e SMTP contra os simuladores; -6. backup v2 criptografado, upload e download no S3Mock; -7. `prepare`, `apply` e `finalize` pelo contêiner de recuperação; -8. prova no PostgreSQL e no Git de que o estado anterior foi restaurado. +5. confirmação de que aplicação e Oxidized, com UIDs distintos, leem o mesmo + commit sem confiança Git global; +6. Webhook, Telegram e SMTP contra os simuladores; +7. backup v2 criptografado, upload e download no S3Mock; +8. `prepare`, `apply` e `finalize` pelo contêiner de recuperação; +9. prova no PostgreSQL e no Git de que o estado anterior foi restaurado. O ambiente permanece disponível ao final em `https://127.0.0.1:18444`. Os serviços de inspeção usam apenas loopback: diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9c62d2d..cc98a07 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -21,7 +21,7 @@ também são espelhadas no GHCR. ```bash mkdir -p /opt/netkeep cd /opt/netkeep -curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.6/compose.yaml +curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml docker compose up -d --wait docker compose ps ``` diff --git a/docs/RELEASE.md b/docs/RELEASE.md index dfcfd96..657d39a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -36,8 +36,8 @@ O token fica somente no cofre de secrets do GitHub e não deve ser salvo em O workflow `.github/workflows/release.yml` é acionado por tags SemVer: ```bash -git tag -a v1.0.6 -m "NetKeep v1.0.6" -git push origin refs/tags/v1.0.6 +git tag -a v1.0.7 -m "NetKeep v1.0.7" +git push origin refs/tags/v1.0.7 ``` O workflow: @@ -49,7 +49,7 @@ O workflow: 3. autentica no GHCR com `GITHUB_TOKEN` e no Docker Hub com os secrets; 4. constrói NetKeep, NetKeep-Updater e NetKeep-Oxidized para `amd64` e `arm64`; 5. publica as tags SemVer do painel e updater e a revisão imutável - `netkeep-oxidized:0.37.0-r3`; + `netkeep-oxidized:0.37.0-r4`; 6. gera SBOM e provenance das imagens e assina seus digests nos dois registries; 7. substitui as tags do Compose pelos digests publicados; @@ -61,20 +61,20 @@ O Compose nunca usa `latest`; a tag existe apenas para descoberta manual. ## Conferência ```bash -docker pull lrqnet/netkeep:1.0.6 -docker image inspect lrqnet/netkeep:1.0.6 +docker pull lrqnet/netkeep:1.0.7 +docker image inspect lrqnet/netkeep:1.0.7 cosign verify \ - --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.6' \ + --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.7' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ - docker.io/lrqnet/netkeep:1.0.6 + docker.io/lrqnet/netkeep:1.0.7 cosign verify \ - --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.6' \ + --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.7' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ - docker.io/lrqnet/netkeep-oxidized:0.37.0-r3 + docker.io/lrqnet/netkeep-oxidized:0.37.0-r4 ``` -Verifique também `docker.io/lrqnet/netkeep-updater:1.0.6` com a mesma +Verifique também `docker.io/lrqnet/netkeep-updater:1.0.7` com a mesma identidade e emissor. Se qualquer registry, assinatura ou criação da release falhar depois de uma @@ -89,7 +89,7 @@ Em um servidor novo: ```bash mkdir -p /opt/netkeep cd /opt/netkeep -curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.6/compose.yaml +curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml docker compose up -d ``` diff --git a/docs/RESTORE.md b/docs/RESTORE.md index b8f5599..5cf536e 100644 --- a/docs/RESTORE.md +++ b/docs/RESTORE.md @@ -98,7 +98,10 @@ docker compose --profile recovery run --rm recovery \ O serviço de recuperação ativa a manutenção, espera filas e coletas em execução terminarem, preserva banco e diretórios atuais e só então troca banco, -Git, modelos, branding e chaves. +Git, modelos, branding e chaves. A troca bloqueia novas conexões nos bancos +envolvidos antes de encerrar sessões residuais e renomeá-los. O banco anterior +permanece indisponível para conexões e somente o banco restaurado, já com o nome +ativo, volta a aceitá-las. Reinicie os processos para carregarem as chaves restauradas e o token interno rotacionado: diff --git a/docs/SECURITY_REVIEW_V1.0.7.md b/docs/SECURITY_REVIEW_V1.0.7.md new file mode 100644 index 0000000..2c6567c --- /dev/null +++ b/docs/SECURITY_REVIEW_V1.0.7.md @@ -0,0 +1,106 @@ +# Revisão de segurança da v1.0.7 + +Data da revisão: 24 de julho de 2026. + +## Escopo + +A revisão cobre falhas esperadas na aprovação SSH, leitura do histórico Git +compartilhado entre contêineres, conclusão fail-closed das coletas, disparo +manual, reconciliação após `post_store`, navegação entre coletas e +configurações, restauração do PostgreSQL, rede do simulador E2E e contraste do +aviso global de atualização. + +Não houve mudança de dependências, formato de backup, política de retenção, +permissões de papéis ou capacidade de escrita nos equipamentos. + +## Cadeia de fornecimento + +- app e updater são publicados com a versão imutável `1.0.7`; +- a imagem derivada do Oxidized recebe a revisão imutável `0.37.0-r4`, sem + sobrescrever a `0.37.0-r3` já publicada; +- o preflight rejeita release, tag de imagem ou metadados divergentes antes da + autenticação dos jobs de build; +- o Compose anexado à release substitui as três tags pelos digests produzidos + no workflow e mantém SBOM, provenance e assinatura Cosign. + +## Aprovação SSH + +- indisponibilidade, timeout, conexão recusada ou chave inválida retornam uma + mensagem traduzida e não produzem erro 500; +- o equipamento permanece pendente, desativado e sem fingerprint aprovado; +- `known_hosts` não é alterado e o Oxidized não é recarregado; +- a auditoria registra somente `device.approval_failed` e o código estável + `ssh_host_key_unavailable`; +- endereço, hostname, stderr e mensagem técnica não são enviados à resposta, + auditoria ou logs; +- a verificação continua obrigatória e fail-closed. + +## Histórico e conclusão de coletas + +- cada processo Git confia somente no caminho canônico configurado por meio de + `safe.directory`; não existe wildcard nem alteração da configuração global; +- argumentos Git não recebem credenciais, e falhas retornam somente + `configuration_history_unavailable`; +- uma execução só termina com sucesso depois que a versão correspondente é + confirmada no Git; +- repositório inacessível ou primeira coleta sem versão persistida produz falha + segura e retry, sem estado saudável enganoso; +- download e diff usam `no-store`, e a tela distingue indisponibilidade de + histórico vazio; +- o evento `post_store` agenda reconciliação direcionada; +- o pedido manual grava o dispatcher na fila antes do redirect, sem depender de + callbacks do worker HTTP persistente. O scheduler permanece como fallback. + +## Restauração e isolamento + +- antes do rename, os bancos envolvidos recusam novas conexões e suas sessões + residuais são encerradas; +- o banco anterior permanece bloqueado e somente o banco que assume o nome + ativo volta a aceitar conexões; +- o E2E confirmou `prepare`, `apply`, `finalize`, reinício ordenado, rollback + preservado até a finalização e restauração dos dados esperados; +- a faixa do simulador é dedicada e configurável, sem ampliar a rede de acesso + aos equipamentos; +- o diagnóstico permanece no sandbox, com Git e logs efêmeros, trace cifrado e + ausência do marker em texto claro nos volumes persistentes. + +## Interface + +- configurações e coletas possuem destinos distintos na lista de equipamentos; +- a aba de coletas pode ser aberta diretamente por URL e mantém semântica de + teclado; +- o aviso de atualização usa as cores próprias da sidebar e passou a auditoria + WCAG 2 AA em todas as páginas cobertas. + +## Validações + +- Pint: 275 arquivos; +- PHPStan: 208 arquivos, sem erros; +- PHPUnit: 210 testes, 1.036 asserções e cinco integrações externas opcionais + ignoradas no ambiente unitário; +- Playwright Chromium: instalação e 25 cenários, incluindo diagnóstico, + acessibilidade, navegação, screenshots, passkey, três idiomas, temas e + atualização; +- integrações E2E: quatro testes e 18 asserções; R2 real ignorado sem + credenciais dedicadas; +- coleta simulada confirmada no Git, com dispatcher e conclusão abaixo de 30 + segundos; +- restauração portátil E2E concluída, com app, worker, scheduler, Oxidized e + sandbox saudáveis após a finalização; +- ESLint, Prettier, TypeScript, build Vite e 869 chaves em três idiomas; +- formatação e testes Go do updater, reporter, controller do sandbox e + simulador; +- Compose de release, desenvolvimento e E2E, além da sintaxe do harness E2E; +- preflight local da `v1.0.7`, incluindo versões do app, updater e Oxidized, + changelog, README e guias públicos; +- auditorias Composer e npm sem advisories de produção; +- Trivy 0.70 no filesystem, configuração e segredos, sem vulnerabilidades + altas ou críticas corrigíveis, configurações altas ou críticas ou segredos; +- `git diff --check` sem erros. + +## Resultado + +Não foi identificada regressão de autorização, exposição de segredo ou bypass +da verificação de host key. Os estados de aprovação, coleta e restauração +permanecem fail-closed. A mudança está apta para pull request; tag, release e +publicação permanecem condicionadas ao merge e aos checks verdes da `main`. diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index a0cdb1b..81ef52e 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -7,12 +7,12 @@ socket Docker e sem depender do agente de atualização. ## Atualização manual para instalações sem updater Instalações v1.0.0 e v1.0.1 ainda não possuem o updater integrado. Faça uma -última atualização manual diretamente para v1.0.6: +última atualização manual diretamente para v1.0.7: ```bash cd /opt/netkeep docker compose exec app php artisan netkeep:backup -curl -fsSLo compose.yaml.next https://github.com/lrqnet/NetKeep/releases/download/v1.0.6/compose.yaml +curl -fsSLo compose.yaml.next https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml docker compose -f compose.yaml.next config --quiet mv compose.yaml.next compose.yaml docker compose pull @@ -61,6 +61,19 @@ Quando o manifesto permite rollback e o health check falha, o Compose anterior é restaurado automaticamente. Caso contrário, a operação entra em `recovery_required`; preserve volumes e snapshot e siga o guia de restauração. +## Mudanças de coleta e histórico na v1.0.7 + +A v1.0.7 trata indisponibilidade da chave SSH durante a aprovação como falha +operacional segura, sem aprovar parcialmente o equipamento. Coletas manuais +entram na fila imediatamente, e o hook `post_store` agenda a reconciliação da +execução sem aguardar o próximo ciclo do scheduler. + +O acesso ao Git compartilhado passa a confiar somente no caminho exato do +repositório. Uma coleta só termina com sucesso depois que a configuração está +confirmada no histórico; falhas de acesso ou da primeira persistência geram +erro seguro e retry. O upgrade preserva o volume `oxidized_git` e não altera o +conteúdo das configurações já coletadas. + ## Mudanças do fluxo na v1.0.6 A v1.0.6 adiciona `request_id`, último progresso e reconhecimento terminal às @@ -83,7 +96,7 @@ manual. As tags v1.0.4 e v1.0.5 não possuem release, Compose ou manifesto de atualização e não são destinos suportados. A v1.0.4 publicou imagens isoladas; a v1.0.5 foi bloqueada antes da publicação. Instalações existentes devem atualizar -diretamente para v1.0.6. Não use as imagens isoladas da v1.0.4 nem tente +diretamente para v1.0.7. Não use as imagens isoladas da v1.0.4 nem tente reconstruir seus artefatos. ## Mudanças de dados e sandbox na v1.0.3 diff --git a/lang/en/netkeep.php b/lang/en/netkeep.php index 3f41160..6ffbba8 100644 --- a/lang/en/netkeep.php +++ b/lang/en/netkeep.php @@ -49,6 +49,7 @@ 'collection_failed_safe' => 'The collection failed. Review the device status and protected logs.', 'diagnostic_queued' => 'The isolated diagnostic was queued. Raw trace access remains protected and audited.', 'approval_engine_failed' => 'Oxidized did not become healthy. Approval was revoked automatically.', + 'ssh_host_key_unavailable' => 'The SSH host key could not be obtained. Check that NetKeep can reach the device and that SSH is listening on the configured port.', 'approved' => 'Device approved for collection.', 'approval_revoked' => 'Device approval revoked and collections paused.', 'imported' => ':count devices imported.', @@ -149,6 +150,9 @@ 'created' => 'Catalog item created.', 'deleted' => 'Item removed. Devices and history were preserved.', ], + 'config' => [ + 'history_unavailable' => 'The configuration history is temporarily unavailable. Try again after checking the application and Oxidized services.', + ], 'system' => [ 'updated' => 'System settings updated.', 'canonical_url_origin_only' => 'The canonical URL must contain only scheme, host, and optional port.', diff --git a/lang/es/netkeep.php b/lang/es/netkeep.php index 76c536c..be35cbd 100644 --- a/lang/es/netkeep.php +++ b/lang/es/netkeep.php @@ -49,6 +49,7 @@ 'collection_failed_safe' => 'La recolección falló. Revisa el estado del equipo y los registros protegidos.', 'diagnostic_queued' => 'El diagnóstico aislado fue puesto en cola. El acceso al trace bruto permanece protegido y auditado.', 'approval_engine_failed' => 'Oxidized no quedó saludable. La aprobación fue revocada automáticamente.', + 'ssh_host_key_unavailable' => 'No se pudo obtener la clave del host SSH. Verifica que NetKeep pueda alcanzar el equipo y que SSH esté activo en el puerto configurado.', 'approved' => 'Equipo aprobado para recolección.', 'approval_revoked' => 'La aprobación fue revocada y las recolecciones quedaron pausadas.', 'imported' => ':count equipos importados.', @@ -149,6 +150,9 @@ 'created' => 'Elemento de catálogo creado.', 'deleted' => 'Elemento eliminado. Los equipos y el historial fueron conservados.', ], + 'config' => [ + 'history_unavailable' => 'El historial de configuraciones no está disponible temporalmente. Inténtalo de nuevo después de verificar los servicios de la aplicación y Oxidized.', + ], 'system' => [ 'updated' => 'Configuración del sistema actualizada.', 'canonical_url_origin_only' => 'La URL canónica debe contener solo esquema, host y puerto opcional.', diff --git a/lang/pt_BR/netkeep.php b/lang/pt_BR/netkeep.php index e76cd7e..9bcfa53 100644 --- a/lang/pt_BR/netkeep.php +++ b/lang/pt_BR/netkeep.php @@ -49,6 +49,7 @@ 'collection_failed_safe' => 'A coleta falhou. Revise o estado do equipamento e os logs protegidos.', 'diagnostic_queued' => 'O diagnóstico isolado foi enfileirado. O acesso ao trace bruto permanece protegido e auditado.', 'approval_engine_failed' => 'O Oxidized não ficou saudável. A aprovação foi revogada automaticamente.', + 'ssh_host_key_unavailable' => 'Não foi possível obter a chave do host SSH. Verifique se o NetKeep alcança o equipamento e se o SSH está ativo na porta configurada.', 'approved' => 'Equipamento aprovado para coleta.', 'approval_revoked' => 'A aprovação foi revogada e as coletas foram pausadas.', 'imported' => ':count equipamentos importados.', @@ -149,6 +150,9 @@ 'created' => 'Item de catálogo criado.', 'deleted' => 'Item removido. Equipamentos e históricos foram preservados.', ], + 'config' => [ + 'history_unavailable' => 'O histórico de configurações está temporariamente indisponível. Tente novamente após verificar os serviços da aplicação e do Oxidized.', + ], 'system' => [ 'updated' => 'Configurações do sistema atualizadas.', 'canonical_url_origin_only' => 'A URL canônica deve conter apenas esquema, host e porta opcional.', diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index e43f0c3..af90d47 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -122,12 +122,12 @@ export function AppSidebar() { href="/updates" className="mx-2 mt-auto rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm transition-colors group-data-[collapsible=icon]:hidden hover:bg-emerald-500/15" > - + {t('updates.sidebar_available', { version: netkeep.update.version ?? '', })} - + {t('updates.sidebar_open')} diff --git a/resources/js/i18n/catalogs/inventory.ts b/resources/js/i18n/catalogs/inventory.ts index e0b58a6..4751049 100644 --- a/resources/js/i18n/catalogs/inventory.ts +++ b/resources/js/i18n/catalogs/inventory.ts @@ -61,10 +61,10 @@ export const inventoryMessages = defineMessages({ pt_BR: 'Estado', es: 'Estado', }, - 'devices.history': { - en: 'History', - pt_BR: 'Histórico', - es: 'Historial', + 'devices.configurations': { + en: 'Configurations', + pt_BR: 'Configurações', + es: 'Configuraciones', }, 'devices.collect_now': { en: 'Collect now', @@ -667,6 +667,16 @@ export const inventoryMessages = defineMessages({ pt_BR: 'A coleta excedeu o limite total de tempo.', es: 'La recolección superó el límite total de tiempo.', }, + 'collections.error.configuration_history_unavailable': { + en: 'The configuration history storage is temporarily unavailable.', + pt_BR: 'O armazenamento do histórico de configurações está temporariamente indisponível.', + es: 'El almacenamiento del historial de configuraciones no está disponible temporalmente.', + }, + 'collections.error.configuration_not_persisted': { + en: 'The engine finished, but no configuration version could be confirmed.', + pt_BR: 'O motor terminou, mas nenhuma versão da configuração pôde ser confirmada.', + es: 'El motor terminó, pero no se pudo confirmar ninguna versión de la configuración.', + }, 'devices.hostname': { en: 'Hostname', pt_BR: 'Hostname', @@ -767,6 +777,26 @@ export const inventoryMessages = defineMessages({ pt_BR: 'Baixar configuração', es: 'Descargar configuración', }, + 'config.view_collections': { + en: 'View collections', + pt_BR: 'Ver coletas', + es: 'Ver recolecciones', + }, + 'config.history_unavailable_title': { + en: 'Configuration history unavailable', + pt_BR: 'Histórico de configurações indisponível', + es: 'Historial de configuraciones no disponible', + }, + 'config.history_unavailable': { + en: 'NetKeep could not verify the permanent Git history. The device is not considered healthy from this collection until persistence is confirmed.', + pt_BR: 'O NetKeep não conseguiu verificar o histórico Git permanente. O equipamento não é considerado saudável por esta coleta até a persistência ser confirmada.', + es: 'NetKeep no pudo verificar el historial Git permanente. El equipo no se considera saludable por esta recolección hasta que se confirme la persistencia.', + }, + 'config.history_unavailable_content': { + en: '# Configuration content is unavailable until Git history access is restored.', + pt_BR: '# O conteúdo da configuração fica indisponível até o acesso ao histórico Git ser restaurado.', + es: '# El contenido de la configuración no está disponible hasta que se restaure el acceso al historial Git.', + }, 'config.versions': { en: 'Versions', pt_BR: 'Versões', diff --git a/resources/js/pages/configurations/show.tsx b/resources/js/pages/configurations/show.tsx index b268ab2..621c4a7 100644 --- a/resources/js/pages/configurations/show.tsx +++ b/resources/js/pages/configurations/show.tsx @@ -1,5 +1,12 @@ -import { Head } from '@inertiajs/react'; -import { Download, GitCompareArrows, History, Router } from 'lucide-react'; +import { Head, Link } from '@inertiajs/react'; +import { + AlertTriangle, + Download, + GitCompareArrows, + History, + ListTree, + Router, +} from 'lucide-react'; import { PageHeader } from '@/components/page-header'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -22,12 +29,14 @@ type Props = { subject: string; }>; content: string; + historyUnavailable: boolean; }; export default function ConfigurationShow({ device, versions, content, + historyUnavailable, }: Props) { const { t, formatDateTime } = useI18n(); @@ -40,17 +49,51 @@ export default function ConfigurationShow({ title={device.name} description={`${device.ip_address} · ${device.oxidized_model} · ${device.group?.name ?? 'default'}`} actions={ - + <> + + {!historyUnavailable && versions.length > 0 ? ( + + ) : ( + + )} + } /> + {historyUnavailable && ( +
+ +
+

+ {t('config.history_unavailable_title')} +

+

+ {t('config.history_unavailable')} +

+
+
+ )} +
@@ -107,7 +150,9 @@ export default function ConfigurationShow({
-                                {content || t('config.awaiting_first')}
+                                {historyUnavailable
+                                    ? t('config.history_unavailable_content')
+                                    : content || t('config.awaiting_first')}
                             
diff --git a/resources/js/pages/devices/edit.tsx b/resources/js/pages/devices/edit.tsx index 13174a0..5997578 100644 --- a/resources/js/pages/devices/edit.tsx +++ b/resources/js/pages/devices/edit.tsx @@ -18,6 +18,7 @@ import { Spinner } from '@/components/ui/spinner'; import { useI18n } from '@/i18n'; type Option = { id: number; name: string }; +type DeviceTab = 'configuration' | 'collections'; type Device = { id: number; name: string; @@ -49,6 +50,7 @@ export default function EditDevice({ options, canManage, canApprove, + initialTab, }: { device: Device; options: { @@ -63,17 +65,28 @@ export default function EditDevice({ }; canManage: boolean; canApprove: boolean; + initialTab: DeviceTab; }) { const { t } = useI18n(); const [interval, setIntervalValue] = useState(device.backup_interval); const [timeout, setTimeoutValue] = useState(device.timeout); - const [activeTab, setActiveTab] = useState<'configuration' | 'collections'>( - 'configuration', - ); + const [activeTab, setActiveTab] = useState(initialTab); const intervalRisk = interval < 900 ? 'critical' : interval < 3600 ? 'warning' : 'normal'; const timeoutRisk = timeout > 180 ? 'critical' : timeout > 60 ? 'warning' : 'normal'; + const selectTab = (tab: DeviceTab): void => { + setActiveTab(tab); + const url = new URL(window.location.href); + + if (tab === 'collections') { + url.searchParams.set('tab', tab); + } else { + url.searchParams.delete('tab'); + } + + window.history.replaceState(window.history.state, '', url); + }; const navigateTabs = (event: React.KeyboardEvent): void => { if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) { return; @@ -82,7 +95,7 @@ export default function EditDevice({ event.preventDefault(); const next = activeTab === 'configuration' ? 'collections' : 'configuration'; - setActiveTab(next); + selectTab(next); requestAnimationFrame(() => document.getElementById(`device-${next}-tab`)?.focus(), ); @@ -122,7 +135,7 @@ export default function EditDevice({ ? 'border-b-2 border-primary' : '' } - onClick={() => setActiveTab('configuration')} + onClick={() => selectTab('configuration')} > {t('devices.configuration_tab')} @@ -138,7 +151,7 @@ export default function EditDevice({ ? 'border-b-2 border-primary' : '' } - onClick={() => setActiveTab('collections')} + onClick={() => selectTab('collections')} > {t('devices.collections_tab')} diff --git a/resources/js/pages/devices/index.tsx b/resources/js/pages/devices/index.tsx index a5c3652..7d01719 100644 --- a/resources/js/pages/devices/index.tsx +++ b/resources/js/pages/devices/index.tsx @@ -286,7 +286,20 @@ export default function DevicesIndex({ href={`/devices/${device.id}/configuration`} > {t( - 'devices.history', + 'devices.configurations', + )} + + + diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index 2b8d981..b277015 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -12,6 +12,8 @@ export NETKEEP_HTTP_PORT="${NETKEEP_E2E_HTTP_PORT:-18081}" export NETKEEP_HTTPS_PORT="${NETKEEP_E2E_HTTPS_PORT:-18444}" export NETKEEP_E2E_BOOTSTRAP_URL="http://127.0.0.1:${NETKEEP_HTTP_PORT}" export NETKEEP_E2E_BASE_URL="https://127.0.0.1:${NETKEEP_HTTPS_PORT}" +export NETKEEP_E2E_DEVICE_IP="${NETKEEP_E2E_DEVICE_IP:-10.254.250.10}" +export NETKEEP_E2E_DEVICE_SUBNET="${NETKEEP_E2E_DEVICE_SUBNET:-10.254.250.0/24}" export NETKEEP_IMAGE="${NETKEEP_E2E_IMAGE:-netkeep:e2e}" export NETKEEP_OXIDIZED_IMAGE="${NETKEEP_E2E_OXIDIZED_IMAGE:-netkeep-oxidized:e2e}" export NETKEEP_UPDATER_IMAGE="${NETKEEP_E2E_UPDATER_IMAGE:-netkeep-updater:e2e}" @@ -134,24 +136,26 @@ test -z "$sandbox_clean" "${compose[@]}" exec --no-TTY sandbox \ sh -c '! grep -R -a -q "NETKEEP-E2E" /home/oxidized/.config/oxidized 2>/dev/null' -"${compose[@]}" exec --no-TTY app php artisan netkeep:dispatch-collections - collection_status='' for _ in $(seq 1 45); do - if "${compose[@]}" exec --no-TTY app php artisan netkeep:reconcile-backups; then - collection_status="$( - "${compose[@]}" exec --no-TTY postgres \ - psql -U netkeep_admin -d netkeep -tAc \ - "SELECT status FROM collection_runs ORDER BY id DESC LIMIT 1" - )" - if [[ "$collection_status" == 'succeeded' ]]; then - break - fi + collection_status="$( + "${compose[@]}" exec --no-TTY postgres \ + psql -U netkeep_admin -d netkeep -tAc \ + "SELECT status FROM collection_runs ORDER BY id DESC LIMIT 1" + )" + if [[ "$collection_status" == 'succeeded' ]]; then + break fi sleep 2 done test "$collection_status" = 'succeeded' +collection_latency="$( + "${compose[@]}" exec --no-TTY postgres \ + psql -U netkeep_admin -d netkeep -tAc \ + "SELECT GREATEST(EXTRACT(EPOCH FROM (started_at - created_at)), EXTRACT(EPOCH FROM (finished_at - started_at)))::int FROM collection_runs ORDER BY id DESC LIMIT 1" +)" +test "$collection_latency" -lt 30 device_uuid="$( "${compose[@]}" exec --no-TTY postgres \ diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 01d7d0f..6598d0f 100644 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -18,7 +18,7 @@ UPDATER_VERSION="$(sed -n 's#.*docker.io/lrqnet/netkeep-updater:\([0-9][0-9.]*\) test "${APP_VERSION}" = "${VERSION}" test "${UPDATER_VERSION}" = "${VERSION}" -test "$(grep -Fc 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r3' compose.yaml)" -eq 2 +test "$(grep -Fc 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r4' compose.yaml)" -eq 2 grep -Fq "## [${VERSION}] - " CHANGELOG.md grep -Fq "releases/tag/${TAG}" README.md grep -Fq "releases/download/${TAG}/compose.yaml" README.md diff --git a/tests/E2E/installation.setup.ts b/tests/E2E/installation.setup.ts index 93fcf94..f359b44 100644 --- a/tests/E2E/installation.setup.ts +++ b/tests/E2E/installation.setup.ts @@ -93,7 +93,9 @@ setup('installs NetKeep and creates the first owner', async ({ page }) => { await gotoWithTlsRetry(page, '/devices'); await page.getByLabel('Name', { exact: true }).fill('E2E Router'); - await page.getByLabel('IP address').fill('172.31.250.10'); + await page + .getByLabel('IP address') + .fill(process.env.NETKEEP_E2E_DEVICE_IP ?? '10.254.250.10'); await page .getByRole('spinbutton', { name: 'Port', exact: true }) .fill('2222'); diff --git a/tests/Feature/NetKeep/CollectionControlTest.php b/tests/Feature/NetKeep/CollectionControlTest.php index 0c93ec9..273f6d4 100644 --- a/tests/Feature/NetKeep/CollectionControlTest.php +++ b/tests/Feature/NetKeep/CollectionControlTest.php @@ -7,11 +7,15 @@ use App\Enums\DeviceApprovalStatus; use App\Enums\DeviceStatus; use App\Enums\RiskLevel; +use App\Enums\UserRole; +use App\Exceptions\GitRepositoryUnavailable; +use App\Jobs\DispatchCollections; use App\Models\BackupRun; use App\Models\CollectionRun; use App\Models\Device; use App\Models\Organization; use App\Models\Site; +use App\Models\User; use App\Services\BackupReconciler; use App\Services\CollectionOrchestrator; use App\Services\CollectionRequestService; @@ -51,6 +55,24 @@ public function test_manual_collection_respects_the_five_minute_cooldown(): void app(CollectionRequestService::class)->request($device, CollectionTrigger::Manual); } + public function test_manual_http_request_kicks_the_dispatcher_without_waiting_for_the_scheduler(): void + { + Queue::fake(); + $device = $this->approvedDevice('198.51.100.62'); + $user = User::factory()->create(['role' => UserRole::Operator]); + + $this->actingAs($user) + ->post(route('devices.collect', $device)) + ->assertRedirect(); + + Queue::assertPushed(DispatchCollections::class); + $this->assertDatabaseHas('collection_runs', [ + 'device_id' => $device->id, + 'trigger' => CollectionTrigger::Manual->value, + 'status' => CollectionRunStatus::Queued->value, + ]); + } + public function test_failed_collections_retry_after_one_five_and_fifteen_minutes(): void { $device = $this->approvedDevice('198.51.100.33'); @@ -160,6 +182,70 @@ public function test_success_without_a_git_change_completes_the_collection(): vo ]); } + public function test_first_success_without_a_git_version_fails_closed(): void + { + $device = $this->approvedDevice('198.51.100.63'); + $run = CollectionRun::query()->create([ + 'device_id' => $device->id, + 'trigger' => CollectionTrigger::Manual, + 'status' => CollectionRunStatus::Running, + 'attempt' => 1, + 'scheduled_for' => now()->subMinute(), + 'started_at' => now()->subSeconds(10), + ]); + $history = Mockery::mock(GitHistory::class); + $history->shouldReceive('versions')->once()->andReturn([]); + $oxidized = Mockery::mock(OxidizedClient::class); + $oxidized->shouldReceive('nodes')->once()->andReturn([[ + 'name' => $device->uuid, + 'last' => ['status' => 'success', 'end' => now()->toIso8601String()], + ]]); + + app(BackupReconciler::class)->reconcile($history, $oxidized); + + $this->assertSame(CollectionRunStatus::Failed, $run->refresh()->status); + $this->assertSame('configuration_not_persisted', $run->error_code); + $this->assertSame(DeviceStatus::Failing, $device->refresh()->status); + $this->assertDatabaseMissing('backup_runs', ['collection_run_id' => $run->id]); + $this->assertDatabaseHas('collection_run_events', [ + 'collection_run_id' => $run->id, + 'code' => 'failure', + ]); + } + + public function test_unavailable_git_history_fails_without_exposing_process_details(): void + { + $device = $this->approvedDevice('198.51.100.64'); + $run = CollectionRun::query()->create([ + 'device_id' => $device->id, + 'trigger' => CollectionTrigger::Manual, + 'status' => CollectionRunStatus::Running, + 'attempt' => 1, + 'scheduled_for' => now()->subMinute(), + 'started_at' => now()->subSeconds(10), + ]); + $history = Mockery::mock(GitHistory::class); + $history->shouldReceive('versions') + ->once() + ->andThrow(new GitRepositoryUnavailable); + $oxidized = Mockery::mock(OxidizedClient::class); + $oxidized->shouldReceive('nodes')->once()->andReturn([[ + 'name' => $device->uuid, + 'last' => ['status' => 'success', 'end' => now()->toIso8601String()], + ]]); + + app(BackupReconciler::class)->reconcile($history, $oxidized); + + $this->assertSame(CollectionRunStatus::Failed, $run->refresh()->status); + $this->assertSame('configuration_history_unavailable', $run->error_code); + $event = $run->events()->where('code', 'failure')->firstOrFail(); + $this->assertNull($event->technical_message); + $this->assertSame( + ['error_code' => 'configuration_history_unavailable'], + $event->context, + ); + } + public function test_collection_risk_boundaries_match_safe_mode_policy(): void { $risks = app(CollectionRiskService::class); diff --git a/tests/Feature/NetKeep/CollectionDiagnosticsTest.php b/tests/Feature/NetKeep/CollectionDiagnosticsTest.php index a39b453..9ef274e 100644 --- a/tests/Feature/NetKeep/CollectionDiagnosticsTest.php +++ b/tests/Feature/NetKeep/CollectionDiagnosticsTest.php @@ -7,6 +7,7 @@ use App\Enums\DeviceApprovalStatus; use App\Enums\DeviceStatus; use App\Enums\UserRole; +use App\Jobs\ReconcileCollectionRun; use App\Jobs\RunDeviceDiagnostic; use App\Models\CollectionRun; use App\Models\Device; @@ -119,6 +120,37 @@ public function test_reporter_event_is_idempotent_and_node_fail_ends_the_active_ $this->assertStringNotContainsString('fictional-password', (string) $run->events()->value('technical_message')); } + public function test_post_store_queues_immediate_git_reconciliation(): void + { + Queue::fake(); + $device = $this->approvedDevice(); + $run = $this->collectionRun($device, CollectionRunStatus::Running); + $payload = [ + 'event_id' => 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff', + 'occurred_at' => now()->toIso8601String(), + 'event' => 'post_store', + 'node_name' => $device->uuid, + 'node_group' => 'default', + 'node_model' => 'IOS', + 'job_status' => 'success', + 'job_time' => '1.2', + ]; + + $this->withHeader('Host', 'app') + ->withHeader('X-NetKeep-Token', 'internal-reporter-token') + ->postJson(route('internal.oxidized.events'), $payload) + ->assertAccepted(); + + Queue::assertPushed( + ReconcileCollectionRun::class, + fn (ReconcileCollectionRun $job): bool => $job->runId === $run->id, + ); + $this->assertDatabaseHas('collection_run_events', [ + 'collection_run_id' => $run->id, + 'code' => 'configuration_stored', + ]); + } + public function test_reporter_endpoints_reject_invalid_host_token_unknown_device_and_large_payload(): void { $payload = [ diff --git a/tests/Feature/NetKeep/DeviceApprovalTest.php b/tests/Feature/NetKeep/DeviceApprovalTest.php new file mode 100644 index 0000000..6c2e05a --- /dev/null +++ b/tests/Feature/NetKeep/DeviceApprovalTest.php @@ -0,0 +1,172 @@ +create([ + 'role' => UserRole::Owner, + 'locale' => 'en', + ]); + $device = $this->pendingDevice(); + $safeMessage = Lang::get('netkeep.devices.ssh_host_key_unavailable', locale: 'en'); + + $this->mock(NetworkTargetGuard::class) + ->shouldReceive('resolve') + ->once() + ->with('198.51.100.20') + ->andReturn(['198.51.100.20']); + $this->mock(SshHostKeyScanner::class) + ->shouldReceive('scan') + ->once() + ->with('198.51.100.20', 22) + ->andThrow(new RuntimeException('ssh_host_key_unavailable')); + $this->mock(KnownHostsWriter::class) + ->shouldNotReceive('write'); + $this->mock(OxidizedClient::class) + ->shouldNotReceive('reload'); + + $response = $this->actingAs($owner) + ->withSession(['auth.password_confirmed_at' => time()]) + ->post(route('devices.approve', $device)); + + $response + ->assertRedirect() + ->assertSessionHas('error', $safeMessage); + $this->assertStringNotContainsString('ssh_host_key_unavailable', $safeMessage); + $this->assertStringNotContainsString('198.51.100.20', $safeMessage); + + $device->refresh(); + $this->assertFalse($device->enabled); + $this->assertSame(DeviceStatus::Pending, $device->status); + $this->assertSame(DeviceApprovalStatus::Pending, $device->approval_status); + $this->assertNull($device->approval_fingerprint); + $this->assertNull($device->approved_by); + $this->assertNull($device->approved_at); + $this->assertNull($device->ssh_host_key); + $this->assertNull($device->ssh_host_key_fingerprint); + + $audit = AuditEvent::query() + ->where('action', 'device.approval_failed') + ->where('subject_id', $device->id) + ->firstOrFail(); + $this->assertSame([ + 'reason' => 'ssh_host_key_unavailable', + 'transport' => 'ssh', + 'port' => 22, + ], $audit->metadata); + $this->assertStringNotContainsString('198.51.100.20', json_encode($audit->metadata, JSON_THROW_ON_ERROR)); + $this->assertDatabaseMissing('audit_events', [ + 'action' => 'device.approved', + 'subject_id' => $device->id, + ]); + } + + public function test_valid_ssh_host_key_still_approves_the_device(): void + { + $owner = User::factory()->create([ + 'role' => UserRole::Owner, + 'locale' => 'en', + ]); + $device = $this->pendingDevice(); + $hostKey = '198.51.100.20 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFictional'; + $fingerprint = 'SHA256:fictional'; + + $this->mock(NetworkTargetGuard::class) + ->shouldReceive('resolve') + ->once() + ->with('198.51.100.20') + ->andReturn(['198.51.100.20']); + $this->mock(SshHostKeyScanner::class) + ->shouldReceive('scan') + ->once() + ->with('198.51.100.20', 22) + ->andReturn([ + 'keys' => $hostKey, + 'fingerprint' => $fingerprint, + ]); + $this->mock(KnownHostsWriter::class) + ->shouldReceive('write') + ->once(); + $this->mock(OxidizedClient::class) + ->shouldReceive('reload') + ->once() + ->andReturnTrue(); + + $this->actingAs($owner) + ->withSession(['auth.password_confirmed_at' => time()]) + ->post(route('devices.approve', $device)) + ->assertRedirect() + ->assertSessionHas('success', Lang::get('netkeep.devices.approved', locale: 'en')); + + $device->refresh(); + $this->assertTrue($device->enabled); + $this->assertSame(DeviceStatus::Pending, $device->status); + $this->assertSame(DeviceApprovalStatus::Approved, $device->approval_status); + $this->assertSame($owner->id, $device->approved_by); + $this->assertNotNull($device->approved_at); + $this->assertSame(['198.51.100.20'], $device->approved_resolved_addresses); + $this->assertSame($hostKey, $device->ssh_host_key); + $this->assertSame($fingerprint, $device->ssh_host_key_fingerprint); + + $this->assertDatabaseHas('audit_events', [ + 'action' => 'device.approved', + 'subject_id' => $device->id, + ]); + $this->assertDatabaseMissing('audit_events', [ + 'action' => 'device.approval_failed', + 'subject_id' => $device->id, + ]); + } + + public function test_ssh_host_key_error_has_complete_backend_translations(): void + { + $messages = [ + 'en' => 'The SSH host key could not be obtained. Check that NetKeep can reach the device and that SSH is listening on the configured port.', + 'pt_BR' => 'Não foi possível obter a chave do host SSH. Verifique se o NetKeep alcança o equipamento e se o SSH está ativo na porta configurada.', + 'es' => 'No se pudo obtener la clave del host SSH. Verifica que NetKeep pueda alcanzar el equipo y que SSH esté activo en el puerto configurado.', + ]; + + foreach ($messages as $locale => $message) { + $this->assertSame( + $message, + Lang::get('netkeep.devices.ssh_host_key_unavailable', locale: $locale), + ); + } + } + + private function pendingDevice(): Device + { + return Device::query()->create([ + 'name' => 'edge-approval-test', + 'ip_address' => '198.51.100.20', + 'port' => 22, + 'transport' => 'ssh', + 'oxidized_model' => 'ios', + 'backup_interval' => 3600, + 'timeout' => 20, + 'enabled' => false, + 'status' => DeviceStatus::Pending, + 'approval_status' => DeviceApprovalStatus::Pending, + ]); + } +} diff --git a/tests/Feature/NetKeep/GitHistoryTest.php b/tests/Feature/NetKeep/GitHistoryTest.php index f485758..4976c13 100644 --- a/tests/Feature/NetKeep/GitHistoryTest.php +++ b/tests/Feature/NetKeep/GitHistoryTest.php @@ -2,11 +2,15 @@ namespace Tests\Feature\NetKeep; +use App\Enums\UserRole; +use App\Exceptions\GitRepositoryUnavailable; use App\Models\Device; use App\Models\DeviceGroup; +use App\Models\User; use App\Services\GitHistory; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; +use Inertia\Testing\AssertableInertia; use Symfony\Component\Process\Process; use Tests\TestCase; @@ -50,6 +54,57 @@ public function test_it_lists_downloads_and_diffs_committed_configurations(): vo $this->assertStringContainsString('+version 2', $diff); } + public function test_unavailable_repository_is_explicit_and_the_page_remains_safe(): void + { + $repository = storage_path('framework/missing-git-history'); + File::deleteDirectory($repository); + config(['netkeep.oxidized.git_path' => $repository]); + $device = Device::query()->create([ + 'name' => 'router-02', + 'ip_address' => '198.51.100.20', + 'oxidized_model' => 'ios', + ]); + $user = User::factory()->create(['role' => UserRole::Owner]); + + $this->actingAs($user) + ->get(route('configurations.show', $device)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('configurations/show') + ->where('historyUnavailable', true) + ->has('versions', 0) + ->where('content', '')); + $this->actingAs($user) + ->get(route('configurations.download', $device)) + ->assertStatus(503) + ->assertSeeText(__('netkeep.config.history_unavailable')); + + try { + app(GitHistory::class)->versions($device); + $this->fail('The unavailable repository should be explicit.'); + } catch (GitRepositoryUnavailable $exception) { + $this->assertSame('git_repository_unavailable', $exception->getMessage()); + $this->assertStringNotContainsString($repository, $exception->getMessage()); + } + } + + public function test_collection_tab_can_be_opened_directly(): void + { + $device = Device::query()->create([ + 'name' => 'router-03', + 'ip_address' => '203.0.113.20', + 'oxidized_model' => 'ios', + ]); + $user = User::factory()->create(['role' => UserRole::Viewer]); + + $this->actingAs($user) + ->get(route('devices.edit', $device).'?tab=collections') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('devices/edit') + ->where('initialTab', 'collections')); + } + /** * @param array $arguments */ diff --git a/tests/Feature/NetKeep/GitMirrorServiceTest.php b/tests/Feature/NetKeep/GitMirrorServiceTest.php index fe86c84..18b8d7d 100644 --- a/tests/Feature/NetKeep/GitMirrorServiceTest.php +++ b/tests/Feature/NetKeep/GitMirrorServiceTest.php @@ -51,11 +51,14 @@ public function test_token_authentication_never_places_the_secret_in_process_arg try { app(GitMirrorService::class)->mirror($destination); - $this->assertStringNotContainsString('github-secret-token', File::get($arguments)); + $capturedArguments = File::get($arguments); + $this->assertStringNotContainsString('github-secret-token', $capturedArguments); $this->assertStringContainsString( 'https://198.51.100.10/private/netkeep.git', - File::get($arguments), + $capturedArguments, ); + $this->assertStringContainsString("safe.directory={$repository}", $capturedArguments); + $this->assertStringNotContainsString('safe.directory=*', $capturedArguments); $this->assertSame( 'Authorization: Basic '.base64_encode('oauth2:github-secret-token'), File::get($header), From 2561c193aef75165ae616c19c215c69c2d1fc864 Mon Sep 17 00:00:00 2001 From: Lucas Quaresma <15186323+lrqnet@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:50:23 -0500 Subject: [PATCH 2/2] Align v1.0.7 release contract test --- tests/Unit/ContainerRuntimeTest.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Unit/ContainerRuntimeTest.php b/tests/Unit/ContainerRuntimeTest.php index 590a516..bbdd6e0 100644 --- a/tests/Unit/ContainerRuntimeTest.php +++ b/tests/Unit/ContainerRuntimeTest.php @@ -171,10 +171,11 @@ public function test_release_versions_match_the_compose_and_oxidized_revision(): $this->assertIsString($compose); $this->assertIsString($preflight); $this->assertIsString($workflow); - $this->assertStringContainsString('docker.io/lrqnet/netkeep:1.0.6', $compose); - $this->assertStringContainsString('docker.io/lrqnet/netkeep-updater:1.0.6', $compose); - $this->assertSame(2, substr_count($compose, 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r3')); - $this->assertStringContainsString('tags: type=raw,value=0.37.0-r3', $workflow); + $this->assertStringContainsString('docker.io/lrqnet/netkeep:1.0.7', $compose); + $this->assertStringContainsString('docker.io/lrqnet/netkeep-updater:1.0.7', $compose); + $this->assertSame(2, substr_count($compose, 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r4')); + $this->assertStringContainsString('tags: type=raw,value=0.37.0-r4', $workflow); + $this->assertStringContainsString('docker.io/lrqnet/netkeep-oxidized:0.37.0-r4', $preflight); $this->assertSame(3, substr_count($workflow, 'needs: preflight')); $this->assertStringContainsString('sh scripts/release-preflight.sh', $workflow); $this->assertStringContainsString('Reject previously published immutable tags', $workflow); @@ -194,7 +195,7 @@ public function test_release_versions_match_the_compose_and_oxidized_revision(): $preflight, ); $this->assertStringContainsString( - 's#docker.io/lrqnet/netkeep-oxidized:0.37.0-r3#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g', + 's#docker.io/lrqnet/netkeep-oxidized:0.37.0-r4#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g', $workflow, ); }