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 @@
-
+
@@ -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({