diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 3f6fb62..0d78006 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -23,6 +23,9 @@ "release_broadcast_candidate_service_implemented": true, "release_broadcast_candidate_service_default_state": "EMPTY_FAIL_CLOSED_LOOPBACK_ONLY", "release_broadcast_candidate_service_source": "server/release-broadcast/server.mjs", + "release_public_route_contract": "server/release-broadcast/public-route.json", + "release_public_path_prefix": "/hololake/releases", + "release_public_route_deployed": false, "release_broadcast_explicit_operator_activation_implemented": true, "release_broadcast_activation_requires_exact_human_approval": true, "release_broadcast_activation_requires_repeated_expected_facts": true, diff --git a/product-source/hololake-native-desktop/scripts/foundation.test.mjs b/product-source/hololake-native-desktop/scripts/foundation.test.mjs index beacff7..95ac8ab 100644 --- a/product-source/hololake-native-desktop/scripts/foundation.test.mjs +++ b/product-source/hololake-native-desktop/scripts/foundation.test.mjs @@ -14,6 +14,7 @@ const tauriConfig = readJson('src-tauri/tauri.conf.json') const capability = readJson('src-tauri/capabilities/default.json') const broadcast = readJson('contracts/release-broadcast.schema.json') const stageOne = readJson('contracts/stage-one-platform.json') +const publicRoute = readJson('server/release-broadcast/public-route.json') test('clean Tauri foundation contains no inherited product updater endpoint', () => { assert.deepEqual(foundation.upstream_product_update_endpoints, []) @@ -30,6 +31,11 @@ test('clean Tauri foundation contains no inherited product updater endpoint', () const source = readText(relative) assert.doesNotMatch(source, /refactoringhq|tolaria|outline/i) } + assert.equal(publicRoute.releaseEndpoint, 'https://guanghulab.com/hololake/releases/latest.json') + assert.equal(publicRoute.allowedMethods.join(','), 'GET,HEAD') + assert.equal(publicRoute.requestBodyAllowed, false) + assert.equal(publicRoute.automaticUpload, false) + assert.equal(publicRoute.automaticActivation, false) }) test('release activation remains explicitly human controlled', () => { @@ -51,6 +57,12 @@ test('release activation remains explicitly human controlled', () => { assert.equal(foundation.release_broadcast_activation_requires_exact_human_approval, true) assert.equal(foundation.release_broadcast_activation_requires_repeated_expected_facts, true) assert.equal(foundation.release_broadcast_operator_automatic_restart_allowed, false) + assert.equal(foundation.release_public_path_prefix, '/hololake/releases') + assert.equal(foundation.release_public_route_deployed, false) + assert.equal(publicRoute.publicPathPrefix, foundation.release_public_path_prefix) + assert.equal(publicRoute.proxyRequestUriPolicy, 'PRESERVE_FULL_PUBLIC_PATH') + assert.equal(publicRoute.frontDoorLoopbackPort, null) + assert.equal(publicRoute.deployed, false) assert.equal(foundation.release_pipeline_automatic_upload_allowed, false) assert.equal( foundation.release_production_activation_state, diff --git a/product-source/hololake-native-desktop/scripts/release-broadcast-operator.test.mjs b/product-source/hololake-native-desktop/scripts/release-broadcast-operator.test.mjs index 2bc443d..c739ea8 100644 --- a/product-source/hololake-native-desktop/scripts/release-broadcast-operator.test.mjs +++ b/product-source/hololake-native-desktop/scripts/release-broadcast-operator.test.mjs @@ -165,3 +165,27 @@ test('symlinked package inputs are rejected instead of followed', () => { fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) } }) + +test('operator refuses a bundle whose packages escape the registered public path', () => { + const fixture = buildOperatorFixture() + try { + const latestPath = path.join(fixture.source, 'latest.json') + const latest = JSON.parse(fs.readFileSync(latestPath, 'utf8')) + latest.platforms['darwin-aarch64'].url = 'https://guanghulab.com/updates/0.2.0/HoloLake.app.tar.gz' + writeJson(latestPath, latest) + const broadcastSha256 = sha256(fs.readFileSync(latestPath)) + const pipelinePath = path.join(fixture.source, 'pipeline-receipt.json') + const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8')) + pipeline.broadcastSha256 = broadcastSha256 + writeJson(pipelinePath, pipeline) + const approval = JSON.parse(fs.readFileSync(fixture.approval, 'utf8')) + approval.broadcastSha256 = broadcastSha256 + writeJson(fixture.approval, approval) + assert.throws( + () => verifyReleaseBundle(fixture.source, fixture.approval), + /HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PUBLIC_PREFIX_MISMATCH/, + ) + } finally { + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + } +}) diff --git a/product-source/hololake-native-desktop/scripts/release-broadcast-server.test.mjs b/product-source/hololake-native-desktop/scripts/release-broadcast-server.test.mjs index 565fc68..f1e615e 100644 --- a/product-source/hololake-native-desktop/scripts/release-broadcast-server.test.mjs +++ b/product-source/hololake-native-desktop/scripts/release-broadcast-server.test.mjs @@ -47,7 +47,7 @@ function buildReleaseRoot() { notes: 'Signed release', platforms: { 'darwin-aarch64': { - url: `https://release.guanghu.test/releases/0.2.0/${packageName}`, + url: `https://release.guanghu.test/hololake/releases/0.2.0/${packageName}`, signature: 'trusted-updater-signature', size: packageBytes.length, sha256: sha256(packageBytes), @@ -105,6 +105,10 @@ test('empty release root stays healthy but returns Tauri-compatible 204 no updat assert.equal(health.status, 200) assert.deepEqual((await health.json()).upstreamUpdateSources, []) assert.equal((await fetch(`${base}/latest.json`)).status, 204) + assert.equal((await fetch(`${base}/hololake/releases/latest.json`)).status, 204) + const publicHealth = await fetch(`${base}/hololake/releases/health`) + assert.equal(publicHealth.status, 200) + assert.equal((await publicHealth.json()).releaseEndpointPath, '/hololake/releases/latest.json') }) }) @@ -127,7 +131,10 @@ test('only an exact human-approved signed notarized evidence chain becomes reada const latest = await fetch(`${base}/latest.json`) assert.equal(latest.status, 200) assert.equal((await latest.json()).releaseId, 'GH-HOLOLAKE-RELEASE-0.2.0') - const updater = await fetch(`${base}/releases/0.2.0/HoloLake.app.tar.gz`) + const publicLatest = await fetch(`${base}/hololake/releases/latest.json`) + assert.equal(publicLatest.status, 200) + assert.equal((await publicLatest.json()).releaseId, 'GH-HOLOLAKE-RELEASE-0.2.0') + const updater = await fetch(`${base}/hololake/releases/0.2.0/HoloLake.app.tar.gz`) assert.equal(updater.status, 200) assert.deepEqual(Buffer.from(await updater.arrayBuffer()), fixture.packageBytes) assert.equal((await fetch(`${base}/unknown`)).status, 404) @@ -151,3 +158,27 @@ test('a symlinked immutable current directory is recognized as the intended exec fs.symlinkSync(versionDirectory, current) assert.equal(isMainModule(path.join(current, 'server.mjs'), new URL(`file://${copy}`)), true) }) + +test('a package outside the registered public prefix locks the release', () => { + const fixture = buildReleaseRoot() + const latestPath = path.join(fixture.root, 'releases', '0.2.0', 'latest.json') + const latest = JSON.parse(fs.readFileSync(latestPath, 'utf8')) + latest.platforms['darwin-aarch64'].url = 'https://release.guanghu.test/releases/0.2.0/HoloLake.app.tar.gz' + writeJson(latestPath, latest) + const broadcastSha256 = sha256(fs.readFileSync(latestPath)) + const pipelinePath = path.join(fixture.root, 'releases', '0.2.0', 'pipeline-receipt.json') + const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8')) + pipeline.broadcastSha256 = broadcastSha256 + writeJson(pipelinePath, pipeline) + const approvalPath = path.join(fixture.root, 'releases', '0.2.0', 'human-approval.json') + const approval = JSON.parse(fs.readFileSync(approvalPath, 'utf8')) + approval.broadcastSha256 = broadcastSha256 + writeJson(approvalPath, approval) + const activationPath = path.join(fixture.root, 'ACTIVE.json') + const activation = JSON.parse(fs.readFileSync(activationPath, 'utf8')) + activation.broadcastSha256 = broadcastSha256 + writeJson(activationPath, activation) + const state = loadRuntimeState(fixture.root) + assert.equal(state.state, 'LOCKED_INVALID_RELEASE_EVIDENCE') + assert.equal(state.reasonCode, 'HOLOLAKE_RELEASE_PACKAGE_PUBLIC_PREFIX_MISMATCH') +}) diff --git a/product-source/hololake-native-desktop/scripts/release-pipeline.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs index 973ece0..db42b1f 100644 --- a/product-source/hololake-native-desktop/scripts/release-pipeline.mjs +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs @@ -8,6 +8,8 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PUBLIC_RELEASE_ENDPOINT_PATH = '/hololake/releases/latest.json' +const PUBLIC_RELEASE_PREFIX = '/hololake/releases' const fail = (code) => { throw new Error(code) @@ -28,7 +30,15 @@ export function validateReleaseTrust(trust) { if (!Array.isArray(trust.allowedReleaseHosts) || trust.allowedReleaseHosts.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_HOST_REQUIRED') const endpoint = new URL(trust.endpoints[0]) const host = requireText(trust.allowedReleaseHosts[0], 'HOLOLAKE_RELEASE_PIPELINE_HOST_REQUIRED') - if (endpoint.protocol !== 'https:' || endpoint.hostname !== host || endpoint.username || endpoint.password) { + if ( + endpoint.protocol !== 'https:' || + endpoint.hostname !== host || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + endpoint.pathname !== PUBLIC_RELEASE_ENDPOINT_PATH + ) { fail('HOLOLAKE_RELEASE_PIPELINE_ENDPOINT_NOT_HOLOLAKE_HTTPS') } if (requireText(trust.publicKey, 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_REQUIRED').length < 32) { @@ -50,7 +60,15 @@ export function validateReleaseInput(input, trustFacts) { if (!/^[a-f0-9]{40}$/.test(input.sourceCommit || '')) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_COMMIT_INVALID') if (input.releaseTag !== `v${version}`) fail('HOLOLAKE_RELEASE_PIPELINE_IMMUTABLE_TAG_INVALID') const packageUrl = new URL(requireText(input.packageUrl, 'HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_REQUIRED')) - if (packageUrl.protocol !== 'https:' || packageUrl.hostname !== trustFacts.host || packageUrl.username || packageUrl.password) { + if ( + packageUrl.protocol !== 'https:' || + packageUrl.hostname !== trustFacts.host || + packageUrl.username || + packageUrl.password || + packageUrl.search || + packageUrl.hash || + !packageUrl.pathname.startsWith(`${PUBLIC_RELEASE_PREFIX}/`) + ) { fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_HOST_NOT_TRUSTED') } if (!Array.isArray(input.features) || input.features.length === 0 || input.features.some((item) => typeof item !== 'string' || !item.trim())) { diff --git a/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs index 8fef6bf..6aa18ef 100644 --- a/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs @@ -10,7 +10,7 @@ import { const readyTrust = () => ({ schema: 'hololake.release-trust/v1', state: 'PROVISIONED', - endpoints: ['https://release.guanghu.test/latest.json'], + endpoints: ['https://release.guanghu.test/hololake/releases/latest.json'], publicKey: 'A'.repeat(64), allowedReleaseHosts: ['release.guanghu.test'], automaticCheckOnStartup: false, @@ -28,7 +28,7 @@ const readyInput = () => ({ releaseTag: 'v0.2.0', sourceCommit: 'a'.repeat(40), platformCode: 'darwin-aarch64', - packageUrl: 'https://release.guanghu.test/releases/0.2.0/HoloLake.app.tar.gz', + packageUrl: 'https://release.guanghu.test/hololake/releases/0.2.0/HoloLake.app.tar.gz', appleTeamIdentifier: '825A9L3G7Q', notes: 'Signed release', features: ['Persistent direct connection'], @@ -53,6 +53,10 @@ test('release package must use the exact registered HoloLake HTTPS host and immu wrongHost.packageUrl = 'https://github.com/example/HoloLake.app.tar.gz' assert.throws(() => validateReleaseInput(wrongHost, trust), /PACKAGE_HOST_NOT_TRUSTED/) + const wrongPath = readyInput() + wrongPath.packageUrl = 'https://release.guanghu.test/updates/HoloLake.app.tar.gz' + assert.throws(() => validateReleaseInput(wrongPath, trust), /PACKAGE_HOST_NOT_TRUSTED/) + const wrongTag = readyInput() wrongTag.releaseTag = 'latest' assert.throws(() => validateReleaseInput(wrongTag, trust), /IMMUTABLE_TAG_INVALID/) diff --git a/product-source/hololake-native-desktop/server/release-broadcast/README.md b/product-source/hololake-native-desktop/server/release-broadcast/README.md index f15d8eb..036bf93 100644 --- a/product-source/hololake-native-desktop/server/release-broadcast/README.md +++ b/product-source/hololake-native-desktop/server/release-broadcast/README.md @@ -15,6 +15,14 @@ The service listens only on `127.0.0.1`. Public HTTPS routing, updater trust-key provisioning, artifact upload, activation, and desktop rollout are independent deployment gates. +The registered public namespace is `/hololake/releases`. The loopback origin +accepts the manifest at both its operator health-check path `/latest.json` and +the public updater path `/hololake/releases/latest.json`; release package URLs +must remain under that same public prefix. The front-door proxy must therefore +preserve the full request URI. Its loopback tunnel port remains deliberately +unassigned until the BS-GZ-006 route owner returns the live, non-conflicting +topology. + `operator.mjs` supplies the separate, root-operated verification and activation boundary. `verify` reconstructs a private candidate tree and accepts it only when the broadcast, pipeline receipt, package bytes, Developer ID receipt, Apple diff --git a/product-source/hololake-native-desktop/server/release-broadcast/hololake-release-broadcast.service b/product-source/hololake-native-desktop/server/release-broadcast/hololake-release-broadcast.service index 9e187f1..3f0b011 100644 --- a/product-source/hololake-native-desktop/server/release-broadcast/hololake-release-broadcast.service +++ b/product-source/hololake-native-desktop/server/release-broadcast/hololake-release-broadcast.service @@ -12,6 +12,7 @@ Environment=HOLOLAKE_RELEASE_HOST=127.0.0.1 Environment=HOLOLAKE_RELEASE_PORT=3940 Environment=HOLOLAKE_RELEASE_STATE_ROOT=/var/lib/guanghu/hololake-release-broadcast Environment=HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER=1 +Environment=HOLOLAKE_RELEASE_PUBLIC_PREFIX=/hololake/releases ExecStart=/usr/bin/node /opt/guanghu/hololake-release-broadcast-candidate/current/server.mjs Restart=on-failure RestartSec=5 diff --git a/product-source/hololake-native-desktop/server/release-broadcast/operator.mjs b/product-source/hololake-native-desktop/server/release-broadcast/operator.mjs index a05d307..bcd82e7 100755 --- a/product-source/hololake-native-desktop/server/release-broadcast/operator.mjs +++ b/product-source/hololake-native-desktop/server/release-broadcast/operator.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { isMainModule, loadRuntimeState } from './server.mjs' +import { DEFAULT_PUBLIC_PREFIX, isMainModule, loadRuntimeState, normalizePublicPrefix } from './server.mjs' const MAX_CONTROL_BYTES = 1024 * 1024 const SHA256_PATTERN = /^[a-f0-9]{64}$/ @@ -54,7 +54,7 @@ function directFile(directory, name, code) { return resolved } -function packageNameFromUrl(value) { +function packageNameFromUrl(value, publicPrefix) { const url = new URL(requireText(value, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_REQUIRED')) if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID') @@ -66,6 +66,9 @@ function packageNameFromUrl(value) { fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID') } if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_NAME_INVALID') + if (!url.pathname.startsWith(`${publicPrefix}/`) || url.pathname === `${publicPrefix}/latest.json` || url.pathname === `${publicPrefix}/health`) { + fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PUBLIC_PREFIX_MISMATCH') + } return packageName } @@ -75,8 +78,13 @@ function addArtifact(artifacts, source, name, type) { artifacts.set(name, { source, name, type }) } -export function inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner = false } = {}) { +export function inspectReleaseBundle( + sourceDirectory, + humanApprovalPath, + { enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {}, +) { const source = path.resolve(sourceDirectory) + const normalizedPublicPrefix = normalizePublicPrefix(publicPrefix) requireTrustedDirectory(source, { enforceRootOwner }) const latestPath = directFile(source, 'latest.json', 'HOLOLAKE_RELEASE_OPERATOR_BROADCAST_PATH_INVALID') const pipelinePath = directFile(source, 'pipeline-receipt.json', 'HOLOLAKE_RELEASE_OPERATOR_PIPELINE_PATH_INVALID') @@ -125,7 +133,7 @@ export function inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enfor addArtifact(artifacts, pipelinePath, 'pipeline-receipt.json', 'control') addArtifact(artifacts, approvalPath, 'human-approval.json', 'control') for (const platform of Object.values(broadcast.platforms)) { - const packageName = packageNameFromUrl(platform?.url) + const packageName = packageNameFromUrl(platform?.url, normalizedPublicPrefix) const packagePath = directFile(source, packageName, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PATH_INVALID') const packageInfo = requireTrustedFile(packagePath, { enforceRootOwner }) if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) { @@ -166,6 +174,7 @@ export function inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enfor sourceCommit: pipelineReceipt.sourceCommit, broadcastSha256, approvalId, + publicPrefix: normalizedPublicPrefix, artifacts: [...artifacts.values()], } } @@ -235,7 +244,7 @@ function prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupI } fs.chownSync(activationPath, 0, trustedGroupId) } - const state = loadRuntimeState(candidateRoot, { enforceRootOwner }) + const state = loadRuntimeState(candidateRoot, { enforceRootOwner, publicPrefix: plan.publicPrefix }) if (state.state !== 'READY_SIGNED_NOTARIZED_BROADCAST') { fail(`HOLOLAKE_RELEASE_OPERATOR_CANDIDATE_INVALID:${state.reasonCode || state.state}`) } @@ -253,12 +262,12 @@ function requireExpected(plan, expected) { } } -function replaceActivation(stateRoot, candidateActivation, previousBytes, enforceRootOwner, trustedGroupId) { +function replaceActivation(stateRoot, candidateActivation, previousBytes, enforceRootOwner, trustedGroupId, publicPrefix) { const tempActivation = path.join(stateRoot, `.ACTIVE.${crypto.randomUUID()}.tmp`) copyFileNoFollow(candidateActivation, tempActivation, enforceRootOwner ? 0o640 : 0o600) if (enforceRootOwner) fs.chownSync(tempActivation, 0, trustedGroupId) fs.renameSync(tempActivation, path.join(stateRoot, 'ACTIVE.json')) - const committed = loadRuntimeState(stateRoot, { enforceRootOwner }) + const committed = loadRuntimeState(stateRoot, { enforceRootOwner, publicPrefix }) if (committed.state === 'READY_SIGNED_NOTARIZED_BROADCAST') return committed const failedActive = path.join(stateRoot, 'ACTIVE.json') if (previousBytes === null) { @@ -312,7 +321,14 @@ export function verifyReleaseBundle(sourceDirectory, humanApprovalPath) { } } -export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot, expected, enforceRootOwner = true }) { +export function activateRelease({ + sourceDirectory, + humanApprovalPath, + stateRoot, + expected, + enforceRootOwner = true, + publicPrefix = DEFAULT_PUBLIC_PREFIX, +}) { if (enforceRootOwner && process.getuid?.() !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_ROOT_REQUIRED') const root = path.resolve(stateRoot) const rootInfo = requireTrustedDirectory(root, { enforceRootOwner }) @@ -320,7 +336,7 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot, const lockDirectory = acquireOperatorLock(root, enforceRootOwner, trustedGroupId) let candidateRoot = null try { - const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner }) + const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner, publicPrefix }) requireExpected(plan, expected) candidateRoot = fs.mkdtempSync(path.join(root, '.candidate-')) const candidate = prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId }) @@ -338,7 +354,14 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot, fs.renameSync(candidate.releaseDirectory, finalRelease) let committed try { - committed = replaceActivation(root, candidate.activationPath, previousBytes, enforceRootOwner, trustedGroupId) + committed = replaceActivation( + root, + candidate.activationPath, + previousBytes, + enforceRootOwner, + trustedGroupId, + plan.publicPrefix, + ) } catch (error) { fs.rmSync(finalRelease, { recursive: true, force: true }) throw error @@ -351,6 +374,7 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot, sourceCommit: plan.sourceCommit, broadcastSha256: plan.broadcastSha256, approvalId: plan.approvalId, + publicPrefix: plan.publicPrefix, automaticUpload: false, automaticActivation: false, automaticRestart: false, @@ -386,6 +410,7 @@ export function main(argv = process.argv.slice(2)) { sourceDirectory, humanApprovalPath, stateRoot: requireText(values['state-root'], 'HOLOLAKE_RELEASE_OPERATOR_STATE_ROOT_REQUIRED'), + publicPrefix: values['public-prefix'] || DEFAULT_PUBLIC_PREFIX, expected: { releaseId: values['expect-release-id'], version: values['expect-version'], diff --git a/product-source/hololake-native-desktop/server/release-broadcast/public-route.json b/product-source/hololake-native-desktop/server/release-broadcast/public-route.json new file mode 100644 index 0000000..f21dcbb --- /dev/null +++ b/product-source/hololake-native-desktop/server/release-broadcast/public-route.json @@ -0,0 +1,19 @@ +{ + "schema": "hololake.release-public-route/v1", + "state": "REGISTERED_PENDING_FRONT_DOOR_DEPLOYMENT", + "publicBaseUrl": "https://guanghulab.com/hololake/releases", + "releaseEndpoint": "https://guanghulab.com/hololake/releases/latest.json", + "publicPathPrefix": "/hololake/releases", + "originNode": "JD-FD-PRIMARY", + "originListener": "127.0.0.1:3940", + "frontDoorNode": "BS-GZ-006", + "frontDoorLoopbackPort": null, + "proxyRequestUriPolicy": "PRESERVE_FULL_PUBLIC_PATH", + "allowedMethods": ["GET", "HEAD"], + "requestBodyAllowed": false, + "latestCachePolicy": "NO_STORE", + "packageCachePolicy": "PUBLIC_IMMUTABLE", + "automaticUpload": false, + "automaticActivation": false, + "deployed": false +} diff --git a/product-source/hololake-native-desktop/server/release-broadcast/server.mjs b/product-source/hololake-native-desktop/server/release-broadcast/server.mjs index 39024fe..ddfdc35 100644 --- a/product-source/hololake-native-desktop/server/release-broadcast/server.mjs +++ b/product-source/hololake-native-desktop/server/release-broadcast/server.mjs @@ -7,6 +7,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' const MAX_CONTROL_BYTES = 1024 * 1024 +export const DEFAULT_PUBLIC_PREFIX = '/hololake/releases' const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ const SHA256_PATTERN = /^[a-f0-9]{64}$/ @@ -21,6 +22,18 @@ const requireText = (value, code) => { return value.trim() } +export function normalizePublicPrefix(value) { + const prefix = requireText(value, 'HOLOLAKE_RELEASE_PUBLIC_PREFIX_REQUIRED') + if (!prefix.startsWith('/') || prefix.endsWith('/') || prefix.includes('//') || prefix.includes('?') || prefix.includes('#')) { + fail('HOLOLAKE_RELEASE_PUBLIC_PREFIX_INVALID') + } + const normalized = path.posix.normalize(prefix) + if (normalized !== prefix || normalized === '/' || normalized.split('/').some((segment) => segment === '..')) { + fail('HOLOLAKE_RELEASE_PUBLIC_PREFIX_INVALID') + } + return prefix +} + function trustedRegularFile(file, { enforceRootOwner, maxBytes = null }) { const info = fs.lstatSync(file) if (!info.isFile() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_FILE_NOT_REGULAR') @@ -43,13 +56,16 @@ function readTrustedJson(file, options) { return { bytes: fs.readFileSync(file), value: JSON.parse(fs.readFileSync(file, 'utf8')) } } -function validatePlatform(platform, releaseDirectory, enforceRootOwner, sourceCommit) { +function validatePlatform(platform, releaseDirectory, enforceRootOwner, sourceCommit, publicPrefix) { const url = new URL(requireText(platform?.url, 'HOLOLAKE_RELEASE_PACKAGE_URL_REQUIRED')) if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { fail('HOLOLAKE_RELEASE_PACKAGE_URL_INVALID') } const packageName = decodeURIComponent(path.posix.basename(url.pathname)) if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_PACKAGE_NAME_INVALID') + if (!url.pathname.startsWith(`${publicPrefix}/`) || url.pathname === `${publicPrefix}/latest.json` || url.pathname === `${publicPrefix}/health`) { + fail('HOLOLAKE_RELEASE_PACKAGE_PUBLIC_PREFIX_MISMATCH') + } const packagePath = safeRelative(releaseDirectory, packageName, 'HOLOLAKE_RELEASE_PACKAGE_PATH_INVALID') const packageInfo = trustedRegularFile(packagePath, { enforceRootOwner }) if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) { @@ -82,10 +98,10 @@ function validatePlatform(platform, releaseDirectory, enforceRootOwner, sourceCo return { pathname: url.pathname, file: packagePath, size: packageInfo.size } } -function loadActiveRelease(stateRoot, enforceRootOwner) { +function loadActiveRelease(stateRoot, enforceRootOwner, publicPrefix) { const activationPath = path.join(stateRoot, 'ACTIVE.json') if (!fs.existsSync(activationPath)) { - return { state: 'EMPTY_FAIL_CLOSED', release: null, reasonCode: null } + return { state: 'EMPTY_FAIL_CLOSED', release: null, reasonCode: null, publicPrefix } } const activation = readTrustedJson(activationPath, { enforceRootOwner }).value if ( @@ -135,7 +151,7 @@ function loadActiveRelease(stateRoot, enforceRootOwner) { const releaseDirectory = path.dirname(broadcastPath) const packages = new Map() for (const platform of Object.values(broadcast.platforms)) { - const accepted = validatePlatform(platform, releaseDirectory, enforceRootOwner, pipelineReceipt.sourceCommit) + const accepted = validatePlatform(platform, releaseDirectory, enforceRootOwner, pipelineReceipt.sourceCommit, publicPrefix) if (packages.has(accepted.pathname)) fail('HOLOLAKE_RELEASE_PACKAGE_ROUTE_COLLISION') packages.set(accepted.pathname, accepted) } @@ -143,18 +159,21 @@ function loadActiveRelease(stateRoot, enforceRootOwner) { state: 'READY_SIGNED_NOTARIZED_BROADCAST', release: { releaseId, version, broadcastBytes: broadcastDocument.bytes, packages }, reasonCode: null, + publicPrefix, } } -export function loadRuntimeState(stateRoot, { enforceRootOwner = false } = {}) { +export function loadRuntimeState(stateRoot, { enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {}) { const resolvedRoot = path.resolve(stateRoot) try { - return loadActiveRelease(resolvedRoot, enforceRootOwner) + const normalizedPrefix = normalizePublicPrefix(publicPrefix) + return loadActiveRelease(resolvedRoot, enforceRootOwner, normalizedPrefix) } catch (error) { return { state: 'LOCKED_INVALID_RELEASE_EVIDENCE', release: null, reasonCode: error instanceof Error ? error.message : 'HOLOLAKE_RELEASE_UNKNOWN_FAILURE', + publicPrefix: null, } } } @@ -178,7 +197,9 @@ export function createReleaseServer(runtimeState) { return response.end() } const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname - if (pathname === '/health') { + const publicHealthPath = runtimeState.publicPrefix ? `${runtimeState.publicPrefix}/health` : null + const publicLatestPath = runtimeState.publicPrefix ? `${runtimeState.publicPrefix}/latest.json` : null + if (pathname === '/health' || pathname === publicHealthPath) { const status = runtimeState.state === 'LOCKED_INVALID_RELEASE_EVIDENCE' ? 503 : 200 return jsonResponse(response, status, { schema: 'hololake.release-broadcast-health/v1', @@ -187,11 +208,15 @@ export function createReleaseServer(runtimeState) { automaticUpload: false, automaticActivation: false, upstreamUpdateSources: [], + ...(runtimeState.publicPrefix ? { + publicPathPrefix: runtimeState.publicPrefix, + releaseEndpointPath: `${runtimeState.publicPrefix}/latest.json`, + } : {}), ...(runtimeState.release ? { releaseId: runtimeState.release.releaseId, version: runtimeState.release.version } : {}), ...(runtimeState.reasonCode ? { reasonCode: runtimeState.reasonCode } : {}), }) } - if (pathname === '/latest.json') { + if (pathname === '/latest.json' || pathname === publicLatestPath) { if (runtimeState.state === 'EMPTY_FAIL_CLOSED') { response.writeHead(204, { 'Cache-Control': 'no-store' }) return response.end() @@ -230,7 +255,11 @@ export function main(env = process.env) { const port = Number(env.HOLOLAKE_RELEASE_PORT || 3940) if (!Number.isInteger(port) || port < 1024 || port > 65535) fail('HOLOLAKE_RELEASE_PORT_INVALID') const stateRoot = path.resolve(env.HOLOLAKE_RELEASE_STATE_ROOT || '/var/lib/guanghu/hololake-release-broadcast') - const runtimeState = loadRuntimeState(stateRoot, { enforceRootOwner: env.HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER === '1' }) + const publicPrefix = env.HOLOLAKE_RELEASE_PUBLIC_PREFIX || DEFAULT_PUBLIC_PREFIX + const runtimeState = loadRuntimeState(stateRoot, { + enforceRootOwner: env.HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER === '1', + publicPrefix, + }) const server = createReleaseServer(runtimeState) server.on('clientError', (_error, socket) => socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')) server.listen(port, host, () => { diff --git a/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs b/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs index aeb301f..b9bfc3b 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs @@ -3,6 +3,7 @@ use tauri::{AppHandle, Runtime, Url}; use tauri_plugin_updater::UpdaterExt; const EMBEDDED_RELEASE_TRUST: &str = include_str!("../release-trust.json"); +const PUBLIC_RELEASE_ENDPOINT_PATH: &str = "/hololake/releases/latest.json"; #[derive(Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -108,6 +109,12 @@ fn validate_release_trust(raw: &str) -> Result { "the updater endpoint is not owned by the configured HoloLake host".into(), ); } + if endpoint.path() != PUBLIC_RELEASE_ENDPOINT_PATH + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return Err("the HoloLake release endpoint path is not registered".into()); + } Ok(ValidatedReleaseTrust::Enabled { endpoints: vec![endpoint], public_key: trust.public_key, @@ -166,7 +173,7 @@ mod tests { fn accepts_https_shape_but_rejects_insecure_endpoints() { let owned_shape = document( "PROVISIONED", - "\"https://releases.example.test/latest.json\"", + "\"https://releases.example.test/hololake/releases/latest.json\"", "public", "\"releases.example.test\"", ); @@ -176,18 +183,25 @@ mod tests { ); let insecure = document( "PROVISIONED", - "\"http://releases.example.test/latest.json\"", + "\"http://releases.example.test/hololake/releases/latest.json\"", "public", "\"releases.example.test\"", ); assert!(validate_release_trust(&insecure).is_err()); let mismatched_host = document( "PROVISIONED", - "\"https://upstream.example.test/latest.json\"", + "\"https://upstream.example.test/hololake/releases/latest.json\"", "public", "\"releases.example.test\"", ); assert!(validate_release_trust(&mismatched_host).is_err()); + let mismatched_path = document( + "PROVISIONED", + "\"https://releases.example.test/latest.json\"", + "public", + "\"releases.example.test\"", + ); + assert!(validate_release_trust(&mismatched_path).is_err()); } #[test] diff --git a/product-source/hololake-native-desktop/src-tauri/src/release_update.rs b/product-source/hololake-native-desktop/src-tauri/src/release_update.rs index 29f32fa..f349415 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/release_update.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/release_update.rs @@ -20,6 +20,7 @@ const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1"; const RECOVERY_SCHEMA: &str = "hololake.release-recovery/v1"; const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000; const EXPECTED_BUNDLE_IDENTIFIER: &str = "world.guanghu.hololake"; +const PUBLIC_RELEASE_PACKAGE_PREFIX: &str = "/hololake/releases/"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -472,7 +473,12 @@ fn validate_update(update: &Update, allowed_host: &str) -> Result