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
This commit is contained in:
Anso
2026-07-14 10:47:54 -04:00
committed by GitHub
parent 8ca8ebaa24
commit 381ed2a91f
54 changed files with 2302 additions and 125 deletions
@@ -62,7 +62,7 @@ describe('fetchRemoteMeta Authorization header', () => {
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
});
});
});
@@ -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);
@@ -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();
@@ -41,6 +41,7 @@ const ONLINE = (over: Partial<RemoteMeta> = {}): 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', () => {
@@ -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);
});
});
@@ -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');
});
});
@@ -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<void>(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<void>(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<void>(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<void>(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<void>;
};
const realPersist = proto.persist.bind(service);
vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise<void> }, '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<string | null>((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);
});
});
@@ -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);
@@ -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<void>((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<void>((resolve) => upstream.close(() => resolve()));
bridge.close();
});
});
@@ -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);
});
@@ -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');
});
});
+4
View File
@@ -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<void> {
})(),
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) => {
+56
View File
@@ -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(':');
}
+43
View File
@@ -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';
}
+2
View File
@@ -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);
+2 -2
View File
@@ -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<voi
try {
const svc = CloudBackupService.getInstance();
if (svc.getProvider() !== 'sencho') {
res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' });
res.status(400).json({ error: 'Usage is only available for Recovery Vault' });
return;
}
const usage = await svc.getSenchoCloudBackupUsage();
+97 -24
View File
@@ -17,9 +17,11 @@ import { StackOpLockService } from '../services/StackOpLockService';
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { respondSelfUpdatePreflight, scheduleLocalUpdate } from './license';
import { respondSelfUpdatePreflight } from './license';
import { ImageOperationService } from '../services/ImageOperationService';
import { classifyImageChannel } from '../helpers/imageChannel';
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
@@ -68,6 +70,7 @@ const EMPTY_PIN_STATUS = {
targetImageRef: null,
updateBlocked: false,
updateBlockedReason: null,
imageChannel: null,
} as const;
function localPinStatusFields(
@@ -87,6 +90,7 @@ function localPinStatusFields(
: null,
updateBlocked,
updateBlockedReason: updateBlocked ? blockedReason : null,
imageChannel: classifyImageChannel(pin.composeImageRef),
};
}
// Throttle the forced latest-version refresh so a caller cannot loop the recheck
@@ -1116,6 +1120,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
let remoteOnline = false;
let remoteImagePinKind: ImagePinKind | null = null;
let remoteUpdateBlocked = false;
let remoteImageChannel: 'community' | 'hardened' | 'unknown' | null = null;
if (node.type === 'local') {
version = gatewayVersion;
} else {
@@ -1126,6 +1131,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
remoteOnline = meta.online;
remoteImagePinKind = meta.imagePinKind;
remoteUpdateBlocked = meta.updateBlocked;
remoteImageChannel = meta.imageChannel;
}
if (tracker?.status === 'updating') {
@@ -1257,15 +1263,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
let targetImageRef: string | null = null;
let updateBlocked = false;
let updateBlockedReason: string | null = null;
let imageChannel: 'community' | 'hardened' | 'unknown' | null = null;
if (node.type === 'local') {
const pin = await SelfUpdateService.getInstance().getPinInfo();
if (pin) {
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason } =
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason, imageChannel } =
localPinStatusFields(pin, compareVersion, compareValid, REPIN_BLOCKED_REASON));
}
} else {
imagePinKind = remoteImagePinKind;
updateBlocked = remoteUpdateBlocked;
imageChannel = remoteImageChannel;
}
return {
@@ -1284,6 +1292,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
targetImageRef,
updateBlocked,
updateBlockedReason,
imageChannel,
};
}),
);
@@ -1354,6 +1363,19 @@ function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVe
});
}
function parseRemoteUpdateFailure(payload: unknown): { error: string; code?: string } {
if (!payload || typeof payload !== 'object') {
return { error: 'Remote node rejected update request.' };
}
const response = payload as Record<string, unknown>;
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<void> => {
@@ -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<string, string>)?.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.' });
+91
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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();
});
+47 -2
View File
@@ -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);
});
+10 -5
View File
@@ -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<void> => {
const selfUpdate = SelfUpdateService.getInstance();
const updateError = selfUpdate.getLastError();
@@ -44,8 +46,11 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
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' } : {}),
});
});
@@ -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
+3 -3
View File
@@ -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<void> {
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 }> {
@@ -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,
};
}
@@ -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<string, CacheEntry>();
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<HardenedEntitlementResult> {
// 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<HardenedEntitlementResult> {
const apiBase = process.env.SENCHO_ASSURANCE_API || ASSURANCE_API_DEFAULT;
if (!isSecureAssuranceApi(apiBase)) return { success: false, code: 'unavailable' };
try {
const response = await axios.post<unknown>(
`${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<string, unknown> {
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;
}
}
@@ -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<void> = 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<HardenedPreflight> {
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<ImageOperation | null> {
const filePath = this.operationFile(operationId);
if (!filePath) return null;
return this.readOperation(filePath);
}
public async getCurrentOperation(): Promise<ImageOperation | null> {
return this.readOperation(this.currentFile());
}
public async acknowledge(operationId: string): Promise<boolean> {
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<void> {
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<boolean> {
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<T>(fn: () => Promise<T>): Promise<T> {
const run = this.stateWriteQueue.then(fn, fn);
this.stateWriteQueue = run.then(() => undefined, () => undefined);
return run;
}
private async fail(operation: ImageOperation, failureCode: FailureCode): Promise<void> {
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<void> {
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<ImageOperation | null> {
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<void> {
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<string, { auth?: string }> },
): Promise<string> {
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<LocalRegistryAccess> {
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<string, string> = {
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<void> {
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<void> {
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<boolean> {
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;
}
}
}
+2
View File
@@ -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();
}
/**
+18
View File
@@ -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<ResolvedDockerConfig> {
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.
+86 -9
View File
@@ -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<ResolvedComposeImage | null> {
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<void> {
async triggerUpdate(options?: {
targetVersion?: string;
targetImageRef?: string;
dockerConfigPath?: string;
successMarkerFile?: string;
successMarkerContent?: string;
}): Promise<void> {
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.
@@ -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 };
+1 -1
View File
@@ -98,7 +98,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
// 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',