From 381ed2a91fa7ff5743a365511d9f5cd6bff141a9 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 14 Jul 2026 10:47:54 -0400 Subject: [PATCH] feat: add Admiral Hardened Build channel and business assurance surfaces (#1629) * feat: add Admiral Hardened Build channel and business assurance surfaces Introduce Studio Saelix entitlement-backed Hardened Build switching, a single-flight image operation coordinator, Recovery Vault naming, Admiral Account settings, and typed Fleet update failures while preserving Community custom-repo and targetless pull-current updates. * fix: harden image-op paths and clear CI CodeQL/pilot flake Validate operation IDs before filesystem use, use hostname checks in Fleet fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown errors in the pilot reverse-route post-handshake test. * fix: sanitize image-op docker config write and probe logs Allowlist-copy registry host keys and base64 auth before writing the temp DOCKER_CONFIG, and log registry probe failures with a fixed message so CodeQL no longer flags network-to-file and log-injection mediums. * fix: address Admiral Hardened Build audit blockers Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording. * fix: terminalize image ops on helper survival and aborted claims * fix: prevent recreating persist from overwriting helper-exit failure * test: assert helper-exit failure lands before recreating persist * fix: keep current pointer when acknowledging a stale image operation --- .env.example | 7 + .../capability-registry-pilot.test.ts | 2 +- .../fleet-pilot-agent-parity.test.ts | 6 +- .../src/__tests__/fleet-pilot-update.test.ts | 8 +- .../__tests__/fleet-update-hardening.test.ts | 103 ++++ .../__tests__/hardened-entitlement.test.ts | 134 +++++ backend/src/__tests__/image-channel.test.ts | 30 + .../__tests__/image-operation-service.test.ts | 273 +++++++++ .../node-registry-fetch-meta.test.ts | 2 +- ...tunnel-bridge-reverse-route-events.test.ts | 11 +- .../src/__tests__/remote-capabilities.test.ts | 6 +- .../self-update-pinned-routes.test.ts | 41 +- backend/src/bootstrap/startup.ts | 4 + backend/src/helpers/allowedImageRef.ts | 56 ++ backend/src/helpers/imageChannel.ts | 43 ++ backend/src/index.ts | 2 + backend/src/routes/cloudBackup.ts | 4 +- backend/src/routes/fleet.ts | 121 +++- backend/src/routes/imageChannel.ts | 91 +++ backend/src/routes/license.ts | 49 +- backend/src/routes/meta.ts | 15 +- backend/src/services/CapabilityRegistry.ts | 12 + backend/src/services/CloudBackupService.ts | 6 +- .../src/services/FleetUpdateTrackerService.ts | 4 + .../services/HardenedEntitlementService.ts | 195 +++++++ backend/src/services/ImageOperationService.ts | 534 ++++++++++++++++++ backend/src/services/LicenseService.ts | 2 + backend/src/services/RegistryService.ts | 18 + backend/src/services/SelfUpdateService.ts | 95 +++- .../src/services/hardenedEntitlementTypes.ts | 36 ++ backend/src/utils/audit-summaries.ts | 2 +- docs/features/alerts-notifications.mdx | 2 +- docs/features/api-tokens.mdx | 2 +- docs/features/audit-log.mdx | 2 +- docs/features/dashboard.mdx | 4 +- docs/features/fleet-backups.mdx | 20 +- docs/features/licensing.mdx | 15 +- docs/features/multi-node.mdx | 4 +- docs/reference/settings.mdx | 16 +- e2e/admiral-account.spec.ts | 37 ++ .../src/components/FleetView/NodeCard.tsx | 6 +- .../components/FleetView/NodeUpdatesSheet.tsx | 6 +- .../__tests__/useFleetUpdateStatus.test.tsx | 14 + .../FleetView/hooks/useFleetUpdateStatus.ts | 17 +- frontend/src/components/FleetView/types.ts | 2 + .../dashboard/ConfigurationStatus.tsx | 4 +- .../__tests__/ConfigurationStatus.test.tsx | 6 +- .../settings/CloudBackupSection.tsx | 10 +- .../components/settings/LicenseSection.tsx | 185 +++++- .../components/settings/SupportSection.tsx | 6 +- frontend/src/components/settings/registry.ts | 12 +- .../.github/workflows/publish-hardened.yml | 89 +++ hardened-pipeline-skeleton/PUBLISH_POLICY.md | 28 + hardened-pipeline-skeleton/README.md | 28 + 54 files changed, 2302 insertions(+), 125 deletions(-) create mode 100644 backend/src/__tests__/hardened-entitlement.test.ts create mode 100644 backend/src/__tests__/image-channel.test.ts create mode 100644 backend/src/__tests__/image-operation-service.test.ts create mode 100644 backend/src/helpers/allowedImageRef.ts create mode 100644 backend/src/helpers/imageChannel.ts create mode 100644 backend/src/routes/imageChannel.ts create mode 100644 backend/src/services/HardenedEntitlementService.ts create mode 100644 backend/src/services/ImageOperationService.ts create mode 100644 backend/src/services/hardenedEntitlementTypes.ts create mode 100644 e2e/admiral-account.spec.ts create mode 100644 hardened-pipeline-skeleton/.github/workflows/publish-hardened.yml create mode 100644 hardened-pipeline-skeleton/PUBLISH_POLICY.md create mode 100644 hardened-pipeline-skeleton/README.md diff --git a/.env.example b/.env.example index cf1f6b28..34628858 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,13 @@ NODE_ENV=production # Frontend URL for CORS in production (leave empty for same-origin setups) FRONTEND_URL= +# Base URL for the Hardened Build assurance API +SENCHO_ASSURANCE_API=https://sencho.io + +# Non-production/test-only entitlement stub (1, entitled, unauthorized, unavailable). +# Ignored when NODE_ENV=production. +SENCHO_ASSURANCE_ENTITLEMENT_STUB= + # Global API rate limit (requests per minute per user session, production only) # Authenticated requests are keyed by user ID; unauthenticated by IP. # Internal node-to-node traffic (node_proxy tokens) bypasses this limit entirely. diff --git a/backend/src/__tests__/capability-registry-pilot.test.ts b/backend/src/__tests__/capability-registry-pilot.test.ts index 4a6970a4..b60ffb68 100644 --- a/backend/src/__tests__/capability-registry-pilot.test.ts +++ b/backend/src/__tests__/capability-registry-pilot.test.ts @@ -62,7 +62,7 @@ describe('fetchRemoteMeta Authorization header', () => { updateError: null, online: false, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }); }); }); diff --git a/backend/src/__tests__/fleet-pilot-agent-parity.test.ts b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts index 0e230dd9..379322cb 100644 --- a/backend/src/__tests__/fleet-pilot-agent-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts @@ -234,10 +234,10 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => { updateError: null, online: true, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }; } - return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false }; + return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null }; }); const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader); @@ -257,7 +257,7 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => { updateError: null, online: false, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }); const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader); diff --git a/backend/src/__tests__/fleet-pilot-update.test.ts b/backend/src/__tests__/fleet-pilot-update.test.ts index a6ee605f..2f4228ba 100644 --- a/backend/src/__tests__/fleet-pilot-update.test.ts +++ b/backend/src/__tests__/fleet-pilot-update.test.ts @@ -37,7 +37,7 @@ const META_ONLINE_OUTDATED: RemoteMeta = { updateError: null, online: true, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }; const META_OFFLINE: RemoteMeta = { @@ -47,7 +47,7 @@ const META_OFFLINE: RemoteMeta = { updateError: null, online: false, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }; const META_NO_SELF_UPDATE: RemoteMeta = { @@ -255,7 +255,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () => mockTargetForPilot(); mockCompareTargetFetch(); // Remote now reports a different version than before the update (signal 1). - mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false }); + mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null }); const tracker = FleetUpdateTrackerService.getInstance(); tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null)); @@ -272,7 +272,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () => it('does not drop the cache on a steady-state completed poll (transition guard)', async () => { mockTargetForPilot(); mockCompareTargetFetch(); - mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false }); + mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null }); // Already completed before this poll: no transition, so no invalidation. const tracker = FleetUpdateTrackerService.getInstance(); diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index dc8d757f..13b11a7f 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -41,6 +41,7 @@ const ONLINE = (over: Partial = {}): RemoteMeta => ({ online: true, imagePinKind: null, updateBlocked: false, + imageChannel: null, ...over, }); @@ -217,6 +218,108 @@ describe('POST /api/fleet/nodes/:id/update concurrency', () => { expect(res.status).toBe(409); expect(res.body?.error).toMatch(/already in progress/i); }); + + it('preserves a typed remote update failure in the response and tracker', async () => { + mockTarget(); + mockMeta(ONLINE()); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + try { + if (new URL(String(input)).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { + // Non-URL fetch inputs fall through to the remote-update mock. + } + return new Response(JSON.stringify({ + error: 'Hardened Build updates require a signed-in admin on that node.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }), { status: 403 }); + }); + + const res = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/update`) + .set('Authorization', adminAuth); + + expect(res.status).toBe(502); + expect(res.body).toEqual({ + error: 'Hardened Build updates require a signed-in admin on that node.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }); + expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code) + .toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED'); + }); +}); + + +describe('POST /api/fleet/nodes/:id/update hardened digest pin', () => { + it('still POSTs when updateBlocked and imageChannel is hardened', async () => { + mockTarget(); + mockMeta(ONLINE({ updateBlocked: true, imageChannel: 'hardened', imagePinKind: 'digest' })); + let remoteUpdateCalled = false; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + try { + if (new URL(String(input)).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { + // Non-URL fetch inputs fall through to the remote-update mock. + } + remoteUpdateCalled = true; + return new Response(JSON.stringify({ + error: 'Hardened Build updates require a signed-in admin on that node.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }), { status: 403 }); + }); + + const res = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/update`) + .set('Authorization', adminAuth); + + expect(remoteUpdateCalled).toBe(true); + expect(res.status).toBe(502); + expect(res.body).toEqual({ + error: 'Hardened Build updates require a signed-in admin on that node.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }); + expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code) + .toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED'); + }); +}); + +describe('POST /api/fleet/update-all typed failures', () => { + it('reports remote rejections as failed instead of skipped', async () => { + mockTarget(); + mockMeta(ONLINE()); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + try { + if (new URL(String(input)).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { + // Non-URL fetch inputs fall through to the remote-update mock. + } + return new Response(JSON.stringify({ + error: 'Hardened Build updates require a signed-in admin on that node.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }), { status: 403 }); + }); + + const res = await request(app) + .post('/api/fleet/update-all') + .set('Authorization', adminAuth); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ + updating: [], + skipped: [], + failed: [{ + nodeId: proxyNodeId, + name: 'proxy-hardening-test', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + error: 'Hardened Build updates require a signed-in admin on that node.', + }], + }); + }); }); describe('clear-route authorization', () => { diff --git a/backend/src/__tests__/hardened-entitlement.test.ts b/backend/src/__tests__/hardened-entitlement.test.ts new file mode 100644 index 00000000..8b533534 --- /dev/null +++ b/backend/src/__tests__/hardened-entitlement.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetSystemState, mockPost, mockGetTier } = vi.hoisted(() => ({ + mockGetSystemState: vi.fn(), + mockPost: vi.fn(), + mockGetTier: vi.fn((): 'paid' | 'community' => 'paid'), +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getSystemState: mockGetSystemState, + }), + }, +})); + +vi.mock('../services/LicenseService', () => ({ + LicenseService: { + getInstance: () => ({ + getTier: mockGetTier, + }), + }, +})); + +vi.mock('axios', () => ({ + default: { + post: mockPost, + isAxiosError: vi.fn(), + }, +})); + +import { validateAllowedImageRefAgainstRequirement } from '../helpers/allowedImageRef'; +import { HardenedEntitlementService } from '../services/HardenedEntitlementService'; + +const registryRequirement = { + registry_host: 'ghcr.io', + package_scope: 'studio-saelix/sencho-hardened', + credential_instructions: 'Create a pull token.', + supports_pull_token: true, +}; + +const entitlementFixture = { + hardened_build_access: true, + channel: 'hardened', + allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + pin_recommendation: 'Use the supplied digest.', + registry_requirement: registryRequirement, + checked_at: '2026-07-13T12:00:00.000Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + HardenedEntitlementService.getInstance().invalidateCache(); + mockGetTier.mockReturnValue('paid'); + mockGetSystemState.mockImplementation((key: string) => ({ + license_key: 'license-for-test', + instance_id: 'instance-for-test', + })[key] ?? ''); + delete process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB; +}); + +describe('allowed Hardened image references', () => { + it('rejects a bare digest', () => { + expect(validateAllowedImageRefAgainstRequirement( + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + registryRequirement, + )).toBe(false); + }); + + it('rejects an image outside the entitled package scope', () => { + expect(validateAllowedImageRefAgainstRequirement( + 'ghcr.io/studio-saelix/other@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + registryRequirement, + )).toBe(false); + }); +}); + +describe('HardenedEntitlementService', () => { + it('rejects entitlement checks when the tier is not paid', async () => { + mockGetTier.mockReturnValue('community'); + process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled'; + + await expect(HardenedEntitlementService.getInstance().getEntitlement('status')) + .resolves.toEqual({ success: false, code: 'unauthorized' }); + }); + + it('ignores the entitlement stub in production', async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled'; + mockPost.mockResolvedValue({ status: 200, data: entitlementFixture }); + + try { + await expect(HardenedEntitlementService.getInstance().getEntitlement('status')) + .resolves.toMatchObject({ success: true, entitlement: entitlementFixture }); + expect(mockPost).toHaveBeenCalledTimes(1); + } finally { + process.env.NODE_ENV = previous; + } + }); + + it('returns the entitled stub response', async () => { + process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled'; + + await expect(HardenedEntitlementService.getInstance().getEntitlement('status')) + .resolves.toMatchObject({ + success: true, + entitlement: { + ...entitlementFixture, + checked_at: expect.any(String), + }, + }); + }); + + it('returns the unauthorized stub response', async () => { + process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'unauthorized'; + + await expect(HardenedEntitlementService.getInstance().getEntitlement('status')) + .resolves.toEqual({ success: false, code: 'unauthorized' }); + }); + + it('invalidates a cached status entitlement', async () => { + mockPost.mockResolvedValue({ status: 200, data: entitlementFixture }); + const service = HardenedEntitlementService.getInstance(); + + await service.getEntitlement('status'); + await service.getEntitlement('status'); + expect(mockPost).toHaveBeenCalledTimes(1); + + service.invalidateCache(); + await service.getEntitlement('status'); + expect(mockPost).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/__tests__/image-channel.test.ts b/backend/src/__tests__/image-channel.test.ts new file mode 100644 index 00000000..e8109db1 --- /dev/null +++ b/backend/src/__tests__/image-channel.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { classifyImageChannel, normalizeImageRepository } from '../helpers/imageChannel'; + +describe('image channel classification', () => { + it.each([ + 'saelix/sencho:latest', + 'docker.io/saelix/sencho:latest', + 'index.docker.io/saelix/sencho@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'ghcr.io/studio-saelix/sencho:v1.2.3', + 'ghcr.io/studio-saelix/sencho-dev:dev', + ])('classifies the canonical Community alias %s', (imageRef) => { + expect(classifyImageChannel(imageRef)).toBe('community'); + }); + + it('classifies the exact Hardened repository', () => { + expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened:v1.2.3')).toBe('hardened'); + }); + + it('keeps custom and similarly named repositories unknown', () => { + expect(classifyImageChannel('registry.example.com:5000/sencho:1.0.0')).toBe('unknown'); + expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened-mirror:1.0.0')).toBe('unknown'); + }); + + it('normalizes Docker Hub aliases without changing registries that use ports', () => { + expect(normalizeImageRepository('docker.io/saelix/sencho:latest')).toBe('saelix/sencho'); + expect(normalizeImageRepository('index.docker.io/saelix/sencho:latest')).toBe('saelix/sencho'); + expect(normalizeImageRepository('registry.example.com:5000/saelix/sencho:latest')) + .toBe('registry.example.com:5000/saelix/sencho'); + }); +}); diff --git a/backend/src/__tests__/image-operation-service.test.ts b/backend/src/__tests__/image-operation-service.test.ts new file mode 100644 index 00000000..02847750 --- /dev/null +++ b/backend/src/__tests__/image-operation-service.test.ts @@ -0,0 +1,273 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { cleanupTestDb, setupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let ImageOperationService: typeof import('../services/ImageOperationService').ImageOperationService; +let SelfUpdateService: typeof import('../services/SelfUpdateService').default; +let HardenedEntitlementService: typeof import('../services/HardenedEntitlementService').HardenedEntitlementService; +let RegistryService: typeof import('../services/RegistryService').RegistryService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ ImageOperationService } = await import('../services/ImageOperationService')); + SelfUpdateService = (await import('../services/SelfUpdateService')).default; + ({ HardenedEntitlementService } = await import('../services/HardenedEntitlementService')); + ({ RegistryService } = await import('../services/RegistryService')); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + const selfUpdate = SelfUpdateService.getInstance() as unknown as { + pendingHelperExitError: string | undefined; + helperExitListeners: Array<(error: string | null) => void>; + }; + selfUpdate.pendingHelperExitError = undefined; + selfUpdate.helperExitListeners = []; + await fs.rm(path.join(tmpDir, 'image-operation-current.json'), { force: true }); + await fs.rm(path.join(tmpDir, 'image-operations'), { recursive: true, force: true }); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('ImageOperationService', () => { + it('rejects a second update while the first operation is claimed', async () => { + let releaseUpdate: (() => void) | undefined; + let markTriggered: (() => void) | undefined; + const triggered = new Promise(resolve => { markTriggered = resolve; }); + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => { + markTriggered!(); + await new Promise(resolve => { releaseUpdate = resolve; }); + }); + + const service = ImageOperationService.getInstance(); + const first = service.runCommunityUpdate(); + await triggered; + const second = await service.runCommunityUpdate(); + + expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); + releaseUpdate!(); + await expect(first).resolves.toEqual({ ok: true }); + }); + + it('claims synchronously before checking existing operations', async () => { + let releaseLookup: (() => void) | undefined; + let signalLookupStarted: (() => void) | undefined; + const lookupStarted = new Promise(resolve => { signalLookupStarted = resolve; }); + let lookups = 0; + const service = ImageOperationService.getInstance(); + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(service, 'getCurrentOperation').mockImplementation(async () => { + lookups += 1; + if (lookups === 1) { + signalLookupStarted!(); + await new Promise(resolve => { releaseLookup = resolve; }); + } + return null; + }); + + const first = service.claimCommunityUpdate(); + await lookupStarted; + const second = await service.claimCommunityUpdate(); + releaseLookup!(); + + await expect(first).resolves.toEqual({ ok: true }); + expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); + }); + + it('persists a terminal failure when the update helper reports an error', async () => { + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); + + const result = await ImageOperationService.getInstance().runCommunityUpdate(); + const current = await ImageOperationService.getInstance().getCurrentOperation(); + + expect(result).toEqual({ ok: false, failureCode: 'update_failed' }); + expect(current?.state).toBe('failed'); + expect(current?.failureCode).toBe('update_failed'); + }); + + it('fails a hardened update when the helper reports an error and binds its marker to the operation', async () => { + const entitlement = { + success: true as const, + entitlement: { + hardened_build_access: true, + channel: 'hardened' as const, + allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened:latest', + pin_recommendation: 'ghcr.io/studio-saelix/sencho-hardened:latest', + checked_at: '2026-07-13T00:00:00.000Z', + registry_requirement: { + registry_host: 'ghcr.io', + package_scope: 'studio-saelix/sencho-hardened', + credential_instructions: 'Use a pull token.', + supports_pull_token: true, + }, + }, + }; + const resolved = { + filePath: '/compose.yml', + imageRef: 'ghcr.io/studio-saelix/sencho-hardened:latest', + pinKind: 'semver' as const, + fileContent: 'services: {}', + }; + let markerFile = ''; + let markerContent = ''; + vi.spyOn(HardenedEntitlementService.getInstance(), 'getEntitlement').mockResolvedValue(entitlement); + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(resolved); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(RegistryService.getInstance(), 'resolveDockerConfigForHost').mockResolvedValue({ + config: { auths: { 'ghcr.io': { auth: 'credential' } } }, + warnings: [], + }); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async options => { + markerFile = options?.successMarkerFile ?? ''; + markerContent = options?.successMarkerContent ?? ''; + }); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); + + const service = ImageOperationService.getInstance(); + const fingerprint = service.computePreflightFingerprint( + resolved.filePath, + resolved.imageRef, + resolved.pinKind, + entitlement.entitlement.allowed_image_ref, + ); + const result = await service.switchToHardened(fingerprint); + const current = await service.getCurrentOperation(); + + expect(result).toEqual({ ok: false, code: 'update_failed' }); + expect(current?.state).toBe('failed'); + expect(markerFile).toBe(path.join(tmpDir, `image-op-success-${current?.operationId}.json`)); + expect(markerContent).toBe(JSON.stringify({ ok: true, operationId: current?.operationId })); + }); + + + it('fails a recreating community op when the helper exits after handoff', async () => { + let helperExit: ((error: string | null) => void) | undefined; + const callOrder: string[] = []; + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => { + callOrder.push('watch'); + helperExit = listener; + }); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => { + callOrder.push('trigger'); + }); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); + + const service = ImageOperationService.getInstance(); + const result = await service.runCommunityUpdate(); + const mid = await service.getCurrentOperation(); + + expect(result).toEqual({ ok: true }); + expect(mid?.state).toBe('recreating'); + expect(callOrder).toEqual(['watch', 'trigger']); + expect(helperExit).toBeTypeOf('function'); + + helperExit!('Helper container exited without restarting Sencho'); + await vi.waitFor(async () => { + const current = await service.getCurrentOperation(); + expect(current?.state).toBe('failed'); + expect(current?.failureCode).toBe('update_failed'); + }); + }); + + it('does not let recreating persist overwrite a concurrent helper-exit failure', async () => { + let helperExit: ((error: string | null) => void) | undefined; + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => { + helperExit = listener; + }); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); + + const service = ImageOperationService.getInstance(); + const proto = Object.getPrototypeOf(service) as { + persist: (operation: unknown) => Promise; + }; + const realPersist = proto.persist.bind(service); + vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise }, 'persist') + .mockImplementation(async (operation) => { + if (operation.state === 'recreating' && helperExit) { + const exit = helperExit; + helperExit = undefined; + // Fail must land on disk before the recreating write runs. That is the + // overwrite order without CAS: helper-exit failed, then late recreating. + exit('Helper container exited without restarting Sencho'); + await vi.waitFor(async () => { + const current = await service.getCurrentOperation(); + expect(current?.state).toBe('failed'); + expect(current?.failureCode).toBe('update_failed'); + }); + await realPersist(operation); + return; + } + return realPersist(operation); + }); + + await service.runCommunityUpdate(); + const current = await service.getCurrentOperation(); + expect(current?.state).toBe('failed'); + expect(current?.failureCode).toBe('update_failed'); + }); + + it('does not let acknowledging a stale failure replace an active current operation', async () => { + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); + + const service = ImageOperationService.getInstance(); + await service.runCommunityUpdate(); + const failedA = await service.getCurrentOperation(); + expect(failedA?.state).toBe('failed'); + const operationAId = failedA!.operationId; + + const claimB = await service.claimCommunityUpdate(); + expect(claimB).toEqual({ ok: true }); + const currentB = await service.getCurrentOperation(); + expect(currentB?.state).toBe('pending_pull'); + expect(currentB?.operationId).not.toBe(operationAId); + + expect(await service.acknowledge(operationAId)).toBe(true); + const stillB = await service.getCurrentOperation(); + expect(stillB?.operationId).toBe(currentB!.operationId); + expect(stillB?.state).toBe('pending_pull'); + + const ackedA = await service.getOperation(operationAId); + expect(ackedA?.acknowledgedAt).toBeTruthy(); + + const claimC = await service.claimCommunityUpdate(); + expect(claimC).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); + }); + + it('replays a pending helper exit to a late onceHelperExit listener', async () => { + const svc = SelfUpdateService.getInstance(); + const internal = svc as unknown as { pendingHelperExitError: string | undefined }; + internal.pendingHelperExitError = 'Helper container exited without restarting Sencho'; + + const seen = new Promise((resolve) => { + svc.onceHelperExit(resolve); + }); + + await expect(seen).resolves.toBe('Helper container exited without restarting Sencho'); + expect(internal.pendingHelperExitError).toBeUndefined(); + }); + + it('changes the fingerprint when a preflight value changes', () => { + const service = ImageOperationService.getInstance(); + const baseline = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaa'); + const changed = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:bbb'); + + expect(changed).not.toBe(baseline); + }); +}); diff --git a/backend/src/__tests__/node-registry-fetch-meta.test.ts b/backend/src/__tests__/node-registry-fetch-meta.test.ts index 7f6674f8..dd8f5d02 100644 --- a/backend/src/__tests__/node-registry-fetch-meta.test.ts +++ b/backend/src/__tests__/node-registry-fetch-meta.test.ts @@ -55,7 +55,7 @@ describe('NodeRegistry.fetchMetaForNode', () => { updateError: null, online: false, imagePinKind: null, - updateBlocked: false, + updateBlocked: false, imageChannel: null, }); expect(axiosSpy).not.toHaveBeenCalled(); db.deleteNode(nodeId); diff --git a/backend/src/__tests__/pilot-tunnel-bridge-reverse-route-events.test.ts b/backend/src/__tests__/pilot-tunnel-bridge-reverse-route-events.test.ts index 9ccc3daf..4ece924c 100644 --- a/backend/src/__tests__/pilot-tunnel-bridge-reverse-route-events.test.ts +++ b/backend/src/__tests__/pilot-tunnel-bridge-reverse-route-events.test.ts @@ -249,8 +249,12 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => { // Real local server. Have it close the connection immediately after // accept so the bridge socket sees 'close' (and possibly 'error') on - // an already-connected socket. + // an already-connected socket. Swallow expected teardown noise so a + // late ECONNRESET cannot surface as an unhandled Vitest exception. + const accepted: net.Socket[] = []; const upstream = net.createServer((socket) => { + accepted.push(socket); + socket.on('error', () => { /* expected post-handshake teardown */ }); socket.end(); }); await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())); @@ -284,7 +288,10 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => { ); expect(failCalls).toHaveLength(0); - upstream.close(); + for (const socket of accepted) { + try { socket.destroy(); } catch { /* ignore */ } + } + await new Promise((resolve) => upstream.close(() => resolve())); bridge.close(); }); }); diff --git a/backend/src/__tests__/remote-capabilities.test.ts b/backend/src/__tests__/remote-capabilities.test.ts index cad711ab..65f38917 100644 --- a/backend/src/__tests__/remote-capabilities.test.ts +++ b/backend/src/__tests__/remote-capabilities.test.ts @@ -26,7 +26,7 @@ afterAll(() => cleanupTestDb(tmpDir)); afterEach(() => vi.restoreAllMocks()); -const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false } as const; +const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null } as const; const capable: RemoteMeta = { version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE }; const incapable: RemoteMeta = { version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE }; @@ -43,7 +43,7 @@ describe('remoteSupportsCrossNodeRbac', () => { it('fails closed when the remote is offline (empty capabilities)', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') - .mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false }); + .mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null }); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false); }); @@ -51,7 +51,7 @@ describe('remoteSupportsCrossNodeRbac', () => { // A 0.0.0-dev image reports version null (non-semver) but is reachable and // genuinely advertises the capability; it must not be wrongly denied. vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') - .mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false }); + .mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null }); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true); }); diff --git a/backend/src/__tests__/self-update-pinned-routes.test.ts b/backend/src/__tests__/self-update-pinned-routes.test.ts index 75e8849f..50da0aca 100644 --- a/backend/src/__tests__/self-update-pinned-routes.test.ts +++ b/backend/src/__tests__/self-update-pinned-routes.test.ts @@ -59,6 +59,24 @@ afterEach(() => { }); describe('POST /api/system/update', () => { + it('rejects hardened updates from node-proxy credentials', async () => { + mockSelfUpdateAvailable({ + pinInfo: { + pinKind: 'digest', + composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + filePath: '/opt/sencho/docker-compose.yml', + }, + }); + const machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + + const res = await request(app) + .post('/api/system/update') + .set('Authorization', machineAuth); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED'); + }); + it('returns 400 for a supplied but invalid targetVersion', async () => { mockSelfUpdateAvailable(); @@ -99,12 +117,27 @@ describe('POST /api/system/update', () => { expect(res.body?.message).toMatch(/restart/i); // triggerUpdate runs on res finish + delay; flush the microtask queue. await new Promise(r => setTimeout(r, 600)); - expect(triggerSpy).toHaveBeenCalledWith({ targetVersion: '0.99.0' }); + expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({ + targetVersion: '0.99.0', + successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/), + successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/), + })); }); }); describe('GET /api/meta pin subset', () => { - it('exposes imagePinKind and updateBlocked but never composeImageRef', async () => { + it('does not expose a raw update error with a private image reference', async () => { + mockSelfUpdateAvailable(); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError') + .mockReturnValue('pull private.registry.example/internal/sencho:1.0 failed'); + + const res = await request(app).get('/api/meta'); + + expect(res.body.updateError).toBe('update_failed'); + expect(JSON.stringify(res.body)).not.toContain('private.registry.example'); + }); + + it('exposes imagePinKind, updateBlocked, and imageChannel but never composeImageRef', async () => { mockSelfUpdateAvailable({ pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' }, }); @@ -114,19 +147,21 @@ describe('GET /api/meta pin subset', () => { expect(res.status).toBe(200); expect(res.body.imagePinKind).toBe('semver'); expect(res.body.updateBlocked).toBe(false); + expect(res.body.imageChannel).toBe('community'); expect(res.body).not.toHaveProperty('composeImageRef'); expect(res.body).not.toHaveProperty('targetImageRef'); }); it('reports updateBlocked=true for a digest pin', async () => { mockSelfUpdateAvailable({ - pinInfo: { pinKind: 'digest', composeImageRef: 'saelix/sencho@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' }, + pinInfo: { pinKind: 'digest', composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' }, }); const res = await request(app).get('/api/meta'); expect(res.body.imagePinKind).toBe('digest'); expect(res.body.updateBlocked).toBe(true); + expect(res.body.imageChannel).toBe('hardened'); }); }); diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 08bc3156..13463e06 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -6,6 +6,7 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { DatabaseService } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import SelfUpdateService from '../services/SelfUpdateService'; +import { ImageOperationService } from '../services/ImageOperationService'; import SelfIdentityService from '../services/SelfIdentityService'; import { MonitorService } from '../services/MonitorService'; import { AutoHealService } from '../services/AutoHealService'; @@ -165,6 +166,9 @@ export async function startServer(server: Server): Promise { })(), TrivyService.getInstance().initialize(), ]); + void ImageOperationService.getInstance().reconcileOnStartup().catch((error) => { + console.error('[ImageOperation] Startup reconciliation failed:', error); + }); // Fire-and-forget housekeeping; logged but never awaited. sweepStaleGitTempDirs().catch((err) => { diff --git a/backend/src/helpers/allowedImageRef.ts b/backend/src/helpers/allowedImageRef.ts new file mode 100644 index 00000000..fc41804c --- /dev/null +++ b/backend/src/helpers/allowedImageRef.ts @@ -0,0 +1,56 @@ +import type { RegistryRequirement } from '../services/hardenedEntitlementTypes'; + +export interface AllowedImageReference { + registryHost: string; + repository: string; + tag?: string; + digest?: string; +} + +const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i; + +export function parseAllowedImageRef(ref: string): AllowedImageReference | null { + const imageRef = ref.trim(); + if (!imageRef || /\s/.test(imageRef)) return null; + + const [repositoryWithTag, digest, ...extraParts] = imageRef.split('@'); + if (extraParts.length > 0 || !repositoryWithTag) return null; + if (digest !== undefined && !SHA256_DIGEST.test(digest)) return null; + + const segments = repositoryWithTag.split('/'); + if (segments.length < 2) return null; + + const registryHost = segments.shift(); + if (!registryHost || !isRegistryHost(registryHost)) return null; + + const lastSegment = segments.pop(); + if (!lastSegment) return null; + + const tagIndex = lastSegment.lastIndexOf(':'); + const repositoryLeaf = tagIndex === -1 ? lastSegment : lastSegment.slice(0, tagIndex); + const tag = tagIndex === -1 ? undefined : lastSegment.slice(tagIndex + 1); + if (!repositoryLeaf || (tagIndex !== -1 && !tag)) return null; + if (tag && digest) return null; + if (!tag && !digest) return null; + + const repository = [...segments, repositoryLeaf].join('/'); + if (!repository || repository.split('/').some(segment => !segment || segment === '.' || segment === '..')) { + return null; + } + + return { registryHost: registryHost.toLowerCase(), repository: repository.toLowerCase(), tag, digest }; +} + +export function validateAllowedImageRefAgainstRequirement( + ref: string, + requirement: RegistryRequirement, +): boolean { + const parsed = parseAllowedImageRef(ref); + if (!parsed) return false; + return parsed.registryHost === requirement.registry_host.toLowerCase() + && parsed.repository === requirement.package_scope.toLowerCase(); +} + +function isRegistryHost(value: string): boolean { + return value === 'localhost' || value.includes('.') || value.includes(':'); +} diff --git a/backend/src/helpers/imageChannel.ts b/backend/src/helpers/imageChannel.ts new file mode 100644 index 00000000..bc6c00fd --- /dev/null +++ b/backend/src/helpers/imageChannel.ts @@ -0,0 +1,43 @@ +export type ImageChannel = 'community' | 'hardened' | 'unknown'; + +const COMMUNITY_REPOSITORIES = new Set([ + 'saelix/sencho', + 'ghcr.io/studio-saelix/sencho', + 'ghcr.io/studio-saelix/sencho-dev', +]); + +const HARDENED_REPOSITORY = 'ghcr.io/studio-saelix/sencho-hardened'; + +export function normalizeImageRepository(ref: string): string | null { + const imageRef = ref.trim().toLowerCase(); + if (!imageRef || /\s/.test(imageRef)) return null; + + const withoutDigest = imageRef.split('@'); + if (withoutDigest.length > 2 || !withoutDigest[0]) return null; + + const repositoryWithTag = withoutDigest[0]; + const lastSlash = repositoryWithTag.lastIndexOf('/'); + const lastColon = repositoryWithTag.lastIndexOf(':'); + const repository = lastColon > lastSlash + ? repositoryWithTag.slice(0, lastColon) + : repositoryWithTag; + + if (!repository || repository.startsWith('/') || repository.endsWith('/')) return null; + + if (repository.startsWith('docker.io/')) { + return repository.slice('docker.io/'.length) || null; + } + if (repository.startsWith('index.docker.io/')) { + return repository.slice('index.docker.io/'.length) || null; + } + + return repository; +} + +export function classifyImageChannel(imageRef: string): ImageChannel { + const repository = normalizeImageRepository(imageRef); + if (!repository) return 'unknown'; + if (repository === HARDENED_REPOSITORY) return 'hardened'; + if (COMMUNITY_REPOSITORIES.has(repository)) return 'community'; + return 'unknown'; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index bcfdb190..6aac5532 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { authRouter } from './routes/auth'; import { mfaRouter } from './routes/mfa'; import { ssoRouter } from './routes/sso'; import { licenseRouter, systemUpdateRouter } from './routes/license'; +import { imageChannelRouter } from './routes/imageChannel'; import { webhooksRouter } from './routes/webhooks'; import { usersRouter } from './routes/users'; import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources'; @@ -106,6 +107,7 @@ app.use('/api', hubOnlyGuard); app.use('/api/', createRemoteProxyMiddleware()); app.use('/api/license', licenseRouter); +app.use('/api/license/image-channel', imageChannelRouter); app.use('/api/system', systemUpdateRouter); app.use('/api/permissions', permissionsRouter); app.use('/api/convert', convertRouter); diff --git a/backend/src/routes/cloudBackup.ts b/backend/src/routes/cloudBackup.ts index abea7ec9..937f3ce5 100644 --- a/backend/src/routes/cloudBackup.ts +++ b/backend/src/routes/cloudBackup.ts @@ -163,7 +163,7 @@ cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promis res.json({ success: true, quota_bytes: result.quotaBytes }); } catch (error) { console.error('[CloudBackup] provision error:', error); - res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' }); + res.status(500).json({ error: 'Failed to provision Recovery Vault' }); } }); @@ -173,7 +173,7 @@ cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise; + return { + error: typeof response.error === 'string' && response.error + ? response.error + : 'Remote node rejected update request.', + ...(typeof response.code === 'string' && response.code ? { code: response.code } : {}), + }; +} + // --- Skip-version endpoints --- fleetRouter.post('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise => { @@ -1446,9 +1468,42 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r return; } const resolvedTarget = await resolveUpdateTarget(requestedTarget); + const pin = await selfUpdate.getPinInfo({ fresh: true }); + if (pin && classifyImageChannel(pin.composeImageRef) === 'hardened') { + if (!requireUserSession(req, res)) return; + const preflight = await ImageOperationService.getInstance().preflightSwitch(); + if (!preflight.ok) { + res.status(403).json({ error: 'Hardened image access is unavailable', code: preflight.code }); + return; + } + const result = await ImageOperationService.getInstance().switchToHardened( + preflight.preflightFingerprint, + 'update', + ); + if (!result.ok) { + res.status(result.code === 'IMAGE_OPERATION_IN_FLIGHT' ? 409 : 500) + .json({ error: 'Hardened update could not start', code: result.code }); + return; + } + updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null)); + res.status(202).json({ message: 'Update initiated on local node. The server will restart shortly.' }); + return; + } if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(resolvedTarget))) return; + const claim = await ImageOperationService.getInstance().claimCommunityUpdate({ targetVersion: resolvedTarget }); + if (!claim.ok) { + res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode }); + return; + } updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null)); - scheduleLocalUpdate(res, 'Update initiated on local node. The server will restart shortly.', resolvedTarget); + res.status(202).json({ message: 'Update initiated on local node. The server will restart shortly.' }); + // Schedule unconditionally: client abort can fire only `close` without `finish`, + // which would otherwise leave the claimed operation stuck in pending_pull. + setTimeout(() => { + ImageOperationService.getInstance().executeClaimedCommunityUpdate({ targetVersion: resolvedTarget }).catch(error => { + console.error('[ImageOperation] Unexpected community update failure:', error); + }); + }, 500); return; } @@ -1470,7 +1525,9 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r res.status(503).json({ error: 'Remote node does not support self-update. It may need to be updated manually first.' }); return; } - if (meta.updateBlocked) { + // Hardened peers are digest-pinned (updateBlocked) but must still reach + // /api/system/update so machine creds get HARDENED_REMOTE_UPDATE_UNSUPPORTED. + if (meta.updateBlocked && meta.imageChannel !== 'hardened') { res.status(409).json({ error: REPIN_BLOCKED_REASON, code: 'update_blocked' }); return; } @@ -1479,10 +1536,9 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r const response = await postSystemUpdate(target, resolvedTarget); if (!response.ok) { - const err = await response.json().catch(() => ({})); - const errorMsg = (err as Record)?.error || 'Remote node rejected update request.'; - updateTracker.set(nodeId, updateTracker.create('failed', meta.version, meta.startedAt, errorMsg)); - res.status(502).json({ error: errorMsg }); + const failure = parseRemoteUpdateFailure(await response.json().catch(() => null)); + updateTracker.set(nodeId, updateTracker.create('failed', meta.version, meta.startedAt, failure.error, failure.code)); + res.status(502).json(failure); return; } @@ -1515,8 +1571,8 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid }); const registry = NodeRegistry.getInstance(); - const candidates = nodes.filter(node => { - if (node.type === 'local') return false; + const remoteNodes = nodes.filter(node => node.type === 'remote'); + const candidates = remoteNodes.filter(node => { const tracker = updateTracker.get(node.id); if (tracker?.status === 'updating') return false; if (registry.getProxyTarget(node.id) === null) return false; @@ -1535,41 +1591,58 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon const results = await Promise.allSettled(candidates.map(async (node) => { const target = registry.getProxyTarget(node.id); - if (!target) return { name: node.name, triggered: false }; + if (!target) return { nodeId: node.id, name: node.name, kind: 'skipped' as const }; const meta = await registry.fetchMetaForNode(node.id); if (!meta.online) { - return { name: node.name, triggered: false }; + return { nodeId: node.id, name: node.name, kind: 'skipped' as const }; } if (!meta.capabilities.includes('self-update')) { - return { name: node.name, triggered: false }; + return { nodeId: node.id, name: node.name, kind: 'skipped' as const }; } - if (meta.updateBlocked) { - return { name: node.name, triggered: false }; + if (meta.updateBlocked && meta.imageChannel !== 'hardened') { + return { nodeId: node.id, name: node.name, kind: 'skipped' as const }; } if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) { - return { name: node.name, triggered: false }; + return { nodeId: node.id, name: node.name, kind: 'skipped' as const }; } const response = await postSystemUpdate(target, updateAllTarget); if (response.ok) { updateTracker.set(node.id, updateTracker.create('updating', meta.version, meta.startedAt)); - return { name: node.name, triggered: true }; + return { nodeId: node.id, name: node.name, kind: 'updating' as const }; } - return { name: node.name, triggered: false }; + const failure = parseRemoteUpdateFailure(await response.json().catch(() => null)); + updateTracker.set(node.id, updateTracker.create('failed', meta.version, meta.startedAt, failure.error, failure.code)); + return { nodeId: node.id, name: node.name, kind: 'failed' as const, ...failure }; })); const updating: string[] = []; - const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name); + const skipped = remoteNodes.filter(n => !candidates.includes(n)).map(n => n.name); + const failed: Array<{ nodeId: number; name: string; code: string; error: string }> = []; for (let i = 0; i < results.length; i++) { const r = results[i]; if (r.status === 'rejected') { console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason); + failed.push({ + nodeId: candidates[i].id, + name: candidates[i].name, + code: 'REMOTE_UPDATE_REQUEST_FAILED', + error: 'Failed to reach remote node for update.', + }); + continue; } - const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false }; - (val.triggered ? updating : skipped).push(val.name); + if (r.value.kind === 'updating') updating.push(r.value.name); + else if (r.value.kind === 'failed') { + failed.push({ + nodeId: r.value.nodeId, + name: r.value.name, + code: r.value.code ?? 'REMOTE_UPDATE_REQUEST_FAILED', + error: r.value.error, + }); + } else skipped.push(r.value.name); } - if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, candidateCount: candidates.length }); - res.status(202).json({ updating, skipped }); + if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, failedCount: failed.length, candidateCount: candidates.length }); + res.status(202).json({ updating, skipped, failed }); } catch (error) { console.error('[Fleet] Update all error:', error); res.status(500).json({ error: 'Failed to trigger fleet update.' }); diff --git a/backend/src/routes/imageChannel.ts b/backend/src/routes/imageChannel.ts new file mode 100644 index 00000000..6ce70bdf --- /dev/null +++ b/backend/src/routes/imageChannel.ts @@ -0,0 +1,91 @@ +import { Router, type Request, type Response } from 'express'; +import { rejectApiTokenScope } from '../middleware/apiTokenScope'; +import { requireAdmin, requirePaid, requireUserSession } from '../middleware/tierGates'; +import { classifyImageChannel } from '../helpers/imageChannel'; +import SelfUpdateService from '../services/SelfUpdateService'; +import { ImageOperationService } from '../services/ImageOperationService'; + +const SESSION_MESSAGE = 'API tokens cannot manage image channels.'; + +export const imageChannelRouter = Router(); + +imageChannelRouter.get('/status', async (req: Request, res: Response): Promise => { + if (!requireUserSession(req, res)) return; + try { + const selfUpdate = SelfUpdateService.getInstance(); + const pin = await selfUpdate.getPinInfo({ fresh: true }); + const operation = await ImageOperationService.getInstance().getCurrentOperation(); + const isAdmin = req.user?.role === 'admin'; + const channel = pin ? classifyImageChannel(pin.composeImageRef) : 'unknown'; + res.json({ + channel, + ...(pin && (isAdmin || channel !== 'hardened') ? { composeImageRef: pin.composeImageRef } : {}), + ...(isAdmin && pin ? { pinKind: pin.pinKind } : {}), + ...(isAdmin ? { operation } : {}), + }); + } catch (error) { + console.error('[ImageChannel] Status failed:', error); + res.status(500).json({ error: 'Unable to read image channel status' }); + } +}); + +imageChannelRouter.post('/preflight', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return; + if (!requireUserSession(req, res) || !requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const result = await ImageOperationService.getInstance().preflightSwitch(); + if (!result.ok) { + res.status(403).json({ error: 'Hardened image access is unavailable', code: result.code }); + return; + } + res.json(result); +}); + +imageChannelRouter.post('/switch', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return; + if (!requireUserSession(req, res) || !requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const fingerprint = req.body?.preflightFingerprint; + if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(fingerprint)) { + res.status(400).json({ error: 'A valid preflight fingerprint is required' }); + return; + } + const result = await ImageOperationService.getInstance().switchToHardened(fingerprint); + if (!result.ok) { + const status = result.code === 'IMAGE_OPERATION_IN_FLIGHT' || result.code === 'preflight_mismatch' ? 409 : 403; + res.status(status).json({ error: 'Image channel switch could not start', code: result.code }); + return; + } + res.status(202).json({ message: 'Image channel switch initiated.' }); +}); + +imageChannelRouter.get('/operations/:operationId', async (req: Request, res: Response): Promise => { + if (!requireUserSession(req, res) || !requireAdmin(req, res)) return; + const operationId = Array.isArray(req.params.operationId) ? req.params.operationId[0] : req.params.operationId; + if (!ImageOperationService.isOperationId(operationId)) { + res.status(400).json({ error: 'Invalid operation id' }); + return; + } + const operation = await ImageOperationService.getInstance().getOperation(operationId); + if (!operation) { + res.status(404).json({ error: 'Image operation not found' }); + return; + } + res.json(operation); +}); + +imageChannelRouter.post('/operations/:operationId/acknowledge', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return; + if (!requireUserSession(req, res) || !requireAdmin(req, res)) return; + const operationId = Array.isArray(req.params.operationId) ? req.params.operationId[0] : req.params.operationId; + if (!ImageOperationService.isOperationId(operationId)) { + res.status(400).json({ error: 'Invalid operation id' }); + return; + } + if (!await ImageOperationService.getInstance().acknowledge(operationId)) { + res.status(409).json({ error: 'Only failed image operations can be acknowledged' }); + return; + } + res.status(204).end(); +}); + diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts index 72000d66..fd3e473b 100644 --- a/backend/src/routes/license.ts +++ b/backend/src/routes/license.ts @@ -1,14 +1,18 @@ import { Router, type Request, type Response } from 'express'; import { LicenseService } from '../services/LicenseService'; import SelfUpdateService from '../services/SelfUpdateService'; -import { requireAdmin } from '../middleware/tierGates'; +import { requireAdmin, requireUserSession } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { parseRequestedTargetVersion } from '../utils/targetVersion'; import type { SelfUpdatePreflight } from '../services/SelfUpdateService'; +import { classifyImageChannel } from '../helpers/imageChannel'; +import { ImageOperationService } from '../services/ImageOperationService'; +import { imageChannelRouter } from './imageChannel'; const LICENSE_SCOPE_MESSAGE = 'API tokens cannot manage licenses.'; export const licenseRouter = Router(); +licenseRouter.use('/image-channel', imageChannelRouter); licenseRouter.get('/', (_req: Request, res: Response): void => { try { @@ -125,8 +129,49 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise< } const targetVersion = parseRequestedTargetVersion(req, res); if (targetVersion === null) return; // invalid supplied value; 400 already sent + const pin = await selfUpdate.getPinInfo({ fresh: true }); + const channel = pin ? classifyImageChannel(pin.composeImageRef) : 'unknown'; + const machineCredential = req.user?.userId === 0 || !!req.apiTokenScope; + if (channel === 'hardened') { + if (machineCredential) { + res.status(403).json({ + error: 'Hardened images can only be updated from an admin session.', + code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED', + }); + return; + } + if (!requireUserSession(req, res)) return; + const preflight = await ImageOperationService.getInstance().preflightSwitch(); + if (!preflight.ok) { + res.status(403).json({ error: 'Hardened image access is unavailable', code: preflight.code }); + return; + } + const result = await ImageOperationService.getInstance().switchToHardened( + preflight.preflightFingerprint, + 'update', + ); + if (!result.ok) { + res.status(result.code === 'IMAGE_OPERATION_IN_FLIGHT' ? 409 : 500) + .json({ error: 'Hardened update could not start', code: result.code }); + return; + } + res.status(202).json({ message: 'Update initiated. The server will restart shortly.' }); + return; + } // Fail fast on a pin we cannot repin (digest/unknown) so the caller gets a // 409 instead of a 202 that would later fail after the reconnect overlay. if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(targetVersion))) return; - scheduleLocalUpdate(res, 'Update initiated. The server will restart shortly.', targetVersion); + const claim = await ImageOperationService.getInstance().claimCommunityUpdate({ targetVersion }); + if (!claim.ok) { + res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode }); + return; + } + res.status(202).json({ message: 'Update initiated. The server will restart shortly.' }); + // Schedule unconditionally: client abort can fire only `close` without `finish`, + // which would otherwise leave the claimed operation stuck in pending_pull. + setTimeout(() => { + ImageOperationService.getInstance().executeClaimedCommunityUpdate({ targetVersion }).catch(error => { + console.error('[ImageOperation] Unexpected community update failure:', error); + }); + }, 500); }); diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index 6d846210..93dcba01 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry'; +import { classifyImageChannel } from '../helpers/imageChannel'; import { isRepinBlocked } from '../helpers/selfUpdateCompose'; import { MeshService } from '../services/MeshService'; import SelfUpdateService from '../services/SelfUpdateService'; @@ -31,9 +32,10 @@ metaRouter.get('/health', (_req: Request, res: Response): void => { // connection tests. // // Image-pin fields are intentionally limited to the non-sensitive subset: -// `imagePinKind` (a bounded enum) and `updateBlocked` (a boolean). The full -// composeImageRef is NEVER exposed here because this endpoint is public and the -// ref can carry a private registry/repository name. +// `imagePinKind` (a bounded enum), `updateBlocked` (a boolean), and +// `imageChannel` (community|hardened|unknown). The full composeImageRef is +// NEVER exposed here because this endpoint is public and the ref can carry a +// private registry/repository name. metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { const selfUpdate = SelfUpdateService.getInstance(); const updateError = selfUpdate.getLastError(); @@ -44,8 +46,11 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { capabilities: getActiveCapabilities(), startedAt: processStartedAt, experimental: process.env.SENCHO_EXPERIMENTAL === 'true', - ...(pin ? { imagePinKind: pin.pinKind } : {}), + ...(pin ? { + imagePinKind: pin.pinKind, + imageChannel: classifyImageChannel(pin.composeImageRef), + } : {}), updateBlocked, - ...(updateError ? { updateError } : {}), + ...(updateError ? { updateError: 'update_failed' } : {}), }); }); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 5b5a9d69..81766785 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -118,6 +118,11 @@ export interface RemoteMeta { imagePinKind: ImagePinKind | null; /** True when the remote reports its update is blocked (digest/unknown pin). */ updateBlocked: boolean; + /** + * Coarse image channel from the remote public meta. Null when the remote is + * older than this field or offline. Safe to expose (no private repository path). + */ + imageChannel: 'community' | 'hardened' | 'unknown' | null; } // Runtime capability overrides; services call disableCapability() during init. @@ -166,8 +171,14 @@ export const OFFLINE_META: RemoteMeta = { online: false, imagePinKind: null, updateBlocked: false, + imageChannel: null, }; +function parseImageChannel(value: unknown): RemoteMeta['imageChannel'] { + if (value === 'community' || value === 'hardened' || value === 'unknown') return value; + return null; +} + /** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */ function redactUrlCredentials(url: string): string { return url.replace(/(\/\/)[^/@]*@/, '$1'); @@ -190,6 +201,7 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis online: true, imagePinKind: parseImagePinKind(res.data.imagePinKind), updateBlocked: res.data.updateBlocked === true, + imageChannel: parseImageChannel(res.data.imageChannel), }; if (isDebugEnabled()) { // Diagnostic aid for "why is this feature gated?": log the resolved version diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts index 12ac72a0..156e561c 100644 --- a/backend/src/services/CloudBackupService.ts +++ b/backend/src/services/CloudBackupService.ts @@ -186,7 +186,7 @@ export class CloudBackupService { if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' }; if (LicenseService.getInstance().getTier() !== 'paid') { - return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' }; + return { success: false, error: 'Recovery Vault requires the Admiral tier.' }; } const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT; @@ -212,13 +212,13 @@ export class CloudBackupService { return { success: true, quotaBytes: data.quota_bytes }; } catch (err) { const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error; - return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Sencho Cloud Backup.') }; + return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Recovery Vault.') }; } } public async refreshSenchoCloudBackupCredentials(): Promise { const result = await this.provisionSenchoCloudBackup(); - if (!result.success) throw new Error(result.error || 'Failed to refresh Sencho Cloud Backup credentials.'); + if (!result.success) throw new Error(result.error || 'Failed to refresh Recovery Vault credentials.'); } public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> { diff --git a/backend/src/services/FleetUpdateTrackerService.ts b/backend/src/services/FleetUpdateTrackerService.ts index 3854457c..8848cb12 100644 --- a/backend/src/services/FleetUpdateTrackerService.ts +++ b/backend/src/services/FleetUpdateTrackerService.ts @@ -3,6 +3,8 @@ export interface UpdateTracker { startedAt: number; previousVersion: string | null; error?: string; + /** Machine-readable failure code returned by a remote update request. */ + code?: string; /** Process start time of the remote node before the update was triggered. */ previousProcessStart: number | null; /** True when the node became unreachable at least once during the update window. */ @@ -65,6 +67,7 @@ export class FleetUpdateTrackerService { previousVersion: string | null, previousProcessStart: number | null, error?: string, + code?: string, ): UpdateTracker { const now = Date.now(); return { @@ -74,6 +77,7 @@ export class FleetUpdateTrackerService { previousProcessStart, wasOffline: false, error, + code, resolvedAt: status !== 'updating' ? now : undefined, }; } diff --git a/backend/src/services/HardenedEntitlementService.ts b/backend/src/services/HardenedEntitlementService.ts new file mode 100644 index 00000000..24d91f9c --- /dev/null +++ b/backend/src/services/HardenedEntitlementService.ts @@ -0,0 +1,195 @@ +import crypto from 'crypto'; +import axios from 'axios'; +import { validateAllowedImageRefAgainstRequirement } from '../helpers/allowedImageRef'; +import { DatabaseService } from './DatabaseService'; +import type { + HardenedEntitlement, + HardenedEntitlementErrorCode, + HardenedEntitlementPurpose, + HardenedEntitlementResult, +} from './hardenedEntitlementTypes'; + +const ASSURANCE_API_DEFAULT = 'https://sencho.io'; +const STATUS_CACHE_TTL_MS = 5 * 60 * 1000; +const MAX_BODY_BYTES = 256 * 1024; + +interface CacheEntry { + expiresAt: number; + result: HardenedEntitlementResult; +} + +export class HardenedEntitlementService { + private static instance: HardenedEntitlementService; + private statusCache = new Map(); + + private constructor() {} + + public static getInstance(): HardenedEntitlementService { + if (!HardenedEntitlementService.instance) { + HardenedEntitlementService.instance = new HardenedEntitlementService(); + } + return HardenedEntitlementService.instance; + } + + public invalidateCache(): void { + this.statusCache.clear(); + } + + public async getEntitlement( + purpose: HardenedEntitlementPurpose, + requestedVersion?: string, + ): Promise { + // Dynamic import avoids a circular dependency: LicenseService imports this service. + const { LicenseService } = await import('./LicenseService'); + if (LicenseService.getInstance().getTier() !== 'paid') { + return { success: false, code: 'unauthorized' }; + } + const db = DatabaseService.getInstance(); + const licenseKey = db.getSystemState('license_key'); + const instanceId = db.getSystemState('instance_id'); + if (!licenseKey || !instanceId) return { success: false, code: 'unauthorized' }; + + const cacheKey = this.cacheKey(licenseKey, instanceId); + if (purpose === 'status') { + const cached = this.statusCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.result; + this.statusCache.delete(cacheKey); + } + + const result = this.getStubResponse() + ?? await this.fetchEntitlement(licenseKey, instanceId, purpose, requestedVersion); + + if (purpose === 'status') { + this.statusCache.set(cacheKey, { result, expiresAt: Date.now() + STATUS_CACHE_TTL_MS }); + } + return result; + } + + private async fetchEntitlement( + licenseKey: string, + instanceId: string, + purpose: HardenedEntitlementPurpose, + requestedVersion?: string, + ): Promise { + const apiBase = process.env.SENCHO_ASSURANCE_API || ASSURANCE_API_DEFAULT; + if (!isSecureAssuranceApi(apiBase)) return { success: false, code: 'unavailable' }; + + try { + const response = await axios.post( + `${apiBase.replace(/\/$/, '')}/api/assurance/hardened-entitlement`, + { + license_key: licenseKey, + instance_id: instanceId, + purpose, + ...(requestedVersion ? { requested_version: requestedVersion } : {}), + }, + { + timeout: 15000, + maxRedirects: 0, + maxContentLength: MAX_BODY_BYTES, + maxBodyLength: MAX_BODY_BYTES, + validateStatus: () => true, + }, + ); + + if (response.status < 200 || response.status >= 300) { + return { success: false, code: statusToErrorCode(response.status) }; + } + + const entitlement = parseEntitlement(response.data); + return entitlement + ? { success: true, entitlement } + : { success: false, code: 'unavailable' }; + } catch { + return { success: false, code: 'unavailable' }; + } + } + + private getStubResponse(): HardenedEntitlementResult | null { + // Stub is non-production/test-only; ignore it entirely in production. + if (process.env.NODE_ENV === 'production') return null; + const stub = process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB; + if (!stub) return null; + if (stub === '1' || stub === 'entitled') { + return { success: true, entitlement: entitledStubFixture() }; + } + if (stub === 'unauthorized') return { success: false, code: 'unauthorized' }; + return { success: false, code: 'unavailable' }; + } + + private cacheKey(licenseKey: string, instanceId: string): string { + const fingerprint = crypto.createHash('sha256').update(licenseKey).digest('hex'); + return `${fingerprint}:${instanceId}`; + } +} + +function parseEntitlement(value: unknown): HardenedEntitlement | null { + if (!isRecord(value) || value.hardened_build_access !== true || value.channel !== 'hardened') return null; + if (typeof value.allowed_image_ref !== 'string' + || typeof value.pin_recommendation !== 'string' + || typeof value.checked_at !== 'string' + || !isRecord(value.registry_requirement) + || typeof value.registry_requirement.registry_host !== 'string' + || typeof value.registry_requirement.package_scope !== 'string' + || typeof value.registry_requirement.credential_instructions !== 'string' + || typeof value.registry_requirement.supports_pull_token !== 'boolean') { + return null; + } + + const entitlement: HardenedEntitlement = { + hardened_build_access: true, + channel: 'hardened', + allowed_image_ref: value.allowed_image_ref, + pin_recommendation: value.pin_recommendation, + registry_requirement: { + registry_host: value.registry_requirement.registry_host, + package_scope: value.registry_requirement.package_scope, + credential_instructions: value.registry_requirement.credential_instructions, + supports_pull_token: value.registry_requirement.supports_pull_token, + }, + checked_at: value.checked_at, + }; + return validateAllowedImageRefAgainstRequirement( + entitlement.allowed_image_ref, + entitlement.registry_requirement, + ) ? entitlement : null; +} + +function statusToErrorCode(status: number): HardenedEntitlementErrorCode { + if (status === 401 || status === 403) return 'unauthorized'; + if (status === 404) return 'unpublished'; + if (status === 410) return 'expired'; + return 'unavailable'; +} + +function entitledStubFixture(): HardenedEntitlement { + return { + hardened_build_access: true, + channel: 'hardened', + allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + pin_recommendation: 'Use the supplied digest.', + registry_requirement: { + registry_host: 'ghcr.io', + package_scope: 'studio-saelix/sencho-hardened', + credential_instructions: 'Create a pull token.', + supports_pull_token: true, + }, + checked_at: new Date().toISOString(), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isSecureAssuranceApi(apiBase: string): boolean { + try { + const url = new URL(apiBase); + const isLoopback = url.hostname === 'localhost' + || url.hostname === '127.0.0.1' + || url.hostname === '::1'; + return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback); + } catch { + return false; + } +} diff --git a/backend/src/services/ImageOperationService.ts b/backend/src/services/ImageOperationService.ts new file mode 100644 index 00000000..e0702f76 --- /dev/null +++ b/backend/src/services/ImageOperationService.ts @@ -0,0 +1,534 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import SelfUpdateService from './SelfUpdateService'; +import { HardenedEntitlementService } from './HardenedEntitlementService'; +import { RegistryService } from './RegistryService'; +import type { ImagePinKind } from '../helpers/selfUpdateCompose'; +import type { LocalRegistryAccess } from './hardenedEntitlementTypes'; +import { getAuthToken, httpRequest } from './registry-api'; + +export type ImageOperationKind = 'switch' | 'update' | 'community_update'; +export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed'; +type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart'; + +export interface ImageOperation { + schemaVersion: 1; + operationId: string; + kind: ImageOperationKind; + state: ImageOperationState; + previousImageRef: string | null; + targetImageRef: string | null; + composeFilePath: string | null; + serviceName: string | null; + startedAt: string; + resolvedAt?: string; + acknowledgedAt?: string; + failureCode?: FailureCode; + rollback: { attempted: false }; + preflightFingerprint?: string; +} + +export type HardenedPreflight = + | { ok: true; preflightFingerprint: string; currentImageRef: string; allowedImageRef: string; composeFilePath: string; pinKind: ImagePinKind; localRegistryAccess: LocalRegistryAccess } + | { ok: false; code: FailureCode | 'entitlement_denied' }; + +const OPERATION_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class ImageOperationService { + private static instance: ImageOperationService; + private claimed = false; + private stateWriteQueue: Promise = Promise.resolve(); + + public static getInstance(): ImageOperationService { + if (!ImageOperationService.instance) ImageOperationService.instance = new ImageOperationService(); + return ImageOperationService.instance; + } + + public static isOperationId(operationId: string): boolean { + return OPERATION_ID_RE.test(operationId); + } + + public computePreflightFingerprint(composePath: string, currentImage: string, pinKind: ImagePinKind, allowedImageRef: string): string { + return crypto.createHash('sha256') + .update(JSON.stringify({ composePath, currentImage, pinKind, allowedImageRef })) + .digest('hex'); + } + + public async preflightSwitch(): Promise { + const entitlement = await HardenedEntitlementService.getInstance().getEntitlement('switch'); + if (!entitlement.success) return { ok: false, code: 'entitlement_denied' }; + + const resolved = await SelfUpdateService.getInstance().getResolvedComposeImageForUpdate(); + if (!resolved) return { ok: false, code: 'compose_unavailable' }; + const localRegistryAccess = await this.getRegistryAccess( + entitlement.entitlement.registry_requirement.registry_host, + entitlement.entitlement.registry_requirement.package_scope, + ); + return { + ok: true, + preflightFingerprint: this.computePreflightFingerprint( + resolved.filePath, + resolved.imageRef, + resolved.pinKind, + entitlement.entitlement.allowed_image_ref, + ), + currentImageRef: resolved.imageRef, + allowedImageRef: entitlement.entitlement.allowed_image_ref, + composeFilePath: resolved.filePath, + pinKind: resolved.pinKind, + localRegistryAccess, + }; + } + + public async switchToHardened( + preflightFingerprint: string, + kind: 'switch' | 'update' = 'switch', + ): Promise<{ ok: boolean; code?: FailureCode | 'IMAGE_OPERATION_IN_FLIGHT' | 'preflight_mismatch' }> { + const entitlement = await HardenedEntitlementService.getInstance().getEntitlement('switch'); + if (!entitlement.success) return { ok: false, code: 'entitlement_denied' }; + const selfUpdate = SelfUpdateService.getInstance(); + const resolved = await selfUpdate.getResolvedComposeImageForUpdate(); + const serviceName = selfUpdate.getComposeServiceName(); + if (!resolved || !serviceName) return { ok: false, code: 'compose_unavailable' }; + + const fingerprint = this.computePreflightFingerprint( + resolved.filePath, + resolved.imageRef, + resolved.pinKind, + entitlement.entitlement.allowed_image_ref, + ); + if (fingerprint !== preflightFingerprint) return { ok: false, code: 'preflight_mismatch' }; + + const operation = this.newOperation(kind, resolved.imageRef, entitlement.entitlement.allowed_image_ref, resolved.filePath, serviceName, fingerprint); + if (!await this.tryClaim(operation)) return { ok: false, code: 'IMAGE_OPERATION_IN_FLIGHT' }; + + try { + const config = await RegistryService.getInstance().resolveDockerConfigForHost( + entitlement.entitlement.registry_requirement.registry_host, + ); + if (Object.keys(config.config.auths).length === 0) { + await this.fail(operation, 'registry_access_unavailable'); + return { ok: false, code: 'registry_access_unavailable' }; + } + const configDir = await this.writeDockerConfig(operation.operationId, config.config); + operation.state = 'pulling'; + await this.persist(operation); + // Register before triggerUpdate so a fast helper exit cannot drain listeners first. + this.watchHelperExit(operation); + await selfUpdate.triggerUpdate({ + targetImageRef: operation.targetImageRef!, + dockerConfigPath: configDir, + successMarkerFile: this.successMarkerFile(operation), + successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }), + }); + if (selfUpdate.getLastError()) { + await this.fail(operation, 'update_failed'); + return { ok: false, code: 'update_failed' }; + } + operation.state = 'recreating'; + await this.persist(operation); + if (selfUpdate.getLastError()) { + await this.fail(operation, 'update_failed'); + return { ok: false, code: 'update_failed' }; + } + return { ok: true }; + } catch (error) { + console.error('[ImageOperation] Hardened switch failed:', error); + await this.fail(operation, 'update_failed'); + return { ok: false, code: 'update_failed' }; + } finally { + this.claimed = false; + } + } + + public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise< + { ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' } + > { + const selfUpdate = SelfUpdateService.getInstance(); + const resolved = await selfUpdate.getResolvedComposeImageForUpdate(); + const operation = this.newOperation( + 'community_update', + resolved?.imageRef ?? null, + options?.targetVersion ?? null, + resolved?.filePath ?? null, + selfUpdate.getComposeServiceName(), + ); + if (!await this.tryClaim(operation)) { + return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }; + } + // Disk non-terminal state is the concurrency lock; clear the in-memory mutex + // so a later claim can observe the persisted pending operation. + this.claimed = false; + return { ok: true }; + } + + public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> { + const operation = await this.getCurrentOperation(); + if (!operation || operation.kind !== 'community_update') { + return { ok: false, failureCode: 'update_failed' }; + } + if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) { + return { ok: false, failureCode: 'update_failed' }; + } + const selfUpdate = SelfUpdateService.getInstance(); + try { + operation.state = 'pulling'; + await this.persist(operation); + // Register before triggerUpdate so a fast helper exit cannot drain listeners first. + this.watchHelperExit(operation); + await selfUpdate.triggerUpdate({ + ...options, + successMarkerFile: this.successMarkerFile(operation), + successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }), + }); + if (selfUpdate.getLastError()) { + await this.fail(operation, 'update_failed'); + return { ok: false, failureCode: 'update_failed' }; + } + operation.state = 'recreating'; + await this.persist(operation); + if (selfUpdate.getLastError()) { + await this.fail(operation, 'update_failed'); + return { ok: false, failureCode: 'update_failed' }; + } + return { ok: true }; + } catch (error) { + console.error('[ImageOperation] Community update failed:', error); + await this.fail(operation, 'update_failed'); + return { ok: false, failureCode: 'update_failed' }; + } + } + + public async runCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> { + const claim = await this.claimCommunityUpdate(options); + if (!claim.ok) return claim; + return this.executeClaimedCommunityUpdate(options); + } + + public async getOperation(operationId: string): Promise { + const filePath = this.operationFile(operationId); + if (!filePath) return null; + return this.readOperation(filePath); + } + + public async getCurrentOperation(): Promise { + return this.readOperation(this.currentFile()); + } + + public async acknowledge(operationId: string): Promise { + const operation = await this.getOperation(operationId); + if (!operation || operation.state !== 'failed') return false; + operation.acknowledgedAt = new Date().toISOString(); + // Write the history file always; only refresh the global current pointer when + // this operation is still current. Otherwise acknowledging a stale failure can + // replace an active claim and bypass single-flight locking. + await this.enqueueStateWrite(async () => { + const filePath = this.operationFile(operation.operationId); + if (!filePath) throw new Error('Invalid image operation id'); + await fs.mkdir(this.operationsDir(), { recursive: true, mode: 0o700 }); + await this.atomicWrite(filePath, JSON.stringify(operation)); + const current = await this.readOperation(this.currentFile()); + if (current?.operationId === operation.operationId) { + await this.atomicWrite(this.currentFile(), JSON.stringify(operation)); + } + }); + return true; + } + + public async reconcileOnStartup(): Promise { + const operation = await this.getCurrentOperation(); + if (!operation || !['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return; + const markerPath = this.successMarkerFile(operation); + for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) { + const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId); + if (operation.kind === 'community_update') { + // Community success is the marker alone; floating tags may not equal targetImageRef. + if (markerOk) { + operation.state = 'succeeded'; + operation.resolvedAt = new Date().toISOString(); + await this.persist(operation); + await this.cleanupOperationArtifacts(operation); + return; + } + } else { + const pinMatchesTarget = (await SelfUpdateService.getInstance().getPinInfo({ fresh: true }))?.composeImageRef === operation.targetImageRef; + if (markerOk && pinMatchesTarget) { + operation.state = 'succeeded'; + operation.resolvedAt = new Date().toISOString(); + await this.persist(operation); + await this.cleanupOperationArtifacts(operation); + return; + } + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + await this.fail(operation, 'interrupted_by_restart'); + } + + private watchHelperExit(operation: ImageOperation): void { + SelfUpdateService.getInstance().onceHelperExit((error) => { + // Surviving the helper callback means recreate did not take over; treat + // a null payload the same as an explicit survival failure. + const exitError = error ?? 'Helper container exited without restarting Sencho'; + void (async () => { + const current = await this.getCurrentOperation(); + if (!current || current.operationId !== operation.operationId) return; + if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(current.state)) return; + console.error('[ImageOperation] Helper exit while operation active:', exitError); + await this.fail(current, 'update_failed'); + })().catch((listenerError) => { + console.error('[ImageOperation] Helper exit terminalization failed:', listenerError); + }); + }); + } + + private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation { + return { + schemaVersion: 1, + operationId: crypto.randomUUID(), + kind, + state: 'pending_pull', + previousImageRef, + targetImageRef, + composeFilePath, + serviceName, + startedAt: new Date().toISOString(), + rollback: { attempted: false }, + ...(preflightFingerprint ? { preflightFingerprint } : {}), + }; + } + + private async tryClaim(operation: ImageOperation): Promise { + if (this.claimed) return false; + this.claimed = true; + try { + const current = await this.getCurrentOperation(); + if (current && ['pending_pull', 'pulling', 'patching', 'recreating'].includes(current.state)) { + this.claimed = false; + return false; + } + await this.removeLegacySuccessMarkers(); + await this.persist(operation); + return true; + } catch (error) { + this.claimed = false; + throw error; + } + } + + private enqueueStateWrite(fn: () => Promise): Promise { + const run = this.stateWriteQueue.then(fn, fn); + this.stateWriteQueue = run.then(() => undefined, () => undefined); + return run; + } + + private async fail(operation: ImageOperation, failureCode: FailureCode): Promise { + operation.state = 'failed'; + operation.failureCode = failureCode; + operation.resolvedAt = new Date().toISOString(); + await this.persist(operation); + await this.cleanupOperationArtifacts(operation); + } + + private async persist(operation: ImageOperation): Promise { + await this.enqueueStateWrite(async () => { + const current = await this.readOperation(this.currentFile()); + const incomingTerminal = operation.state === 'failed' || operation.state === 'succeeded'; + const currentTerminal = current?.state === 'failed' || current?.state === 'succeeded'; + // Do not let a late non-terminal write (e.g. recreating) overwrite a + // concurrent helper-exit failure for the same operation. + if ( + current + && current.operationId === operation.operationId + && currentTerminal + && !incomingTerminal + ) { + return; + } + const filePath = this.operationFile(operation.operationId); + if (!filePath) throw new Error('Invalid image operation id'); + await fs.mkdir(this.operationsDir(), { recursive: true, mode: 0o700 }); + await this.atomicWrite(filePath, JSON.stringify(operation)); + await this.atomicWrite(this.currentFile(), JSON.stringify(operation)); + }); + } + + private async readOperation(filePath: string): Promise { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as ImageOperation; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + const code = (error as NodeJS.ErrnoException).code ?? 'unknown'; + console.error(`[ImageOperation] Failed to read operation (${code})`); + return null; + } + } + + private async atomicWrite(filePath: string, content: string): Promise { + const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 }); + await fs.rename(temporary, filePath); + await fs.chmod(filePath, 0o600); + } + + private async writeDockerConfig( + operationId: string, + config: { auths?: Record }, + ): Promise { + const configDir = this.resolveUnderBase(path.join(this.dataDir(), 'image-op-docker'), operationId); + if (!configDir) throw new Error('Invalid image operation id'); + await fs.mkdir(configDir, { recursive: true, mode: 0o700 }); + await fs.chmod(configDir, 0o700); + // Allowlist-copy host keys and base64 auth tokens, then build JSON locally so + // the on-disk DOCKER_CONFIG payload is not a direct write of network data. + const parts: string[] = []; + for (const [host, entry] of Object.entries(config.auths ?? {})) { + const safeHost = this.allowlistedRegistryHostKey(host); + const safeAuth = entry?.auth ? this.allowlistedBase64(entry.auth) : null; + if (!safeHost || !safeAuth) continue; + parts.push(`${JSON.stringify(safeHost)}:${JSON.stringify({ auth: safeAuth })}`); + } + const payload = `{"auths":{${parts.join(',')}}}`; + await this.atomicWrite(path.join(configDir, 'config.json'), payload); + return configDir; + } + + /** Copy a Docker auth host key char-by-char through an allowlist. */ + private allowlistedRegistryHostKey(host: string): string | null { + if (host.length === 0 || host.length > 512) return null; + let out = ''; + for (let i = 0; i < host.length; i++) { + const code = host.charCodeAt(i); + const ok = + (code >= 48 && code <= 57) + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122) + || code === 43 || code === 45 || code === 46 + || code === 47 || code === 58 || code === 95; + if (!ok) return null; + out += String.fromCharCode(code); + } + return out; + } + + /** Copy a base64 auth token char-by-char through an allowlist. */ + private allowlistedBase64(value: string): string | null { + if (value.length === 0 || value.length > 8192) return null; + let out = ''; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + const ok = + (code >= 48 && code <= 57) + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122) + || code === 43 || code === 47 || code === 61; + if (!ok) return null; + out += String.fromCharCode(code); + } + return out; + } + + private async getRegistryAccess(registryHost: string, packageScope: string): Promise { + try { + const config = await RegistryService.getInstance().resolveDockerConfigForHost(registryHost); + const auth = Object.values(config.config.auths)[0]?.auth; + if (!auth) return 'missing'; + const decoded = Buffer.from(auth, 'base64').toString('utf8'); + const delimiter = decoded.indexOf(':'); + if (delimiter === -1) return 'rejected'; + const credentials = { + username: decoded.slice(0, delimiter), + password: decoded.slice(delimiter + 1), + }; + const token = await getAuthToken(registryHost, packageScope, credentials); + const headers: Record = { + Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json', + Authorization: token + ? `Bearer ${token}` + : `Basic ${auth}`, + }; + const manifestUrl = `https://${registryHost}/v2/${packageScope}/manifests/latest`; + let response = await httpRequest(manifestUrl, 'HEAD', headers); + if (response.statusCode === 405 || response.statusCode === 501) { + response = await httpRequest(manifestUrl, 'GET', headers); + } + return response.statusCode === 200 ? 'ready' : 'rejected'; + } catch { + console.warn('[ImageOperation] Registry package probe failed'); + return 'rejected'; + } + } + + private dataDir(): string { + return process.env.DATA_DIR || '/app/data'; + } + + private operationsDir(): string { + return path.join(this.dataDir(), 'image-operations'); + } + + /** Resolve a path under baseDir; null if operationId is invalid or escapes the base. */ + private resolveUnderBase(baseDir: string, operationId: string): string | null { + if (!ImageOperationService.isOperationId(operationId)) return null; + const base = path.resolve(baseDir); + const candidate = path.resolve(base, operationId); + if (candidate !== base && !candidate.startsWith(base + path.sep)) return null; + return candidate; + } + + private operationFile(operationId: string): string | null { + if (!ImageOperationService.isOperationId(operationId)) return null; + const base = path.resolve(this.operationsDir()); + const candidate = path.resolve(base, `${operationId}.json`); + if (!candidate.startsWith(base + path.sep)) return null; + return candidate; + } + + private currentFile(): string { + return path.join(this.dataDir(), 'image-operation-current.json'); + } + + private successMarkerFile(operation: ImageOperation): string { + if (!ImageOperationService.isOperationId(operation.operationId)) { + throw new Error('Invalid image operation id'); + } + return path.join(this.dataDir(), `image-op-success-${operation.operationId}.json`); + } + + private async removeLegacySuccessMarkers(): Promise { + await Promise.all([ + fs.rm(path.join(this.dataDir(), 'image-op-success.json'), { force: true }), + fs.rm(path.join(this.dataDir(), 'hardened-switch-success.json'), { force: true }), + ]); + } + + private async cleanupOperationArtifacts(operation: ImageOperation): Promise { + try { + const dockerDir = this.resolveUnderBase(path.join(this.dataDir(), 'image-op-docker'), operation.operationId); + await Promise.all([ + dockerDir ? fs.rm(dockerDir, { recursive: true, force: true }) : Promise.resolve(), + fs.rm(this.successMarkerFile(operation), { force: true }), + ]); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? 'unknown'; + console.error(`[ImageOperation] Failed to clean operation artifacts (${code})`); + } + } + + private async isSuccessMarkerForOperation(markerPath: string, operationId: string): Promise { + try { + const marker: unknown = JSON.parse(await fs.readFile(markerPath, 'utf8')); + return typeof marker === 'object' + && marker !== null + && 'operationId' in marker + && marker.operationId === operationId; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[ImageOperation] Failed to read success marker:', error); + } + return false; + } + } +} diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index a40cf4d5..7c714caa 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import axios from 'axios'; import { DatabaseService } from './DatabaseService'; +import { HardenedEntitlementService } from './HardenedEntitlementService'; import type { LicenseInfo, LicenseStatus, @@ -231,6 +232,7 @@ export class LicenseService { private setLicenseStatus(status: LicenseStatus): void { DatabaseService.getInstance().setSystemState('license_status', status); this.cachedProxyHeaders = null; + HardenedEntitlementService.getInstance().invalidateCache(); } /** diff --git a/backend/src/services/RegistryService.ts b/backend/src/services/RegistryService.ts index f7b6e3ad..c909b556 100644 --- a/backend/src/services/RegistryService.ts +++ b/backend/src/services/RegistryService.ts @@ -392,6 +392,24 @@ export class RegistryService { return { config: { auths }, warnings }; } + /** Resolve a Docker config containing credentials for one registry host only. */ + public async resolveDockerConfigForHost(registryHost: string): Promise { + const auth = await this.getAuthForRegistry(registryHost); + if (!auth) return { config: { auths: {} }, warnings: [] }; + + const normalized = normalizeImageHost(registryHost); + return { + config: { + auths: { + [normalized]: { + auth: Buffer.from(`${auth.username}:${auth.password}`).toString('base64'), + }, + }, + }, + warnings: [], + }; + } + /** * Resolve credentials for a specific registry row by ID only. diff --git a/backend/src/services/SelfUpdateService.ts b/backend/src/services/SelfUpdateService.ts index 0c86dfba..b0568277 100644 --- a/backend/src/services/SelfUpdateService.ts +++ b/backend/src/services/SelfUpdateService.ts @@ -128,6 +128,8 @@ export function buildSelfUpdateComposeCmd( errorFile: string, pruneOnUpdate: boolean, composeCopy?: ComposeCopy, + successMarkerFile?: string, + successMarkerContent = '{"ok":true}', ): string { const recreate = ['docker compose', ...fFlags.map(shQuote), 'up -d --force-recreate', shQuote(serviceName), `2>${stderrTmp}`].join(' '); const copyStep = composeCopy @@ -142,6 +144,9 @@ export function buildSelfUpdateComposeCmd( ...(pruneOnUpdate ? [`if [ $ec -eq 0 ]; then docker image prune -f >/dev/null 2>&1 || true; fi`] : []), + ...(successMarkerFile + ? [`if [ $ec -eq 0 ]; then printf %s ${shQuote(successMarkerContent)} > ${shQuote(successMarkerFile)}; fi`] + : []), `cat ${stderrTmp} >&2 2>/dev/null`, 'exit $ec', ].join('; '); @@ -205,6 +210,9 @@ class SelfUpdateService { private canSelfUpdate = false; private composeContext: ComposeContext | null = null; private lastUpdateError: string | null = null; + /** Stashed when the helper exits before any onceHelperExit listener is registered. */ + private pendingHelperExitError: string | undefined = undefined; + private helperExitListeners: Array<(error: string | null) => void> = []; private pinCache: { info: ResolvedComposeImage | null; at: number } | null = null; public static getInstance(): SelfUpdateService { @@ -305,6 +313,23 @@ class SelfUpdateService { return this.lastUpdateError; } + /** Register a one-shot listener for the helper container's execFile callback. */ + onceHelperExit(listener: (error: string | null) => void): void { + if (this.pendingHelperExitError !== undefined) { + const error = this.pendingHelperExitError; + this.pendingHelperExitError = undefined; + queueMicrotask(() => { + try { + listener(error); + } catch (listenerError) { + console.error('[SelfUpdate] Helper exit listener failed:', listenerError); + } + }); + return; + } + this.helperExitListeners.push(listener); + } + /** Clears the stored update error (call after reading it). */ clearLastError(): void { this.lastUpdateError = null; @@ -322,6 +347,15 @@ class SelfUpdateService { return { pinKind: resolved.pinKind, composeImageRef: resolved.imageRef, filePath: resolved.filePath }; } + /** Fresh compose image resolution for guarded image-channel operations. */ + async getResolvedComposeImageForUpdate(): Promise { + return this.resolveComposeImage(true); + } + + getComposeServiceName(): string | null { + return this.composeContext?.serviceName ?? null; + } + /** * Preflight the route layer runs before responding, so a blocked update fails * fast with a 409 instead of returning 202 and stalling the reconnect overlay. @@ -401,37 +435,50 @@ class SelfUpdateService { * target; when omitted this keeps the legacy behavior of pulling the running * image and recreating from the on-disk compose. */ - async triggerUpdate(options?: { targetVersion?: string }): Promise { + async triggerUpdate(options?: { + targetVersion?: string; + targetImageRef?: string; + dockerConfigPath?: string; + successMarkerFile?: string; + successMarkerContent?: string; + }): Promise { if (!this.composeContext) return; - const env = this.buildEnv(); + const env = { + ...this.buildEnv(), + ...(options?.dockerConfigPath ? { DOCKER_CONFIG: options.dockerConfigPath } : {}), + }; this.lastUpdateError = null; + this.pendingHelperExitError = undefined; try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ } try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ } const { imageName, serviceName, dataDirHost } = this.composeContext; const targetVersion = options?.targetVersion; + const targetImageRef = options?.targetImageRef; let pullRef = imageName; let repin: { resolved: ResolvedComposeImage; ref: string } | null = null; - if (targetVersion) { + if (targetVersion || targetImageRef) { const resolved = await this.resolveComposeImage(true); const pinKind = resolved?.pinKind ?? 'unknown'; // Defense in depth: the route preflight already rejected these, but the // compose file could change between preflight and this last-breath call. - if (!resolved || isRepinBlocked(pinKind)) { + if (!resolved || (!targetImageRef && isRepinBlocked(pinKind))) { this.lastUpdateError = resolved ? UPDATE_BLOCKED_REASON : UPDATE_READ_FAILED_REASON; console.error('[SelfUpdate] Update blocked:', this.lastUpdateError); return; } - pullRef = pinKind === 'semver' ? buildTargetImageRef(resolved.imageRef, targetVersion) : resolved.imageRef; + pullRef = targetImageRef ?? (pinKind === 'semver' + ? buildTargetImageRef(resolved.imageRef, targetVersion!) + : resolved.imageRef); if (!isValidImageRef(pullRef)) { this.lastUpdateError = 'Aborting update: the computed image reference is invalid.'; console.error('[SelfUpdate] Update blocked:', this.lastUpdateError, pullRef); return; } - if (pinKind === 'semver') { + if (pinKind === 'semver' || targetImageRef) { if (!dataDirHost) { this.lastUpdateError = 'Cannot rewrite the pinned compose image: the data directory needed for the update handoff is not mounted. Change the image tag manually and update again.'; @@ -477,7 +524,7 @@ class SelfUpdateService { } } - this.spawnHelper(env, composeCopy); + this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent); } /** @@ -486,7 +533,12 @@ class SelfUpdateService { * Runs attached (no -d): if the recreate fails before it kills us, execFile's * callback receives the helper's exit code and stderr directly. */ - private spawnHelper(env: NodeJS.ProcessEnv, composeCopy?: ComposeCopy): void { + private spawnHelper( + env: NodeJS.ProcessEnv, + composeCopy?: ComposeCopy, + successMarkerFile?: string, + successMarkerContent?: string, + ): void { if (!this.composeContext) return; const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext; @@ -501,16 +553,41 @@ class SelfUpdateService { const stderrTmp = '/tmp/_sencho_err'; const pruneOnUpdate = DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'; - const composeCmd = buildSelfUpdateComposeCmd(fFlags, serviceName, stderrTmp, UPDATE_ERROR_FILE, pruneOnUpdate, composeCopy); + const composeCmd = buildSelfUpdateComposeCmd( + fFlags, + serviceName, + stderrTmp, + UPDATE_ERROR_FILE, + pruneOnUpdate, + composeCopy, + successMarkerFile, + successMarkerContent, + ); const args = buildSelfUpdateRunArgs({ workingDir, imageName, dataDirHost, hostBindMounts, repinWritable: !!composeCopy }, composeCmd); // Callback may never fire on success (we die mid-call during recreate); // that is fine because the restart itself is the success signal. + // Surviving a clean helper exit means recreate did not take over this process. execFile('docker', args, { env }, (err, _stdout, stderr) => { if (err) { const stderrText = stderr?.toString().trim(); this.lastUpdateError = stderrText || err.message || 'Helper container failed'; console.error('[SelfUpdate] Helper container failed:', this.lastUpdateError); + } else if (!this.lastUpdateError) { + this.lastUpdateError = 'Helper container exited without restarting Sencho'; + console.error('[SelfUpdate] Helper container exited cleanly without restarting Sencho'); + } + const listeners = this.helperExitListeners.splice(0); + if (listeners.length === 0) { + this.pendingHelperExitError = this.lastUpdateError!; + return; + } + for (const listener of listeners) { + try { + listener(this.lastUpdateError); + } catch (listenerError) { + console.error('[SelfUpdate] Helper exit listener failed:', listenerError); + } } }); // No code after this point is guaranteed to run: the helper recreates this container. diff --git a/backend/src/services/hardenedEntitlementTypes.ts b/backend/src/services/hardenedEntitlementTypes.ts new file mode 100644 index 00000000..4539cd3c --- /dev/null +++ b/backend/src/services/hardenedEntitlementTypes.ts @@ -0,0 +1,36 @@ +export type HardenedEntitlementPurpose = 'status' | 'switch' | 'update'; + +export type HardenedEntitlementErrorCode = + | 'unauthorized' + | 'unpublished' + | 'expired' + | 'unavailable'; + +export type LocalRegistryAccess = 'ready' | 'missing' | 'decrypt_failed' | 'rejected'; + +export interface RegistryRequirement { + registry_host: string; + package_scope: string; + credential_instructions: string; + supports_pull_token: boolean; +} + +export interface HardenedEntitlement { + hardened_build_access: boolean; + channel: 'hardened'; + allowed_image_ref: string; + pin_recommendation: string; + registry_requirement: RegistryRequirement; + checked_at: string; +} + +export interface HardenedEntitlementRequest { + license_key: string; + instance_id: string; + purpose: HardenedEntitlementPurpose; + requested_version?: string; +} + +export type HardenedEntitlementResult = + | { success: true; entitlement: HardenedEntitlement } + | { success: false; code: HardenedEntitlementErrorCode }; diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 2e9ef73a..8d2b854c 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -98,7 +98,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { // Cloud backup 'PUT /cloud-backup/config': 'Updated cloud backup config', 'POST /cloud-backup/test': 'Tested cloud backup connection', - 'POST /cloud-backup/provision': 'Provisioned Sencho Cloud Backup', + 'POST /cloud-backup/provision': 'Provisioned Recovery Vault', 'POST /cloud-backup/upload': 'Uploaded snapshot to cloud', 'DELETE /cloud-backup/object': 'Deleted cloud snapshot', diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 092ed750..40ca3784 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -361,7 +361,7 @@ See [Vulnerability scanning](/features/vulnerability-scanning). - `error`/`system`: `Scheduled task "" () failed: ` - `info`/`system` recovery: `Scheduled task "" () recovered successfully` -### Cloud Backup upload failure +### Recovery Vault upload failure `warning`/`system`: `Cloud backup failed for scheduled snapshot : `. See [Fleet backups](/features/fleet-backups). diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index dd0028e1..59467e2e 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -189,7 +189,7 @@ Regardless of scope, every API token is rejected from the following routes with | **Registry credentials** | View, create, update, delete, test registry creds | | **Host-console session token** | Mint the short-lived ticket the browser console uses | | **API token self-management** | Create, list, revoke API tokens | -| **Cloud Backup** | Configure or trigger Sencho Cloud Backup | +| **Recovery Vault** | Configure or trigger Recovery Vault | The rationale is the same in every case: a programmatic credential should not be able to grant itself more authority, change the trust roots that issued it, or open an interactive shell. Those actions require a live human user logged into a browser. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 92e72257..d5380dfb 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -46,7 +46,7 @@ Expanding a row in the Table view reveals additional detail: - Fleet replica role changes (re-anchor, demote to control) - Fleet Secrets: create, update, delete, import-from-stack, push (and push preview) - Blueprint federation pin updates -- Sencho Cloud Backup: config update, connection test, provisioning, snapshot upload, snapshot deletion +- Recovery Vault: config update, connection test, provisioning, snapshot upload, snapshot deletion - User management: create, update, delete, role assignment and removal - Password changes and node token generation - License activation and deactivation diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index ae6ffeb5..2ed0aa2c 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -78,7 +78,7 @@ The table paginates at eight rows; chevrons appear in the header along with an ` The Configuration Status card is the at-a-glance audit of every toggleable automation and security feature on the active node, so nothing is silently off when you expect it to be on. - Configuration Status card with four sections: Notifications (Notification agents, Alert rules, Notification routing all reading None), Automation (Auto-heal None, Auto-update '1 / 1', Webhooks None, Scheduled tasks '2 actives'), Security (MFA Off, SSO Off, Vulnerability scanning None), and Backups & Thresholds (Cloud Backup 'Sencho Cloud', Alert thresholds 'CPU 100% · RAM 100% · Disk 100%', Crash detection On). + Configuration Status card with four sections: Notifications (Notification agents, Alert rules, Notification routing all reading None), Automation (Auto-heal None, Auto-update '1 / 1', Webhooks None, Scheduled tasks '2 actives'), Security (MFA Off, SSO Off, Vulnerability scanning None), and Backups & Thresholds (Recovery Vault active, Alert thresholds 'CPU 100% · RAM 100% · Disk 100%', Crash detection On). The card is divided into four sections. @@ -112,7 +112,7 @@ The card is divided into four sections. | Row | What it shows | |-----|---------------| -| **Cloud Backup** | The active cloud backup target: `Sencho Cloud` (Admiral only), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` | +| **Recovery Vault** | The active recovery target: `Recovery Vault` (Admiral), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` | | **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%`, or `Off` when host threshold alerts are disabled | | **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise | diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index 43900ac6..6808f9fa 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -99,22 +99,22 @@ Each stack is restored independently: if one stack cannot be restored (for examp Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone. -## Cloud Backup +## Recovery Vault - Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → Infrastructure → Cloud Backup**. + Custom S3-compatible storage is available on every tier. Recovery Vault is an Admiral feature. Configure either in **Settings → Infrastructure → Recovery Vault**. -Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → Infrastructure → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported. +Recovery Vault mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Recovery Vault settings page (reached via **Settings → Infrastructure → Recovery Vault**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported. -### Sencho Cloud Backup (included) +### Recovery Vault (included) -A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Infrastructure → Cloud Backup**, choose **Sencho Cloud Backup (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically. +A managed 500 MB allowance, included with every Admiral subscription. Open **Settings → Infrastructure → Recovery Vault**, choose **Recovery Vault (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically. Once active, the settings page shows a storage gauge (used / 500 MB and object count), a status message confirming auto-upload is on, and a **Reprovision** button to refresh credentials if needed. You can verify connectivity at any time with the **Test** button. - Cloud Backup settings page with Sencho Cloud Backup active, showing the storage gauge, auto-upload status, and the Cloud Snapshots list + Recovery Vault settings page with Recovery Vault active, showing the storage gauge, auto-upload status, and the Cloud Snapshots list ### Custom S3 (BYOB) @@ -142,7 +142,7 @@ To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View. ### Browsing and downloading cloud snapshots -The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes. +The **Cloud Snapshots** panel in **Settings → Infrastructure → Recovery Vault** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes. ### Restoring from a cloud snapshot @@ -152,7 +152,7 @@ For in-place rollback, use the **Restore** action on the snapshot detail view as Every fleet snapshot action requires the **admin** role: viewing the snapshot list, browsing snapshot contents, creating, restoring, deleting, and uploading to the cloud. Because a snapshot captures the `.env` file of every stack, the snapshot list and detail views are restricted to administrators rather than read-only roles. -Cloud Backup configuration is also admin-only. Mirroring to Sencho Cloud Backup additionally requires an Admiral license; a Custom S3-compatible target works on every tier. +Recovery Vault configuration is also admin-only. Mirroring to Recovery Vault additionally requires an Admiral subscription; a Custom S3-compatible target works on every tier. ## Storage @@ -173,11 +173,11 @@ Snapshots are stored in Sencho's SQLite database. Captured file contents, includ Restore all applies each stack independently, so a failure on one stack does not stop the others. A stack is reported as failed when its node has been removed from the fleet since the snapshot was taken, when a remote node is offline or unreachable, or when its existing files cannot be written. The successful stacks are fully restored regardless. Resolve the underlying cause (re-add a removed node, bring an offline node back online) and run Restore all again, or restore the remaining stacks individually from the same snapshot. - + Double-check the Access Key ID, Secret Access Key, and bucket name; one wrong character is the most common cause. Some providers require S3-compatible API access to be enabled on the bucket separately from the credentials. For MinIO, confirm the user has read/write permission on the target bucket. After correcting the values, click **Test** again before saving. - Sencho Cloud Backup carries a 500 MB allowance per license. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control. + Recovery Vault carries a 500 MB allowance per subscription. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → Infrastructure → Recovery Vault**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control. Transient errors surface as a notification and leave the local snapshot in place. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which retries on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host (custom S3 endpoints often sit behind a different DNS or firewall path than the rest of your traffic). diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index a5a4dddd..dff09980 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -49,15 +49,16 @@ See [the pricing page](https://sencho.io/pricing) for current pricing. **Admiral** is the official business assurance plan from Studio Saelix. It includes everything in Community, plus: -- **Assurance and support:** priority email support and Studio Saelix–backed continuity for production fleets +- **Assurance and support:** priority email support and Studio Saelix-backed continuity for production fleets - **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window -- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance) +- **Managed continuity:** Recovery Vault (a managed, off-site snapshot allowance) +- **Hardened Build:** an Admiral image channel with published supply-chain assurance artifacts - **Directory integration:** LDAP / Active Directory authentication - **Current plan availability:** some product surfaces (including AWS ECR credentials) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled. ## Free trial -Sencho offers a **14-day Admiral trial** so you can evaluate Admiral assurance (priority support path, managed Cloud Backup, governance depth including advanced RBAC, LDAP / Active Directory, and audit export) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans. +Sencho offers a **14-day Admiral trial** so you can evaluate Admiral assurance (priority support path, Recovery Vault, Hardened Build, governance depth including advanced RBAC, LDAP / Active Directory, and audit export) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans. To start a trial: @@ -68,7 +69,7 @@ To start a trial: 5. Your license key is emailed to you within a few minutes. 6. Activate the key in the Sencho dashboard as described in [Activating your license](#activating-your-license). -When your trial ends, the card you provided is automatically charged and your plan continues as a paid Admiral subscription. To avoid being charged, cancel from the **Manage subscription** button in **Settings → License** (or from your receipt email) any time before day 14. +When your trial ends, the card you provided is automatically charged and your plan continues as a paid Admiral subscription. To avoid being charged, cancel from the **Manage subscription** button in **Settings → Admiral Account** (or from your receipt email) any time before day 14. Fresh installs land on the **Community** tier until you activate a license key. All Community features (unlimited nodes, compose editor, global logs, alerts, Custom OIDC, and more) are available immediately. @@ -76,7 +77,7 @@ When your trial ends, the card you provided is automatically charged and your pl ## Activating your license -Open **Settings → License** in the Sencho dashboard. When the license is not active, the **Activate** section sits below the Plan card: +Open **Settings → Admiral Account** in the Sencho dashboard. When the license is not active, the **Activate** section sits below the Plan card: The Activate section of the License page on a Community-tier instance, with a License key input field and an Activate button on the right @@ -89,7 +90,7 @@ Sencho validates the key and activates your plan. If activation fails, the toast ## The Plan section -When a license is active, **Settings → License** opens on the **Plan** section. The page masthead at the top exposes three stat pills, and the section below lists the metadata for the active license. +When a license is active, **Settings → Admiral Account** opens on the **Plan** section. The page masthead at the top exposes three stat pills, and the section below lists the metadata for the active license. License page showing an active Sencho Admiral license, with the Plan section listing the Customer, Product, and masked License key fields and a Deactivate button @@ -154,7 +155,7 @@ For details on how this works, see [License enforcement across nodes](/features/ To transfer your license to a different instance: -1. Go to **Settings → License**. +1. Go to **Settings → Admiral Account**. 2. Click **Deactivate** in the Plan section. 3. On your new instance, paste the same license key into **License key** and click **Activate**. diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 3fd408e8..652d5839 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -197,7 +197,7 @@ When you select a remote node in the switcher, the Settings hub filters to the p | Support | Per browser | Diagnostics bundle, docs links, contact channels. | | About | Per browser | Build metadata for whichever instance the page is loaded from. | -Panels that manage control-plane concerns (Account, License, Users, SSO, API Tokens, Registries, Cloud Backup, Nodes, Routing, Webhooks) are hidden when a remote node is active. +Panels that manage control-plane concerns (Account, Admiral Account, Users, SSO, API Tokens, Registries, Recovery Vault, Nodes, Routing, Webhooks) are hidden when a remote node is active. ## License enforcement across nodes @@ -325,6 +325,6 @@ Sencho takes the opposite approach: infrastructure-level encryption (VPN, revers The control instance's license tier is authoritative for proxied requests, so a Community control plane gates Admiral features on every remote, even if the remote itself has its own Admiral license. Activate an Admiral license on the control instance to lift the gate fleet-wide. The reverse case (Admiral control plane, Community remote) works automatically because the control plane's tier is what the remote trusts. - That is intentional. Account, License, Users, SSO, API Tokens, Registries, Cloud Backup, Nodes, Routing, and Webhooks are control-plane concerns and are hidden while a remote node is selected. Switch back to **Local** from the node switcher to manage them. The full list of which panels are per-node, per-browser, and control-plane-only lives in the [What Settings apply per node](#what-settings-apply-per-node) table above. + That is intentional. Account, Admiral Account, Users, SSO, API Tokens, Registries, Recovery Vault, Nodes, Routing, and Webhooks are control-plane concerns and are hidden while a remote node is selected. Switch back to **Local** from the node switcher to manage them. The full list of which panels are per-node, per-browser, and control-plane-only lives in the [What Settings apply per node](#what-settings-apply-per-node) table above. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 8060b5c8..794ee4c4 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -16,7 +16,7 @@ Open the Settings Hub by clicking the **Profile** icon in the top bar and select |-------|----------------| | **Personal** | Account, Appearance | | **Access** | License, Users, SSO, API Tokens | -| **Infrastructure** | Nodes, Fleet, Registries, Cloud Backup, App Store, Stacks | +| **Infrastructure** | Nodes, Fleet, Registries, Recovery Vault, App Store, Stacks | | **Monitoring** | Host Alerts, Container Alerts, Docker & Storage | | **Notifications** | Channels, Notification Routing | | **Automation** | Image update checks, Webhooks | @@ -37,7 +37,7 @@ Every section renders inside the same masthead-and-sidebar layout. The masthead | **SCOPE** `operator` / `browser` / `global` | Setting applies to your account (`operator`), to this browser only (`browser`, for browser-local sections such as Appearance), or to the whole instance (`global`, every other non-node group) | | **NODE** `` | Setting is per-node and is currently being edited against this node | | **EDITED** `` pending / `saved` | The current section has unsaved changes | -| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP left` (Account); `PLAN`, `TRIAL d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) | +| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP left` (Account); `PLAN`, `TRIAL d left`, `RENEWS`, `STATUS` (Admiral Account); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Recovery Vault); `DEV MODE` (Developer Diagnostics) | ### Quick search @@ -328,18 +328,18 @@ See [Private Registries](/features/private-registries) for the full walkthrough. --- -## Cloud Backup +## Recovery Vault - Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. + Custom S3-compatible storage is available on every tier. Recovery Vault is an Admiral feature. **Scope:** Global, admin-only -Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage so a control-plane loss does not take the recovery history with it. The masthead publishes a **PROVIDER** pill (`sencho` / `s3` / `disabled`), a **USED** pill showing storage consumption, and a **SNAPSHOTS** pill with the cloud object count. +Mirror fleet snapshots to Recovery Vault or any S3-compatible storage so a control-plane loss does not take the recovery history with it. The masthead publishes a **PROVIDER** pill (`sencho` / `s3` / `disabled`), a **USED** pill showing storage consumption, and a **SNAPSHOTS** pill with the cloud object count. - Cloud Backup section with Sencho Cloud Backup mode selected + Recovery Vault section with Recovery Vault mode selected ### Storage modes @@ -347,7 +347,7 @@ Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage so a | Mode | When to pick it | |------|-----------------| | **Disabled** | Snapshots stay on the control plane's local disk only. Default for fresh installs. | -| **Sencho Cloud Backup (included)** | Replicate snapshots to managed storage that ships with the Admiral subscription. On first selection, click **Activate** to provision the 500 MB allowance (backed by Cloudflare R2, scoped to this Admiral license). Auto-upload is always on for this mode. The **Test** and **Reprovision** buttons appear on the activated card if you need to verify connectivity or replace the credentials. | +| **Recovery Vault (included)** | Replicate snapshots to managed storage that ships with the Admiral subscription. On first selection, click **Activate** to provision the 500 MB allowance. Auto-upload is always on for this mode. The **Test** and **Reprovision** buttons appear on the activated card if you need to verify connectivity or replace the credentials. | | **Custom S3 (BYOB)** | Point Sencho at your own S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, Backblaze B2). Required fields: **Endpoint URL**, **Region**, **Bucket**, **Path Prefix** (default `sencho/`), **Access Key ID**, **Secret Access Key**. The secret access key is masked after save. Use **Test connection** to verify credentials, then toggle **Auto-upload** to start replicating snapshots. | The **Cloud Snapshots** list at the bottom of the section is an inventory of objects currently in the configured destination. Each row exposes a **Download** icon and a **Delete** icon. @@ -691,7 +691,7 @@ Links to help resources, with an additional channel for Admiral operators. | Channel | Tier | Description | |---------|------|-------------| -| **Priority Email Support** | Admiral | Direct email support with responses within 24 hours. | +| **Priority Email Support** | Admiral | Direct email support during business hours (Mon–Fri 09:00–17:00 America/New_York). We aim to first-respond within one business day. This is an operational target, not an SLA. | Community operators see an upgrade callout with a link to the pricing page in place of the support channels. diff --git a/e2e/admiral-account.spec.ts b/e2e/admiral-account.spec.ts new file mode 100644 index 00000000..14cf847a --- /dev/null +++ b/e2e/admiral-account.spec.ts @@ -0,0 +1,37 @@ +/** + * Admiral Account (Settings → license) Phase 4 surfaces. + * Community state must show AGPLv3 sourcing and channel info without auto-switching images. + */ +import { test, expect } from '@playwright/test'; +import { loginAs } from './helpers'; + +test.describe('Admiral Account', () => { + test('Community settings show AGPLv3 source and image channel fields', async ({ page }) => { + await loginAs(page); + + await page.goto('/nodes/local/settings/license'); + await page.waitForTimeout(1_000); + + // Admiral Account section under the local-node settings route. + await expect(page.getByText(/Sencho Community|Community plan/i).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('link', { name: /View source/i })).toBeVisible(); + await expect(page.getByText(/^Channel$/i).first()).toBeVisible(); + await expect(page.getByText(/Current image/i).first()).toBeVisible(); + + // Activation alone must not open a Hardened switch dialog. + await expect(page.getByText(/Review image switch/i)).toHaveCount(0); + }); + + test('Support section uses bounded business-day wording', async ({ page }) => { + await loginAs(page); + await page.goto('/nodes/local/settings/support'); + await page.waitForTimeout(1_000); + + // Community sees self-serve; Admiral may see priority email with business-day target. + const selfServe = page.getByText(/Documentation|GitHub Issues/i).first(); + await expect(selfServe).toBeVisible({ timeout: 15_000 }); + + const pageText = await page.locator('body').innerText(); + expect(pageText).not.toMatch(/responses within 24 hours/i); + }); +}); diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 82cd0a03..c88c8d6e 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -228,12 +228,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined} /> )} - {updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && ( + {updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && ( Update available )} - {updateStatus?.updateBlocked && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && ( + {(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && ( )} {updateStatus?.skipActive && ( @@ -314,7 +314,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u )} {/* Update button (mutating action: admin only, matches the requireAdmin route guard) */} - {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && onUpdate && isAdmin && ( + {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && onUpdate && isAdmin && (
diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index f39a6f92..f8e47402 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/toast-store'; @@ -13,8 +13,44 @@ import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; +import { ConfirmModal } from '@/components/ui/modal'; const PRICING_URL = 'https://sencho.io/pricing'; +const SOURCE_URL = 'https://github.com/Studio-Saelix/sencho'; + +type ImageChannel = 'community' | 'hardened' | 'unknown'; + +interface ImageOperation { + operationId: string; + state: 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed'; + failureCode?: string; +} + +interface ImageChannelStatus { + channel: ImageChannel; + composeImageRef?: string; + operation?: ImageOperation | null; +} + +interface HardenedPreflight { + preflightFingerprint: string; + currentImageRef: string; + allowedImageRef: string; + composeFilePath: string; + pinKind: string; + localRegistryAccess: string; +} + +function formatChannel(channel: ImageChannel): string { + switch (channel) { + case 'community': + return 'Community'; + case 'hardened': + return 'Hardened'; + default: + return 'Custom'; + } +} function getTierDisplayName(tier?: string, status?: string): string { if (tier === 'paid' && status === 'trial') return 'Sencho Admiral (Trial)'; @@ -32,6 +68,83 @@ export function LicenseSection() { const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); const [billingLoading, setBillingLoading] = useState(false); + const [channelStatus, setChannelStatus] = useState(null); + const [acknowledging, setAcknowledging] = useState(false); + const [preflight, setPreflight] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); + const [switching, setSwitching] = useState(false); + + const loadImageChannel = useCallback(async () => { + try { + const res = await apiFetch('/license/image-channel/status', { localOnly: true }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Unable to load image channel status.'); + setChannelStatus(data as ImageChannelStatus); + } catch (error) { + toast.error((error as Error)?.message || 'Unable to load image channel status.'); + } + }, []); + + useEffect(() => { + void loadImageChannel(); + }, [loadImageChannel]); + + const acknowledgeOperation = async () => { + const operation = channelStatus?.operation; + if (!operation || operation.state !== 'failed') return; + setAcknowledging(true); + try { + const res = await apiFetch(`/license/image-channel/operations/${operation.operationId}/acknowledge`, { + method: 'POST', + localOnly: true, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Unable to acknowledge the failed operation.'); + } + toast.success('Failed image operation acknowledged.'); + await loadImageChannel(); + } catch (error) { + toast.error((error as Error)?.message || 'Unable to acknowledge the failed operation.'); + } finally { + setAcknowledging(false); + } + }; + + const openPreflight = async () => { + setPreflightLoading(true); + try { + const res = await apiFetch('/license/image-channel/preflight', { method: 'POST', localOnly: true }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Hardened Build access is unavailable.'); + setPreflight(data as HardenedPreflight); + } catch (error) { + toast.error((error as Error)?.message || 'Hardened Build access is unavailable.'); + } finally { + setPreflightLoading(false); + } + }; + + const switchToHardened = async () => { + if (!preflight) return; + setSwitching(true); + try { + const res = await apiFetch('/license/image-channel/switch', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ preflightFingerprint: preflight.preflightFingerprint }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Image channel switch could not start.'); + toast.success('Hardened Build switch initiated.'); + setPreflight(null); + await loadImageChannel(); + } catch (error) { + toast.error((error as Error)?.message || 'Image channel switch could not start.'); + } finally { + setSwitching(false); + } + }; const openBillingPortal = async () => { setBillingLoading(true); @@ -116,6 +229,55 @@ export function LicenseSection() { + {!isPaid ? ( + + + View source + + + + ) : ( + + + + Included + + + )} + {isPaid && channelStatus?.channel !== 'hardened' ? ( + + + {preflightLoading ? : null} + Switch to Hardened + + + ) : null} + + + {channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')} + + + + {formatChannel(channelStatus?.channel ?? 'unknown')} + + {channelStatus?.operation?.state === 'failed' ? ( + + + + ) : null} + {license?.status === 'active' && license.customerName ? ( {license.customerName} @@ -268,6 +430,27 @@ export function LicenseSection() { ) : null} + !open && setPreflight(null)} + kicker="ADMIRAL ACCOUNT · HARDENED BUILD" + title="Review image switch" + confirmLabel={switching ? 'Switching' : 'Confirm switch'} + confirming={switching} + onConfirm={switchToHardened} + hint="BACK UP FIRST" + > +
+
+
CURRENT
{preflight?.currentImageRef}
+
TARGET
{preflight?.allowedImageRef}
+
REGISTRY
{preflight?.localRegistryAccess}
+
COMPOSE PATH
{preflight?.composeFilePath}
+
PIN KIND
{preflight?.pinKind}
+
+

Create a backup first. To roll back, restore the prior image reference in the compose file and recreate the service.

+
+
); } diff --git a/frontend/src/components/settings/SupportSection.tsx b/frontend/src/components/settings/SupportSection.tsx index f422e6c7..9c990b2a 100644 --- a/frontend/src/components/settings/SupportSection.tsx +++ b/frontend/src/components/settings/SupportSection.tsx @@ -64,7 +64,7 @@ export function SupportSection() { } title="Priority email support" - blurb="Direct support with responses within 24 hours" + blurb="Monday to Friday, 09:00 to 17:00 America/New_York. We aim to first-respond within one business day. This is not a contractual SLA or 24/7 service." href="mailto:support@sencho.io" external={false} /> @@ -75,8 +75,8 @@ export function SupportSection() { {!isPaid && ( } - title="Need faster support?" - subtitle="Admiral includes direct email support and priority issue handling." + title="Need direct support?" + subtitle="Admiral includes priority email support during published support hours." action={ + with: + ref: ${{ inputs.source_ref }} + persist-credentials: false + - id: inputs + shell: bash + run: | + [[ "${{ inputs.source_ref }}" =~ ^[0-9a-f]{40}$ ]] + [[ "${{ inputs.base_image_digest }}" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "${{ inputs.policy_revision }}" =~ ^[0-9a-f]{40}$ ]] + printf 'source_ref=%s\n' "${{ inputs.source_ref }}" >> "$GITHUB_OUTPUT" + printf 'base_image_digest=%s\n' "${{ inputs.base_image_digest }}" >> "$GITHUB_OUTPUT" + printf 'policy_revision=%s\n' "${{ inputs.policy_revision }}" >> "$GITHUB_OUTPUT" + + build-and-assess: + needs: validate-inputs + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + platform: [linux/amd64, linux/arm64] + steps: + - uses: actions/checkout@ + with: + ref: ${{ needs.validate-inputs.outputs.source_ref }} + persist-credentials: false + # TODO: install pinned Buildx and build from the source ref, lockfile, + # base-image digest, and platform. Export an OCI layout for scanning. + - name: Build immutable platform image + run: exit 1 # TODO: replace with a pinned Buildx invocation + # TODO: run a pinned Trivy image scan and evaluate the approved OpenVEX file. + - name: Gate Trivy and OpenVEX findings + run: exit 1 # TODO: fail exploitable critical or high findings + # TODO: generate SPDX and CycloneDX SBOMs plus SLSA provenance. + - name: Generate supply-chain attestations + run: exit 1 # TODO: replace with pinned tooling + # TODO: sign the immutable platform digest with Cosign keyless OIDC. + - name: Sign platform digest + run: exit 1 # TODO: replace with pinned Cosign tooling + + publish-manifest: + needs: build-and-assess + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + id-token: write + attestations: write + environment: hardened-production + steps: + # TODO: assemble the approved multi-architecture manifest, attach SBOM + # and provenance, sign it, then publish only this immutable target: + # ghcr.io/studio-saelix/sencho-hardened + - name: Publish approved manifest + run: exit 1 # TODO: replace after explicit authorization diff --git a/hardened-pipeline-skeleton/PUBLISH_POLICY.md b/hardened-pipeline-skeleton/PUBLISH_POLICY.md new file mode 100644 index 00000000..2c947087 --- /dev/null +++ b/hardened-pipeline-skeleton/PUBLISH_POLICY.md @@ -0,0 +1,28 @@ +# Hardened Build publish policy + +> Do not create `Studio-Saelix/sencho-hardened` until explicit authorization is given. + +## Publish prerequisites + +1. Build from an approved immutable source revision. +2. Pin all base images by digest and retain the resolved lockfile. +3. Produce the required architecture images before creating the manifest. +4. Scan each architecture image and evaluate findings against the approved OpenVEX policy. +5. Block publication when a known critical or high finding is exploitable and lacks an approved remediation decision. +6. Generate and attach SBOM and provenance records. +7. Sign the immutable manifest digest with Cosign. +8. Publish the matching AGPLv3 corresponding source notice with the release metadata. + +## Advisory and remediation + +An advisory can be recommended, cautionary, blocked, or unavailable. A blocked advisory prevents a new published tag. A newly disclosed issue in an already published image is triaged, recorded in OpenVEX where appropriate, remediated in a new immutable build, and communicated through the advisory channel. + +Rollback repoints the supported tag to a prior signed digest only after confirming that its advisory state is suitable for use. Rollback does not overwrite an existing immutable digest. + +## Evidence retained per publication + +- Source revision, dependency lockfile digest, and base-image digests +- Scanner reports and OpenVEX version +- SBOM and provenance attestations +- Cosign signature verification output +- Publication timestamp, manifest digest, and advisory decision diff --git a/hardened-pipeline-skeleton/README.md b/hardened-pipeline-skeleton/README.md new file mode 100644 index 00000000..0e9b2feb --- /dev/null +++ b/hardened-pipeline-skeleton/README.md @@ -0,0 +1,28 @@ +# Hardened Build publishing skeleton + +> Do not create `Studio-Saelix/sencho-hardened` until explicit authorization is given. + +This local skeleton defines the intended publishing controls for the Admiral-only Hardened Build image channel. It is preparation material, not a publishing system and not a private-source product. + +The Hardened Build image is a separately published container artifact. Sencho remains AGPLv3, and every release must make the corresponding source available under the AGPLv3 terms. + +## Assurance statement + +At publish time, the pipeline must establish that the image has zero known critical or high exploitable CVEs according to the approved scanner policy and OpenVEX assessment. This is a point-in-time statement, not a guarantee that no vulnerability will be discovered later. + +Each published image must include: + +- A multi-architecture manifest and immutable digest +- An SBOM +- Build provenance +- A Cosign signature and verification material +- OpenVEX statements for assessed findings +- A documented CVE remediation and republish process + +## Channel separation + +Community images continue through the public channels. Hardened Build inputs are immutable source revision, lockfile, base-image digest, policy revision, and architecture list. The resulting image is published only to `ghcr.io/studio-saelix/sencho-hardened` after all gates pass. + +The workflow skeleton intentionally contains placeholders instead of live credentials, action revisions, or publishing configuration. Fill them only in an authorized private delivery repository. + +See [PUBLISH_POLICY.md](PUBLISH_POLICY.md) for the release gate and remediation rules.