Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-34d399" alt="AGPL-3.0 license"></a>
<a href="https://github.com/lrqnet/NetKeep/releases/tag/v1.0.6"><img src="https://img.shields.io/badge/release-v1.0.6-0f172a" alt="NetKeep v1.0.6"></a>
<a href="https://github.com/lrqnet/NetKeep/releases/tag/v1.0.7"><img src="https://img.shields.io/badge/release-v1.0.7-0f172a" alt="NetKeep v1.0.7"></a>
<a href="https://hub.docker.com/r/lrqnet/netkeep"><img src="https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ed?logo=docker&logoColor=white" alt="Docker images for amd64 and arm64"></a>
<a href="https://github.com/ytti/oxidized"><img src="https://img.shields.io/badge/Oxidized-0.37.0-475569" alt="Oxidized 0.37.0"></a>
</p>
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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**.
Expand Down
25 changes: 14 additions & 11 deletions app/Console/Commands/MigrateGitIdentityCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
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
{
protected $signature = 'netkeep:migrate-git-identity';

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')) {
Expand All @@ -24,27 +24,27 @@ 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;
}
}
}, '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}.");
Expand Down Expand Up @@ -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<string> $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();
}
Expand Down
13 changes: 13 additions & 0 deletions app/Exceptions/GitRepositoryUnavailable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Exceptions;

use RuntimeException;

class GitRepositoryUnavailable extends RuntimeException
{
public function __construct(?\Throwable $previous = null)
{
parent::__construct('git_repository_unavailable', previous: $previous);
}
}
47 changes: 42 additions & 5 deletions app/Http/Controllers/ConfigurationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace App\Http\Controllers;

use App\Exceptions\GitRepositoryUnavailable;
use App\Models\Device;
use App\Services\AuditLogger;
use App\Services\GitHistory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
Expand All @@ -15,23 +17,40 @@ class ConfigurationController extends Controller
{
public function show(Device $device, GitHistory $history): Response
{
$unavailable = false;
try {
$versions = $history->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',
]);
}
Expand All @@ -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',
]);
}
}
18 changes: 17 additions & 1 deletion app/Http/Controllers/DeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
]);
}

Expand Down Expand Up @@ -190,6 +194,7 @@ public function collect(
'collection_run_uuid' => $run->uuid,
'force' => $force,
]);
DispatchCollections::dispatch();

return back()->with('success', __('netkeep.devices.collection_queued'));
}
Expand All @@ -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'));
}
Expand Down Expand Up @@ -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'];
}
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Controllers/Internal/OxidizedEventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions app/Jobs/DispatchCollections.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Jobs;

use App\Services\CollectionOrchestrator;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class DispatchCollections implements ShouldBeUnique, ShouldQueue
{
use Queueable;

public int $tries = 1;

public int $timeout = 30;

public int $uniqueFor = 30;

public function handle(CollectionOrchestrator $orchestrator): void
{
$orchestrator->tick();
}
}
Loading
Loading