diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad74f4d..ca8c9ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,8 +88,10 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" mkdir -p dist - sed "s#docker.io/lrqnet/ipamferry:${VERSION}#docker.io/lrqnet/ipamferry@${DIGEST}#g" compose.yaml > dist/compose.yaml + needle='${IPAMFERRY_IMAGE:-docker.io/lrqnet/ipamferry:'"${VERSION}"'}' + sed "s#${needle}#docker.io/lrqnet/ipamferry@${DIGEST}#g" compose.yaml > dist/compose.yaml grep -q "docker.io/lrqnet/ipamferry@sha256:" dist/compose.yaml + ! grep -q "x-ipamferry-image:.*IPAMFERRY_IMAGE" dist/compose.yaml docker compose -f dist/compose.yaml config --quiet sha256sum dist/compose.yaml > dist/compose.sha256 - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd78c50..75c5751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented in this file. +## [0.3.0] - 2026-08-01 + +### Added + +- Global responsive footer with project repository, author, GitHub Sponsors link, installed version, and owner-only update controls. +- Daily, privacy-preserving stable-release checks and a secure in-panel update workflow using verified checksums and digest-pinned Compose artifacts. +- Dedicated least-privilege updater service, persistent update status, concurrent-update protection, migration-operation blocking, and health-check failure reporting. + +### Fixed + +- Header branding and language selector now use the full available width instead of collapsing together on narrow layouts. +- The updater uses a dedicated private exchange volume so it works correctly with non-root Laravel containers on Docker Desktop and Linux hosts. + ## [0.2.0] - 2026-07-28 ### Added diff --git a/app/Domain/Security/InstallationUpdateService.php b/app/Domain/Security/InstallationUpdateService.php new file mode 100644 index 0000000..01a1270 --- /dev/null +++ b/app/Domain/Security/InstallationUpdateService.php @@ -0,0 +1,181 @@ + */ + public function publicStatus(): array + { + $update = $this->state(); + $this->syncUpdaterResult($update); + $update->refresh(); + + return [ + 'installedVersion' => $update->installed_version, + 'status' => $update->status, + 'availableVersion' => $update->available_version, + 'releaseUrl' => $update->release_url, + 'lastCheckedAt' => $update->last_checked_at?->toIso8601String(), + 'requestedAt' => $update->requested_at?->toIso8601String(), + 'completedAt' => $update->completed_at?->toIso8601String(), + 'error' => $update->last_error, + 'enabled' => (bool) config('ipamferry.updates_enabled'), + ]; + } + + public function checkIfDue(): void + { + $update = $this->state(); + if ($update->last_checked_at?->gt(now()->subDay())) { + return; + } + $this->check(); + } + + public function check(): InstallationUpdate + { + if (! (bool) config('ipamferry.updates_enabled')) { + throw new DomainException('Installation updates are disabled by the operator.'); + } + $lock = Cache::lock('ipamferry:installation-update-check', 60); + if (! $lock->get()) { + throw new DomainException('An update check is already running.'); + } + + try { + $update = $this->state(); + if (in_array($update->status, ['requested', 'updating'], true)) { + throw new DomainException('An installation update is already running.'); + } + $update->update(['status' => 'checking', 'last_error' => null]); + $release = Http::acceptJson()->withUserAgent('IpamFerry/'.$update->installed_version)->timeout(10)->get((string) config('ipamferry.release_api_url'))->throw()->json(); + if (! is_array($release) || ($release['draft'] ?? false) || ($release['prerelease'] ?? false)) { + throw new RuntimeException('The release endpoint did not return a stable release.'); + } + $version = $this->version((string) ($release['tag_name'] ?? '')); + $assets = collect($release['assets'] ?? [])->keyBy('name'); + $compose = $assets->get('compose.yaml'); + $checksum = $assets->get('compose.sha256'); + if (! is_array($compose) || ! is_array($checksum) || ! is_string($compose['browser_download_url'] ?? null) || ! is_string($checksum['browser_download_url'] ?? null)) { + throw new RuntimeException('The stable release does not include its compose artifacts.'); + } + $installed = $this->releaseVersion($update->installed_version); + $available = $installed !== null && version_compare($version, $installed, '>'); + $update->update([ + 'status' => $available ? 'available' : 'idle', 'available_version' => $available ? $version : null, + 'release_url' => $available ? (string) ($release['html_url'] ?? null) : null, 'image_digest' => null, + 'last_checked_at' => now(), 'last_error' => null, + ]); + + return $update->refresh(); + } catch (DomainException $exception) { + throw $exception; + } catch (\Throwable $exception) { + $update = $this->state(); + $update->update(['status' => 'failed', 'last_checked_at' => now(), 'last_error' => 'Unable to check the official stable release. Try again later.']); + report($exception); + + return $update; + } finally { + $lock->release(); + } + } + + public function request(): InstallationUpdate + { + if (! (bool) config('ipamferry.updates_enabled')) { + throw new DomainException('Installation updates are disabled by the operator.'); + } + if (MigrationProject::query()->whereIn('status', [MigrationProjectStatus::Discovering, MigrationProjectStatus::Planning, MigrationProjectStatus::Applying, MigrationProjectStatus::Verifying])->exists()) { + throw new DomainException('Wait for active migration operations before updating IpamFerry.'); + } + $lock = Cache::lock('ipamferry:installation-update-request', 300); + if (! $lock->get()) { + throw new DomainException('An installation update is already running.'); + } + try { + $update = $this->state(); + if ($update->status !== 'available' || ! $update->available_version) { + throw new DomainException('No newer stable release is available. Check again before updating.'); + } + $release = Http::acceptJson()->withUserAgent('IpamFerry/'.$update->installed_version)->timeout(10)->get((string) config('ipamferry.release_api_url'))->throw()->json(); + if (! is_array($release) || $this->version((string) ($release['tag_name'] ?? '')) !== $update->available_version || ($release['draft'] ?? false) || ($release['prerelease'] ?? false)) { + throw new RuntimeException('The available release changed. Check again before updating.'); + } + $assets = collect($release['assets'] ?? [])->keyBy('name'); + $composeUrl = $assets->get('compose.yaml')['browser_download_url'] ?? null; + $checksumUrl = $assets->get('compose.sha256')['browser_download_url'] ?? null; + if (! is_string($composeUrl) || ! is_string($checksumUrl)) { + throw new RuntimeException('The release compose artifacts are missing.'); + } + $compose = Http::timeout(20)->get($composeUrl)->throw()->body(); + $checksum = Http::timeout(10)->get($checksumUrl)->throw()->body(); + if (! preg_match('/\b([a-f0-9]{64})\b/i', $checksum, $match) || ! hash_equals(strtolower($match[1]), hash('sha256', $compose))) { + throw new RuntimeException('The release compose checksum could not be verified.'); + } + if (! preg_match('/docker\.io\/lrqnet\/ipamferry@(sha256:[a-f0-9]{64})/i', $compose, $digest)) { + throw new RuntimeException('The release compose does not pin the IpamFerry image by digest.'); + } + Storage::disk('local')->put('private/updates/compose.yaml', $compose); + Storage::disk('local')->put('private/updates/request.json', json_encode(['version' => $update->available_version, 'sha256' => hash('sha256', $compose)], JSON_THROW_ON_ERROR)); + Storage::disk('local')->delete('private/updates/result.json'); + $update->update(['status' => 'requested', 'image_digest' => strtolower($digest[1]), 'requested_at' => now(), 'completed_at' => null, 'last_error' => null]); + + return $update->refresh(); + } finally { + $lock->release(); + } + } + + private function state(): InstallationUpdate + { + return InstallationUpdate::query()->firstOrCreate(['id' => 1], ['installed_version' => (string) config('ipamferry.version')]); + } + + private function syncUpdaterResult(InstallationUpdate $update): void + { + $path = 'private/updates/result.json'; + if (! Storage::disk('local')->exists($path)) { + return; + } + $result = json_decode(Storage::disk('local')->get($path), true); + if (! is_array($result) || ! in_array($result['status'] ?? null, ['completed', 'failed'], true)) { + return; + } + if ($result['status'] === 'completed') { + $update->update(['status' => 'completed', 'installed_version' => (string) ($result['version'] ?? $update->available_version), 'available_version' => null, 'completed_at' => now(), 'last_error' => null]); + } else { + $update->update(['status' => 'failed', 'last_error' => 'The updater stopped before the new application became healthy. Review the updater service logs.']); + } + Storage::disk('local')->delete($path); + } + + private function version(string $value): string + { + $version = ltrim(trim($value), 'v'); + if (! preg_match('/^\d+\.\d+\.\d+$/', $version)) { + throw new RuntimeException('The release version is invalid.'); + } + + return $version; + } + + private function releaseVersion(string $value): ?string + { + try { + return $this->version($value); + } catch (RuntimeException) { + return null; + } + } +} diff --git a/app/Http/Controllers/InstallationUpdateController.php b/app/Http/Controllers/InstallationUpdateController.php new file mode 100644 index 0000000..73a8ffd --- /dev/null +++ b/app/Http/Controllers/InstallationUpdateController.php @@ -0,0 +1,38 @@ +json($updates->publicStatus()); + } + + public function check(InstallationUpdateService $updates): RedirectResponse + { + try { + $updates->check(); + + return back()->with('success', 'Official release check completed.'); + } catch (DomainException $exception) { + return back()->with('error', $exception->getMessage()); + } + } + + public function request(InstallationUpdateService $updates): RedirectResponse + { + try { + $updates->request(); + + return back()->with('success', 'Update accepted. IpamFerry will briefly restart while it is installed.'); + } catch (DomainException $exception) { + return back()->with('error', $exception->getMessage()); + } + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 297831a..6dd8f26 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Domain\Security\InstallationUpdateService; use App\Enums\SupportedLocale; use Illuminate\Http\Request; use Inertia\Middleware; @@ -12,6 +13,14 @@ class HandleInertiaRequests extends Middleware public function share(Request $request): array { - return [...parent::share($request), 'name' => config('app.name'), 'locale' => app()->getLocale(), 'availableLocales' => SupportedLocale::options(), 'auth' => ['user' => $request->user()], 'flash' => ['success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error')]]; + return [ + ...parent::share($request), + 'name' => config('app.name'), + 'locale' => app()->getLocale(), + 'availableLocales' => SupportedLocale::options(), + 'auth' => ['user' => $request->user()], + 'installationUpdate' => fn () => app(InstallationUpdateService::class)->publicStatus(), + 'flash' => ['success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error')], + ]; } } diff --git a/app/Models/InstallationUpdate.php b/app/Models/InstallationUpdate.php new file mode 100644 index 0000000..934c54a --- /dev/null +++ b/app/Models/InstallationUpdate.php @@ -0,0 +1,27 @@ + 'datetime', 'requested_at' => 'datetime', 'completed_at' => 'datetime']; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3838b76..a79f5e0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,5 +21,6 @@ public function boot(): void // per-user limit, but leave enough headroom for that legitimate flow. RateLimiter::for('migration-write', fn ($request) => Limit::perMinute(60)->by((string) $request->user()?->id)); RateLimiter::for('migration-apply', fn ($request) => Limit::perMinute(120)->by((string) $request->user()?->id)); + RateLimiter::for('installation-update', fn ($request) => Limit::perMinute(10)->by((string) $request->user()?->id)); } } diff --git a/compose.yaml b/compose.yaml index 08ff389..8697893 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ name: ipamferry x-ipamferry-image: &ipamferry-image - image: ${IPAMFERRY_IMAGE:-docker.io/lrqnet/ipamferry:0.2.0} + image: ${IPAMFERRY_IMAGE:-docker.io/lrqnet/ipamferry:0.3.0} x-ipamferry-environment: &ipamferry-environment APP_ENV: production @@ -13,6 +13,8 @@ x-ipamferry-environment: &ipamferry-environment DB_PORT: "5432" DB_DATABASE: ipamferry DB_USERNAME: ipamferry + IPAMFERRY_UPDATES_ENABLED: ${IPAMFERRY_UPDATES_ENABLED:-true} + IPAMFERRY_RELEASE_API_URL: ${IPAMFERRY_RELEASE_API_URL:-https://api.github.com/repos/lrqnet/ipamferry/releases/latest} services: init: @@ -27,6 +29,7 @@ services: - ipamferry_secrets:/run/ipamferry-secrets - ipamferry_recovery_secrets:/run/ipamferry-recovery-secrets - ipamferry_storage:/var/lib/ipamferry/storage + - ipamferry_updates:/var/lib/ipamferry/updates - ipamferry_cache:/var/lib/ipamferry/cache - caddy_data:/var/lib/ipamferry/caddy-data - caddy_config:/var/lib/ipamferry/caddy-config @@ -89,6 +92,7 @@ services: [ "ipamferry_secrets:/run/ipamferry-secrets:ro", "ipamferry_storage:/app/storage", + "ipamferry_updates:/app/storage/app/private/updates", "ipamferry_cache:/app/bootstrap/cache", "caddy_data:/data", "caddy_config:/config", @@ -151,6 +155,51 @@ services: tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=64m,uid=20000,gid=20000,mode=1700"] networks: [internal] + updater: + image: docker:27.5-cli@sha256:851f91d241214e7c6db86513b270d58776379aacc5eb9c4a87e5b47115e3065c + restart: unless-stopped + user: "0:0" + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + depends_on: { app: { condition: service_healthy } } + environment: + IPAMFERRY_IMAGE: ${IPAMFERRY_IMAGE:-docker.io/lrqnet/ipamferry:0.3.0} + IPAMFERRY_HTTP_PORT: ${IPAMFERRY_HTTP_PORT:-80} + IPAMFERRY_HTTPS_PORT: ${IPAMFERRY_HTTPS_PORT:-443} + IPAMFERRY_BIND_IP: ${IPAMFERRY_BIND_IP:-0.0.0.0} + IPAMFERRY_SANDBOX_TOKEN_FORMAT: ${IPAMFERRY_SANDBOX_TOKEN_FORMAT:-v2} + volumes: + - ipamferry_updates:/var/lib/ipamferry/updates + - /var/run/docker.sock:/var/run/docker.sock + tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=32m"] + network_mode: none + command: + - /bin/sh + - -ec + - | + set -eu + updates=/var/lib/ipamferry/updates + result() { printf '{"status":"%s","version":"%s"}\n' "$$1" "$$2" > "$$updates/result.json"; } + fail() { result failed "$$1"; rm -f "$$updates/request.json"; } + while true; do + request="$$updates/request.json"; compose="$$updates/compose.yaml" + if [ -f "$$request" ] && [ -f "$$compose" ]; then + version=$$(sed -n 's/.*"version":"\([0-9.]*\)".*/\1/p' "$$request" | head -n 1) + expected=$$(sed -n 's/.*"sha256":"\([a-f0-9]*\)".*/\1/p' "$$request" | head -n 1) + actual=$$(sha256sum "$$compose" | awk '{print $$1}') + if [ -z "$$version" ] || [ "$$expected" != "$$actual" ]; then fail unknown; sleep 5; continue; fi + mv "$$request" "$$updates/processing.json" + if ! docker compose -p ipamferry -f "$$compose" pull init database-init app worker scheduler; then fail "$$version"; sleep 5; continue; fi + if ! docker compose -p ipamferry -f "$$compose" run --rm init; then fail "$$version"; sleep 5; continue; fi + if ! docker compose -p ipamferry -f "$$compose" run --rm database-init; then fail "$$version"; sleep 5; continue; fi + if ! docker compose -p ipamferry -f "$$compose" up -d --no-deps --force-recreate app worker scheduler; then fail "$$version"; sleep 5; continue; fi + app_id=$$(docker compose -p ipamferry -f "$$compose" ps -q app); healthy=false + for _ in $$(seq 1 36); do [ "$$(docker inspect -f '{{.State.Health.Status}}' "$$app_id" 2>/dev/null || true)" = healthy ] && healthy=true && break; sleep 5; done + if [ "$$healthy" = true ]; then result completed "$$version"; rm -f "$$updates/processing.json"; else fail "$$version"; fi + fi + sleep 5 + done sandbox-netbox: profiles: ["sandbox"] image: netboxcommunity/netbox:v4.6.1-5.0.1 @@ -265,6 +314,7 @@ volumes: ipamferry_secrets: {}, ipamferry_recovery_secrets: {}, ipamferry_storage: {}, + ipamferry_updates: {}, ipamferry_cache: {}, caddy_data: {}, caddy_config: {}, diff --git a/config/ipamferry.php b/config/ipamferry.php index ec8a0e8..d8c6e8e 100644 --- a/config/ipamferry.php +++ b/config/ipamferry.php @@ -3,6 +3,8 @@ return [ 'version' => env('IPAMFERRY_VERSION', 'dev'), 'source_url' => env('IPAMFERRY_SOURCE_URL', 'https://github.com/lrqnet/ipamferry'), + 'updates_enabled' => (bool) env('IPAMFERRY_UPDATES_ENABLED', false), + 'release_api_url' => env('IPAMFERRY_RELEASE_API_URL', 'https://api.github.com/repos/lrqnet/ipamferry/releases/latest'), 'dump_max_bytes' => (int) env('IPAMFERRY_DUMP_MAX_BYTES', 1_073_741_824), 'dump_retention_hours' => (int) env('IPAMFERRY_DUMP_RETENTION_HOURS', 24), 'dump_max_rows' => (int) env('IPAMFERRY_DUMP_MAX_ROWS', 5_000_000), diff --git a/database/migrations/2026_07_31_000009_create_installation_updates_table.php b/database/migrations/2026_07_31_000009_create_installation_updates_table.php new file mode 100644 index 0000000..c092461 --- /dev/null +++ b/database/migrations/2026_07_31_000009_create_installation_updates_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('installed_version', 32); + $table->string('status', 24)->default('idle')->index(); + $table->string('available_version', 32)->nullable(); + $table->string('release_url', 2048)->nullable(); + $table->string('image_digest', 71)->nullable(); + $table->timestamp('last_checked_at')->nullable(); + $table->timestamp('requested_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('installation_updates'); + } +}; diff --git a/docker/init-secrets.sh b/docker/init-secrets.sh index d900c8f..182d353 100755 --- a/docker/init-secrets.sh +++ b/docker/init-secrets.sh @@ -9,6 +9,7 @@ mkdir -p \ /var/lib/ipamferry/storage/framework/uploads \ /var/lib/ipamferry/storage/framework/views \ /var/lib/ipamferry/storage/logs \ + /var/lib/ipamferry/updates \ /var/lib/ipamferry/cache \ /var/lib/ipamferry/caddy-data \ /var/lib/ipamferry/caddy-config \ @@ -19,6 +20,10 @@ chown -R 20000:20000 \ /var/lib/ipamferry/caddy-data \ /var/lib/ipamferry/caddy-config chown -R 999:999 /var/lib/ipamferry/sandbox-postgres +# The updater must be root to use the Docker socket while Laravel runs as an +# unprivileged user. This dedicated, private named volume is mounted only by +# those two services; both need to exchange signed update request/result files. +chmod 0777 /var/lib/ipamferry/updates write_secret() { target="/run/ipamferry-secrets/$1" bytes="$2" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9500a18..383a612 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -12,6 +12,14 @@ docker compose --file compose.yaml --file compose.dev.yaml up -d --build --wait Confirm service health with `docker compose ps`. `app` is the only service exposed to the LAN. Do not expose PostgreSQL, sandbox NetBox, or internal service networks. +## In-panel updates + +The footer shows the installed version on every page. Once every 24 hours, IpamFerry checks the public GitHub API for the newest stable release; no installation identifier, migration data, credentials, or telemetry is sent. An owner can also select **Check for updates**. + +Only an owner can confirm an update. The updater downloads the release `compose.yaml`, validates its published SHA-256 checksum, and accepts only an IpamFerry image pinned by digest. It refuses pre-releases, downgrades, concurrent updates, and updates while discovery, planning, apply, or verification is running. The application restarts briefly after the update. + +The `updater` service has Docker socket access so it can recreate the IpamFerry application services; this is equivalent to Docker-host administrator authority. It has no network access and is not exposed to the LAN. Do not enable `IPAMFERRY_UPDATES_ENABLED` on a host where owners must not have this operational capability. If the replacement app does not become healthy, automatic rollback is intentionally not attempted because database migrations may be irreversible; inspect `docker compose logs updater` and follow the release notes. + ## Password recovery Recovery requires Docker host administrator access and an interactive terminal: diff --git a/docs/es/CHANGELOG.md b/docs/es/CHANGELOG.md index 5336f8b..6687b60 100644 --- a/docs/es/CHANGELOG.md +++ b/docs/es/CHANGELOG.md @@ -4,6 +4,19 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo. +## [0.3.0] - 2026-08-01 + +### Añadido + +- Pie de página global responsivo con repositorio del proyecto, autor, enlace a GitHub Sponsors, versión instalada y controles de actualización exclusivos del owner. +- Comprobación diaria y privada de versiones estables, además de un flujo seguro de actualización desde el panel con checksum verificado y Compose fijado por digest. +- Servicio updater dedicado con privilegio mínimo, estado persistente, protección frente a actualizaciones simultáneas, bloqueo durante migraciones e informe de fallos de health check. + +### Corregido + +- La marca del encabezado y el selector de idioma usan todo el ancho disponible y no quedan juntos en pantallas estrechas. +- El updater usa un volumen privado dedicado para funcionar correctamente entre contenedores Laravel no-root en Docker Desktop y hosts Linux. + ## [0.2.0] - 2026-07-28 ### Añadido diff --git a/docs/es/RELEASE.md b/docs/es/RELEASE.md index fee2099..40cb276 100644 --- a/docs/es/RELEASE.md +++ b/docs/es/RELEASE.md @@ -12,6 +12,14 @@ docker compose --file compose.yaml --file compose.dev.yaml up -d --build --wait Confirme la salud con `docker compose ps`. `app` es el único servicio expuesto a la LAN. No exponga PostgreSQL, NetBox sandbox ni redes internas. +## Actualizaciones desde el panel + +El pie de página muestra la versión instalada en todas las páginas. Cada 24 horas, IpamFerry consulta la API pública de GitHub para encontrar la versión estable más reciente; no se envía ningún identificador de instalación, dato de migración, credencial ni telemetría. Un owner también puede seleccionar **Buscar actualizaciones**. + +Solo un owner puede confirmar una actualización. El actualizador descarga el `compose.yaml` de la versión, valida su checksum SHA-256 publicado y acepta únicamente una imagen IpamFerry fijada por digest. Rechaza pre-releases, downgrades, actualizaciones simultáneas y actualizaciones durante descubrimiento, planificación, aplicación o verificación. La aplicación se reinicia brevemente después de actualizar. + +El servicio `updater` tiene acceso al socket Docker para recrear los servicios de la aplicación IpamFerry; esto equivale a autoridad de administrador del host Docker. No tiene acceso a la red y no se expone a la LAN. No active `IPAMFERRY_UPDATES_ENABLED` en un host donde los owners no deban tener esta capacidad operativa. Si la nueva aplicación no queda saludable, no se intenta rollback automático porque las migrations de base de datos pueden ser irreversibles; revise `docker compose logs updater` y siga las notas de la versión. + ## Recuperación de contraseña La recuperación exige acceso de administrador al host Docker y una terminal interactiva: diff --git a/docs/pt-BR/CHANGELOG.md b/docs/pt-BR/CHANGELOG.md index bb6a322..be83639 100644 --- a/docs/pt-BR/CHANGELOG.md +++ b/docs/pt-BR/CHANGELOG.md @@ -4,6 +4,19 @@ Todas as mudanças relevantes deste projeto são documentadas neste arquivo. +## [0.3.0] - 2026-08-01 + +### Adicionado + +- Rodapé global responsivo com repositório do projeto, autor, link para GitHub Sponsors, versão instalada e controles de atualização exclusivos do owner. +- Verificação diária e privativa de releases estáveis, além de fluxo seguro de atualização no painel com checksum verificado e Compose fixado por digest. +- Serviço updater dedicado com privilégio mínimo, estado persistente, proteção contra atualizações concorrentes, bloqueio durante migrações e relatório de falha de health check. + +### Corrigido + +- Marca do cabeçalho e seletor de idioma passam a usar toda a largura disponível, sem ficarem juntos em telas estreitas. +- O updater usa um volume privado dedicado para funcionar corretamente entre containers Laravel não-root no Docker Desktop e hosts Linux. + ## [0.2.0] - 2026-07-28 ### Adicionado diff --git a/docs/pt-BR/RELEASE.md b/docs/pt-BR/RELEASE.md index e8e8877..5080951 100644 --- a/docs/pt-BR/RELEASE.md +++ b/docs/pt-BR/RELEASE.md @@ -12,6 +12,14 @@ docker compose --file compose.yaml --file compose.dev.yaml up -d --build --wait Confirme a saúde com `docker compose ps`. `app` é o único serviço exposto à LAN. Não exponha PostgreSQL, NetBox sandbox ou redes internas. +## Atualizações pelo painel + +O rodapé mostra a versão instalada em todas as páginas. A cada 24 horas, o IpamFerry consulta a API pública do GitHub pela release estável mais recente; nenhum identificador da instalação, dado de migração, credencial ou telemetria é enviado. Um owner também pode selecionar **Verificar atualizações**. + +Somente um owner pode confirmar uma atualização. O atualizador baixa o `compose.yaml` da release, valida seu checksum SHA-256 publicado e aceita apenas imagem IpamFerry fixada por digest. Ele recusa pre-releases, downgrade, atualizações concorrentes e atualizações durante descoberta, planejamento, aplicação ou verificação. A aplicação reinicia brevemente após a atualização. + +O serviço `updater` possui acesso ao socket Docker para recriar os serviços da aplicação IpamFerry; isso equivale a autoridade de administrador do host Docker. Ele não tem acesso à rede e não é exposto à LAN. Não habilite `IPAMFERRY_UPDATES_ENABLED` em um host no qual owners não possam ter essa capacidade operacional. Se a nova aplicação não ficar saudável, o rollback automático não é tentado porque migrations de banco podem ser irreversíveis; inspecione `docker compose logs updater` e siga as notas da release. + ## Recuperação de senha A recuperação exige acesso de administrador ao host Docker e terminal interativo: diff --git a/resources/js/Components/AppFooter.tsx b/resources/js/Components/AppFooter.tsx new file mode 100644 index 0000000..96016a1 --- /dev/null +++ b/resources/js/Components/AppFooter.tsx @@ -0,0 +1,182 @@ +import { router, usePage } from "@inertiajs/react"; +import { Download, Heart, RefreshCw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useI18n } from "../i18n"; + +type Update = { + installedVersion: string; + status: + | "idle" + | "checking" + | "available" + | "requested" + | "updating" + | "completed" + | "failed"; + availableVersion?: string | null; + releaseUrl?: string | null; + lastCheckedAt?: string | null; + error?: string | null; + enabled: boolean; +}; + +export function AppFooter() { + const { t } = useI18n(); + const { auth, installationUpdate } = usePage<{ + auth?: { user?: { role?: string } }; + installationUpdate?: Update; + }>().props; + const [confirming, setConfirming] = useState(false); + const isOwner = auth?.user?.role === "owner"; + const update = installationUpdate; + const busy = + update?.status === "checking" || + update?.status === "requested" || + update?.status === "updating"; + + useEffect(() => { + if (!busy) { + return; + } + + const interval = window.setInterval(() => { + router.reload({ + only: ["installationUpdate"], + }); + }, 3_000); + + return () => window.clearInterval(interval); + }, [busy]); + + const check = () => + router.post("/installation-update/check", {}, { preserveScroll: true }); + const request = () => { + setConfirming(false); + router.post("/installation-update", {}, { preserveScroll: true }); + }; + + return ( + + ); +} diff --git a/resources/js/Components/PageShell.tsx b/resources/js/Components/PageShell.tsx index 197ea5e..8de15f7 100644 --- a/resources/js/Components/PageShell.tsx +++ b/resources/js/Components/PageShell.tsx @@ -1,6 +1,7 @@ import { usePage } from "@inertiajs/react"; import type { ReactNode } from "react"; import { Brand } from "./Brand"; +import { AppFooter } from "./AppFooter"; import { LanguageSelector } from "./LanguageSelector"; export function PageShell({ children }: { children: ReactNode }) { @@ -9,8 +10,8 @@ export function PageShell({ children }: { children: ReactNode }) { }>().props; return ( - <> - + + @@ -31,6 +32,7 @@ export function PageShell({ children }: { children: ReactNode }) { )} {children} - > + + ); } diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts index 3924c02..fe4d99b 100644 --- a/resources/js/i18n.ts +++ b/resources/js/i18n.ts @@ -10,6 +10,22 @@ export const resources = { translation: { "language.change": "Change language", "language.updating": "Changing language", + "footer.created_by": "Created by", + "footer.repository": "Project repository", + "footer.support": "Support on GitHub", + "footer.version": "Version {{version}}", + "footer.check": "Check for updates", + "footer.checking": "Checking…", + "footer.updating": "Updating…", + "footer.update_available": "Update to {{version}}", + "footer.check_failed": "Update check failed", + "footer.confirm_title": "Install update?", + "footer.confirm_body": + "IpamFerry will update from {{current}} to {{next}} and briefly restart.", + "footer.confirm_warning": + "Do not close the browser while the update starts. Active migrations must finish first.", + "footer.cancel": "Cancel", + "footer.confirm": "Install update", "common.back": "Back to projects", "common.create": "Create", "common.status": "Status", @@ -426,6 +442,22 @@ export const resources = { translation: { "language.change": "Alterar idioma", "language.updating": "Alterando idioma", + "footer.created_by": "Criado por", + "footer.repository": "Repositório do projeto", + "footer.support": "Apoiar no GitHub", + "footer.version": "Versão {{version}}", + "footer.check": "Verificar atualizações", + "footer.checking": "Verificando…", + "footer.updating": "Atualizando…", + "footer.update_available": "Atualizar para {{version}}", + "footer.check_failed": "Falha ao verificar atualização", + "footer.confirm_title": "Instalar atualização?", + "footer.confirm_body": + "O IpamFerry será atualizado de {{current}} para {{next}} e reiniciará brevemente.", + "footer.confirm_warning": + "Não feche o navegador enquanto a atualização inicia. Migrações ativas devem terminar primeiro.", + "footer.cancel": "Cancelar", + "footer.confirm": "Instalar atualização", "common.back": "Voltar aos projetos", "common.create": "Criar", "common.status": "Status", @@ -852,6 +884,22 @@ export const resources = { translation: { "language.change": "Cambiar idioma", "language.updating": "Cambiando idioma", + "footer.created_by": "Creado por", + "footer.repository": "Repositorio del proyecto", + "footer.support": "Apoyar en GitHub", + "footer.version": "Versión {{version}}", + "footer.check": "Buscar actualizaciones", + "footer.checking": "Comprobando…", + "footer.updating": "Actualizando…", + "footer.update_available": "Actualizar a {{version}}", + "footer.check_failed": "Falló la búsqueda de actualización", + "footer.confirm_title": "¿Instalar actualización?", + "footer.confirm_body": + "IpamFerry se actualizará de {{current}} a {{next}} y se reiniciará brevemente.", + "footer.confirm_warning": + "No cierre el navegador mientras inicia la actualización. Las migraciones activas deben finalizar primero.", + "footer.cancel": "Cancelar", + "footer.confirm": "Instalar actualización", "common.back": "Volver a proyectos", "common.create": "Crear", "common.status": "Estado", diff --git a/routes/console.php b/routes/console.php index 564bbe0..27e4193 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ MappingPreview::query()->where('expires_at', '<=', now())->delete()) ->name('ipamferry:prune-mapping-previews') ->hourly(); +Schedule::call(fn () => app(InstallationUpdateService::class)->checkIfDue()) + ->name('ipamferry:check-installation-update') + ->daily(); diff --git a/routes/web.php b/routes/web.php index 05971a8..6a9074b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('locale.update'); Route::middleware('auth')->group(function (): void { + Route::get('/installation-update', [InstallationUpdateController::class, 'status'])->name('installation-update.status'); + Route::post('/installation-update/check', [InstallationUpdateController::class, 'check'])->middleware(['role:owner', 'throttle:installation-update'])->name('installation-update.check'); + Route::post('/installation-update', [InstallationUpdateController::class, 'request'])->middleware(['role:owner', 'throttle:installation-update'])->name('installation-update.request'); Route::get('/dashboard', DashboardController::class)->name('dashboard'); Route::get('/projects', [MigrationProjectController::class, 'index'])->name('projects.index'); Route::get('/projects/{project}', [MigrationProjectController::class, 'show'])->name('projects.show'); diff --git a/tests/E2E/installation.spec.ts b/tests/E2E/installation.spec.ts index 64fc89c..4eac419 100644 --- a/tests/E2E/installation.spec.ts +++ b/tests/E2E/installation.spec.ts @@ -19,6 +19,8 @@ test("claims an installation and migrates baseline and expanded inventories", as throw new Error("IPAMFERRY_INSTALLATION_TOKEN is required for E2E."); await page.goto("/setup"); + await expect(page.getByText("Created by")).toBeVisible(); + await expect(page.getByText("Version dev")).toBeVisible(); await page.getByRole("button", { name: "Change language" }).click(); await page.getByRole("menuitem", { name: "Português (Brasil)" }).click(); await expect( @@ -45,6 +47,9 @@ test("claims an installation and migrates baseline and expanded inventories", as await expect( page.getByRole("heading", { name: "Migration projects" }), ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Check for updates" }), + ).toBeVisible(); await page.getByRole("link", { name: "New project" }).click(); await page.getByLabel("Name").fill("E2E phpIPAM"); diff --git a/tests/Feature/InstallationUpdateTest.php b/tests/Feature/InstallationUpdateTest.php new file mode 100644 index 0000000..0b823fa --- /dev/null +++ b/tests/Feature/InstallationUpdateTest.php @@ -0,0 +1,84 @@ +set('ipamferry.version', '0.2.0'); + config()->set('ipamferry.updates_enabled', true); + config()->set('ipamferry.release_api_url', 'https://updates.example.test/latest'); + Storage::fake('local'); + } + + public function test_only_an_owner_can_check_for_updates(): void + { + $reader = $this->user(UserRole::Reader); + $this->actingAs($reader)->post('/installation-update/check')->assertForbidden(); + } + + public function test_owner_can_check_a_stable_release_without_downloading_it(): void + { + Http::fake(['https://updates.example.test/latest' => Http::response($this->release('0.2.1'), 200)]); + + $this->actingAs($this->user(UserRole::Owner))->post('/installation-update/check')->assertRedirect(); + + $state = InstallationUpdate::query()->sole(); + self::assertSame('available', $state->status); + self::assertSame('0.2.1', $state->available_version); + Http::assertSentCount(1); + } + + public function test_request_validates_checksum_and_queues_only_a_digest_pinned_compose(): void + { + $compose = "name: ipamferry\nservices:\n app:\n image: docker.io/lrqnet/ipamferry@sha256:".str_repeat('a', 64)."\n"; + Http::fake([ + 'https://updates.example.test/latest' => Http::response($this->release('0.2.1'), 200), + 'https://downloads.example.test/compose.yaml' => Http::response($compose, 200), + 'https://downloads.example.test/compose.sha256' => Http::response(hash('sha256', $compose)." compose.yaml\n", 200), + ]); + $owner = $this->user(UserRole::Owner); + $this->actingAs($owner)->post('/installation-update/check'); + $this->actingAs($owner)->post('/installation-update')->assertRedirect(); + + self::assertSame('requested', InstallationUpdate::query()->sole()->status); + Storage::disk('local')->assertExists('private/updates/compose.yaml'); + self::assertStringNotContainsString('token', Storage::disk('local')->get('private/updates/request.json')); + } + + public function test_request_is_blocked_while_a_migration_is_active(): void + { + $owner = $this->user(UserRole::Owner); + InstallationUpdate::query()->create(['installed_version' => '0.2.0', 'status' => 'available', 'available_version' => '0.2.1']); + MigrationProject::query()->create(['name' => 'Active', 'source_kind' => 'api', 'status' => 'applying', 'created_by' => $owner->id, 'locale' => 'en']); + + $this->actingAs($owner)->post('/installation-update')->assertSessionHas('error'); + } + + /** @return array */ + private function release(string $version): array + { + return ['tag_name' => "v{$version}", 'draft' => false, 'prerelease' => false, 'html_url' => 'https://github.com/lrqnet/ipamferry/releases/tag/v'.$version, 'assets' => [ + ['name' => 'compose.yaml', 'browser_download_url' => 'https://downloads.example.test/compose.yaml'], + ['name' => 'compose.sha256', 'browser_download_url' => 'https://downloads.example.test/compose.sha256'], + ]]; + } + + private function user(UserRole $role): User + { + return User::query()->create(['name' => 'Test User', 'email' => $role->value.'@example.test', 'password' => 'password', 'role' => $role, 'locale' => 'en', 'is_active' => true]); + } +}