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
@@ -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 };