feat(hololake): bind release chain to public path namespace
This commit is contained in:
parent
ba80036282
commit
9006075310
13 changed files with 223 additions and 29 deletions
|
|
@ -23,6 +23,9 @@
|
||||||
"release_broadcast_candidate_service_implemented": true,
|
"release_broadcast_candidate_service_implemented": true,
|
||||||
"release_broadcast_candidate_service_default_state": "EMPTY_FAIL_CLOSED_LOOPBACK_ONLY",
|
"release_broadcast_candidate_service_default_state": "EMPTY_FAIL_CLOSED_LOOPBACK_ONLY",
|
||||||
"release_broadcast_candidate_service_source": "server/release-broadcast/server.mjs",
|
"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_explicit_operator_activation_implemented": true,
|
||||||
"release_broadcast_activation_requires_exact_human_approval": true,
|
"release_broadcast_activation_requires_exact_human_approval": true,
|
||||||
"release_broadcast_activation_requires_repeated_expected_facts": true,
|
"release_broadcast_activation_requires_repeated_expected_facts": true,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const tauriConfig = readJson('src-tauri/tauri.conf.json')
|
||||||
const capability = readJson('src-tauri/capabilities/default.json')
|
const capability = readJson('src-tauri/capabilities/default.json')
|
||||||
const broadcast = readJson('contracts/release-broadcast.schema.json')
|
const broadcast = readJson('contracts/release-broadcast.schema.json')
|
||||||
const stageOne = readJson('contracts/stage-one-platform.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', () => {
|
test('clean Tauri foundation contains no inherited product updater endpoint', () => {
|
||||||
assert.deepEqual(foundation.upstream_product_update_endpoints, [])
|
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)
|
const source = readText(relative)
|
||||||
assert.doesNotMatch(source, /refactoringhq|tolaria|outline/i)
|
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', () => {
|
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_exact_human_approval, true)
|
||||||
assert.equal(foundation.release_broadcast_activation_requires_repeated_expected_facts, 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_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_pipeline_automatic_upload_allowed, false)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
foundation.release_production_activation_state,
|
foundation.release_production_activation_state,
|
||||||
|
|
|
||||||
|
|
@ -165,3 +165,27 @@ test('symlinked package inputs are rejected instead of followed', () => {
|
||||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function buildReleaseRoot() {
|
||||||
notes: 'Signed release',
|
notes: 'Signed release',
|
||||||
platforms: {
|
platforms: {
|
||||||
'darwin-aarch64': {
|
'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',
|
signature: 'trusted-updater-signature',
|
||||||
size: packageBytes.length,
|
size: packageBytes.length,
|
||||||
sha256: sha256(packageBytes),
|
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.equal(health.status, 200)
|
||||||
assert.deepEqual((await health.json()).upstreamUpdateSources, [])
|
assert.deepEqual((await health.json()).upstreamUpdateSources, [])
|
||||||
assert.equal((await fetch(`${base}/latest.json`)).status, 204)
|
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`)
|
const latest = await fetch(`${base}/latest.json`)
|
||||||
assert.equal(latest.status, 200)
|
assert.equal(latest.status, 200)
|
||||||
assert.equal((await latest.json()).releaseId, 'GH-HOLOLAKE-RELEASE-0.2.0')
|
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.equal(updater.status, 200)
|
||||||
assert.deepEqual(Buffer.from(await updater.arrayBuffer()), fixture.packageBytes)
|
assert.deepEqual(Buffer.from(await updater.arrayBuffer()), fixture.packageBytes)
|
||||||
assert.equal((await fetch(`${base}/unknown`)).status, 404)
|
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)
|
fs.symlinkSync(versionDirectory, current)
|
||||||
assert.equal(isMainModule(path.join(current, 'server.mjs'), new URL(`file://${copy}`)), true)
|
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')
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.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) => {
|
const fail = (code) => {
|
||||||
throw new Error(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')
|
if (!Array.isArray(trust.allowedReleaseHosts) || trust.allowedReleaseHosts.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_HOST_REQUIRED')
|
||||||
const endpoint = new URL(trust.endpoints[0])
|
const endpoint = new URL(trust.endpoints[0])
|
||||||
const host = requireText(trust.allowedReleaseHosts[0], 'HOLOLAKE_RELEASE_PIPELINE_HOST_REQUIRED')
|
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')
|
fail('HOLOLAKE_RELEASE_PIPELINE_ENDPOINT_NOT_HOLOLAKE_HTTPS')
|
||||||
}
|
}
|
||||||
if (requireText(trust.publicKey, 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_REQUIRED').length < 32) {
|
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 (!/^[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')
|
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'))
|
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')
|
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())) {
|
if (!Array.isArray(input.features) || input.features.length === 0 || input.features.some((item) => typeof item !== 'string' || !item.trim())) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
const readyTrust = () => ({
|
const readyTrust = () => ({
|
||||||
schema: 'hololake.release-trust/v1',
|
schema: 'hololake.release-trust/v1',
|
||||||
state: 'PROVISIONED',
|
state: 'PROVISIONED',
|
||||||
endpoints: ['https://release.guanghu.test/latest.json'],
|
endpoints: ['https://release.guanghu.test/hololake/releases/latest.json'],
|
||||||
publicKey: 'A'.repeat(64),
|
publicKey: 'A'.repeat(64),
|
||||||
allowedReleaseHosts: ['release.guanghu.test'],
|
allowedReleaseHosts: ['release.guanghu.test'],
|
||||||
automaticCheckOnStartup: false,
|
automaticCheckOnStartup: false,
|
||||||
|
|
@ -28,7 +28,7 @@ const readyInput = () => ({
|
||||||
releaseTag: 'v0.2.0',
|
releaseTag: 'v0.2.0',
|
||||||
sourceCommit: 'a'.repeat(40),
|
sourceCommit: 'a'.repeat(40),
|
||||||
platformCode: 'darwin-aarch64',
|
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',
|
appleTeamIdentifier: '825A9L3G7Q',
|
||||||
notes: 'Signed release',
|
notes: 'Signed release',
|
||||||
features: ['Persistent direct connection'],
|
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'
|
wrongHost.packageUrl = 'https://github.com/example/HoloLake.app.tar.gz'
|
||||||
assert.throws(() => validateReleaseInput(wrongHost, trust), /PACKAGE_HOST_NOT_TRUSTED/)
|
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()
|
const wrongTag = readyInput()
|
||||||
wrongTag.releaseTag = 'latest'
|
wrongTag.releaseTag = 'latest'
|
||||||
assert.throws(() => validateReleaseInput(wrongTag, trust), /IMMUTABLE_TAG_INVALID/)
|
assert.throws(() => validateReleaseInput(wrongTag, trust), /IMMUTABLE_TAG_INVALID/)
|
||||||
|
|
|
||||||
|
|
@ -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
|
provisioning, artifact upload, activation, and desktop rollout are independent
|
||||||
deployment gates.
|
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
|
`operator.mjs` supplies the separate, root-operated verification and activation
|
||||||
boundary. `verify` reconstructs a private candidate tree and accepts it only when
|
boundary. `verify` reconstructs a private candidate tree and accepts it only when
|
||||||
the broadcast, pipeline receipt, package bytes, Developer ID receipt, Apple
|
the broadcast, pipeline receipt, package bytes, Developer ID receipt, Apple
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Environment=HOLOLAKE_RELEASE_HOST=127.0.0.1
|
||||||
Environment=HOLOLAKE_RELEASE_PORT=3940
|
Environment=HOLOLAKE_RELEASE_PORT=3940
|
||||||
Environment=HOLOLAKE_RELEASE_STATE_ROOT=/var/lib/guanghu/hololake-release-broadcast
|
Environment=HOLOLAKE_RELEASE_STATE_ROOT=/var/lib/guanghu/hololake-release-broadcast
|
||||||
Environment=HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER=1
|
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
|
ExecStart=/usr/bin/node /opt/guanghu/hololake-release-broadcast-candidate/current/server.mjs
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
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 MAX_CONTROL_BYTES = 1024 * 1024
|
||||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||||
|
|
@ -54,7 +54,7 @@ function directFile(directory, name, code) {
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageNameFromUrl(value) {
|
function packageNameFromUrl(value, publicPrefix) {
|
||||||
const url = new URL(requireText(value, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_REQUIRED'))
|
const url = new URL(requireText(value, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_REQUIRED'))
|
||||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
||||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
||||||
|
|
@ -66,6 +66,9 @@ function packageNameFromUrl(value) {
|
||||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
||||||
}
|
}
|
||||||
if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_NAME_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
|
return packageName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,8 +78,13 @@ function addArtifact(artifacts, source, name, type) {
|
||||||
artifacts.set(name, { 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 source = path.resolve(sourceDirectory)
|
||||||
|
const normalizedPublicPrefix = normalizePublicPrefix(publicPrefix)
|
||||||
requireTrustedDirectory(source, { enforceRootOwner })
|
requireTrustedDirectory(source, { enforceRootOwner })
|
||||||
const latestPath = directFile(source, 'latest.json', 'HOLOLAKE_RELEASE_OPERATOR_BROADCAST_PATH_INVALID')
|
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')
|
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, pipelinePath, 'pipeline-receipt.json', 'control')
|
||||||
addArtifact(artifacts, approvalPath, 'human-approval.json', 'control')
|
addArtifact(artifacts, approvalPath, 'human-approval.json', 'control')
|
||||||
for (const platform of Object.values(broadcast.platforms)) {
|
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 packagePath = directFile(source, packageName, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PATH_INVALID')
|
||||||
const packageInfo = requireTrustedFile(packagePath, { enforceRootOwner })
|
const packageInfo = requireTrustedFile(packagePath, { enforceRootOwner })
|
||||||
if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) {
|
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,
|
sourceCommit: pipelineReceipt.sourceCommit,
|
||||||
broadcastSha256,
|
broadcastSha256,
|
||||||
approvalId,
|
approvalId,
|
||||||
|
publicPrefix: normalizedPublicPrefix,
|
||||||
artifacts: [...artifacts.values()],
|
artifacts: [...artifacts.values()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +244,7 @@ function prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupI
|
||||||
}
|
}
|
||||||
fs.chownSync(activationPath, 0, trustedGroupId)
|
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') {
|
if (state.state !== 'READY_SIGNED_NOTARIZED_BROADCAST') {
|
||||||
fail(`HOLOLAKE_RELEASE_OPERATOR_CANDIDATE_INVALID:${state.reasonCode || state.state}`)
|
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`)
|
const tempActivation = path.join(stateRoot, `.ACTIVE.${crypto.randomUUID()}.tmp`)
|
||||||
copyFileNoFollow(candidateActivation, tempActivation, enforceRootOwner ? 0o640 : 0o600)
|
copyFileNoFollow(candidateActivation, tempActivation, enforceRootOwner ? 0o640 : 0o600)
|
||||||
if (enforceRootOwner) fs.chownSync(tempActivation, 0, trustedGroupId)
|
if (enforceRootOwner) fs.chownSync(tempActivation, 0, trustedGroupId)
|
||||||
fs.renameSync(tempActivation, path.join(stateRoot, 'ACTIVE.json'))
|
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
|
if (committed.state === 'READY_SIGNED_NOTARIZED_BROADCAST') return committed
|
||||||
const failedActive = path.join(stateRoot, 'ACTIVE.json')
|
const failedActive = path.join(stateRoot, 'ACTIVE.json')
|
||||||
if (previousBytes === null) {
|
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')
|
if (enforceRootOwner && process.getuid?.() !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_ROOT_REQUIRED')
|
||||||
const root = path.resolve(stateRoot)
|
const root = path.resolve(stateRoot)
|
||||||
const rootInfo = requireTrustedDirectory(root, { enforceRootOwner })
|
const rootInfo = requireTrustedDirectory(root, { enforceRootOwner })
|
||||||
|
|
@ -320,7 +336,7 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot,
|
||||||
const lockDirectory = acquireOperatorLock(root, enforceRootOwner, trustedGroupId)
|
const lockDirectory = acquireOperatorLock(root, enforceRootOwner, trustedGroupId)
|
||||||
let candidateRoot = null
|
let candidateRoot = null
|
||||||
try {
|
try {
|
||||||
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner })
|
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner, publicPrefix })
|
||||||
requireExpected(plan, expected)
|
requireExpected(plan, expected)
|
||||||
candidateRoot = fs.mkdtempSync(path.join(root, '.candidate-'))
|
candidateRoot = fs.mkdtempSync(path.join(root, '.candidate-'))
|
||||||
const candidate = prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId })
|
const candidate = prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId })
|
||||||
|
|
@ -338,7 +354,14 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot,
|
||||||
fs.renameSync(candidate.releaseDirectory, finalRelease)
|
fs.renameSync(candidate.releaseDirectory, finalRelease)
|
||||||
let committed
|
let committed
|
||||||
try {
|
try {
|
||||||
committed = replaceActivation(root, candidate.activationPath, previousBytes, enforceRootOwner, trustedGroupId)
|
committed = replaceActivation(
|
||||||
|
root,
|
||||||
|
candidate.activationPath,
|
||||||
|
previousBytes,
|
||||||
|
enforceRootOwner,
|
||||||
|
trustedGroupId,
|
||||||
|
plan.publicPrefix,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fs.rmSync(finalRelease, { recursive: true, force: true })
|
fs.rmSync(finalRelease, { recursive: true, force: true })
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -351,6 +374,7 @@ export function activateRelease({ sourceDirectory, humanApprovalPath, stateRoot,
|
||||||
sourceCommit: plan.sourceCommit,
|
sourceCommit: plan.sourceCommit,
|
||||||
broadcastSha256: plan.broadcastSha256,
|
broadcastSha256: plan.broadcastSha256,
|
||||||
approvalId: plan.approvalId,
|
approvalId: plan.approvalId,
|
||||||
|
publicPrefix: plan.publicPrefix,
|
||||||
automaticUpload: false,
|
automaticUpload: false,
|
||||||
automaticActivation: false,
|
automaticActivation: false,
|
||||||
automaticRestart: false,
|
automaticRestart: false,
|
||||||
|
|
@ -386,6 +410,7 @@ export function main(argv = process.argv.slice(2)) {
|
||||||
sourceDirectory,
|
sourceDirectory,
|
||||||
humanApprovalPath,
|
humanApprovalPath,
|
||||||
stateRoot: requireText(values['state-root'], 'HOLOLAKE_RELEASE_OPERATOR_STATE_ROOT_REQUIRED'),
|
stateRoot: requireText(values['state-root'], 'HOLOLAKE_RELEASE_OPERATOR_STATE_ROOT_REQUIRED'),
|
||||||
|
publicPrefix: values['public-prefix'] || DEFAULT_PUBLIC_PREFIX,
|
||||||
expected: {
|
expected: {
|
||||||
releaseId: values['expect-release-id'],
|
releaseId: values['expect-release-id'],
|
||||||
version: values['expect-version'],
|
version: values['expect-version'],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const MAX_CONTROL_BYTES = 1024 * 1024
|
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 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}$/
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||||
|
|
||||||
|
|
@ -21,6 +22,18 @@ const requireText = (value, code) => {
|
||||||
return value.trim()
|
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 }) {
|
function trustedRegularFile(file, { enforceRootOwner, maxBytes = null }) {
|
||||||
const info = fs.lstatSync(file)
|
const info = fs.lstatSync(file)
|
||||||
if (!info.isFile() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_FILE_NOT_REGULAR')
|
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')) }
|
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'))
|
const url = new URL(requireText(platform?.url, 'HOLOLAKE_RELEASE_PACKAGE_URL_REQUIRED'))
|
||||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
||||||
fail('HOLOLAKE_RELEASE_PACKAGE_URL_INVALID')
|
fail('HOLOLAKE_RELEASE_PACKAGE_URL_INVALID')
|
||||||
}
|
}
|
||||||
const packageName = decodeURIComponent(path.posix.basename(url.pathname))
|
const packageName = decodeURIComponent(path.posix.basename(url.pathname))
|
||||||
if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_PACKAGE_NAME_INVALID')
|
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 packagePath = safeRelative(releaseDirectory, packageName, 'HOLOLAKE_RELEASE_PACKAGE_PATH_INVALID')
|
||||||
const packageInfo = trustedRegularFile(packagePath, { enforceRootOwner })
|
const packageInfo = trustedRegularFile(packagePath, { enforceRootOwner })
|
||||||
if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) {
|
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 }
|
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')
|
const activationPath = path.join(stateRoot, 'ACTIVE.json')
|
||||||
if (!fs.existsSync(activationPath)) {
|
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
|
const activation = readTrustedJson(activationPath, { enforceRootOwner }).value
|
||||||
if (
|
if (
|
||||||
|
|
@ -135,7 +151,7 @@ function loadActiveRelease(stateRoot, enforceRootOwner) {
|
||||||
const releaseDirectory = path.dirname(broadcastPath)
|
const releaseDirectory = path.dirname(broadcastPath)
|
||||||
const packages = new Map()
|
const packages = new Map()
|
||||||
for (const platform of Object.values(broadcast.platforms)) {
|
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')
|
if (packages.has(accepted.pathname)) fail('HOLOLAKE_RELEASE_PACKAGE_ROUTE_COLLISION')
|
||||||
packages.set(accepted.pathname, accepted)
|
packages.set(accepted.pathname, accepted)
|
||||||
}
|
}
|
||||||
|
|
@ -143,18 +159,21 @@ function loadActiveRelease(stateRoot, enforceRootOwner) {
|
||||||
state: 'READY_SIGNED_NOTARIZED_BROADCAST',
|
state: 'READY_SIGNED_NOTARIZED_BROADCAST',
|
||||||
release: { releaseId, version, broadcastBytes: broadcastDocument.bytes, packages },
|
release: { releaseId, version, broadcastBytes: broadcastDocument.bytes, packages },
|
||||||
reasonCode: null,
|
reasonCode: null,
|
||||||
|
publicPrefix,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadRuntimeState(stateRoot, { enforceRootOwner = false } = {}) {
|
export function loadRuntimeState(stateRoot, { enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {}) {
|
||||||
const resolvedRoot = path.resolve(stateRoot)
|
const resolvedRoot = path.resolve(stateRoot)
|
||||||
try {
|
try {
|
||||||
return loadActiveRelease(resolvedRoot, enforceRootOwner)
|
const normalizedPrefix = normalizePublicPrefix(publicPrefix)
|
||||||
|
return loadActiveRelease(resolvedRoot, enforceRootOwner, normalizedPrefix)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
state: 'LOCKED_INVALID_RELEASE_EVIDENCE',
|
state: 'LOCKED_INVALID_RELEASE_EVIDENCE',
|
||||||
release: null,
|
release: null,
|
||||||
reasonCode: error instanceof Error ? error.message : 'HOLOLAKE_RELEASE_UNKNOWN_FAILURE',
|
reasonCode: error instanceof Error ? error.message : 'HOLOLAKE_RELEASE_UNKNOWN_FAILURE',
|
||||||
|
publicPrefix: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +197,9 @@ export function createReleaseServer(runtimeState) {
|
||||||
return response.end()
|
return response.end()
|
||||||
}
|
}
|
||||||
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
|
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
|
const status = runtimeState.state === 'LOCKED_INVALID_RELEASE_EVIDENCE' ? 503 : 200
|
||||||
return jsonResponse(response, status, {
|
return jsonResponse(response, status, {
|
||||||
schema: 'hololake.release-broadcast-health/v1',
|
schema: 'hololake.release-broadcast-health/v1',
|
||||||
|
|
@ -187,11 +208,15 @@ export function createReleaseServer(runtimeState) {
|
||||||
automaticUpload: false,
|
automaticUpload: false,
|
||||||
automaticActivation: false,
|
automaticActivation: false,
|
||||||
upstreamUpdateSources: [],
|
upstreamUpdateSources: [],
|
||||||
|
...(runtimeState.publicPrefix ? {
|
||||||
|
publicPathPrefix: runtimeState.publicPrefix,
|
||||||
|
releaseEndpointPath: `${runtimeState.publicPrefix}/latest.json`,
|
||||||
|
} : {}),
|
||||||
...(runtimeState.release ? { releaseId: runtimeState.release.releaseId, version: runtimeState.release.version } : {}),
|
...(runtimeState.release ? { releaseId: runtimeState.release.releaseId, version: runtimeState.release.version } : {}),
|
||||||
...(runtimeState.reasonCode ? { reasonCode: runtimeState.reasonCode } : {}),
|
...(runtimeState.reasonCode ? { reasonCode: runtimeState.reasonCode } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (pathname === '/latest.json') {
|
if (pathname === '/latest.json' || pathname === publicLatestPath) {
|
||||||
if (runtimeState.state === 'EMPTY_FAIL_CLOSED') {
|
if (runtimeState.state === 'EMPTY_FAIL_CLOSED') {
|
||||||
response.writeHead(204, { 'Cache-Control': 'no-store' })
|
response.writeHead(204, { 'Cache-Control': 'no-store' })
|
||||||
return response.end()
|
return response.end()
|
||||||
|
|
@ -230,7 +255,11 @@ export function main(env = process.env) {
|
||||||
const port = Number(env.HOLOLAKE_RELEASE_PORT || 3940)
|
const port = Number(env.HOLOLAKE_RELEASE_PORT || 3940)
|
||||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) fail('HOLOLAKE_RELEASE_PORT_INVALID')
|
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 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)
|
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.on('clientError', (_error, socket) => socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'))
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use tauri::{AppHandle, Runtime, Url};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
const EMBEDDED_RELEASE_TRUST: &str = include_str!("../release-trust.json");
|
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)]
|
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
|
@ -108,6 +109,12 @@ fn validate_release_trust(raw: &str) -> Result<ValidatedReleaseTrust, String> {
|
||||||
"the updater endpoint is not owned by the configured HoloLake host".into(),
|
"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 {
|
Ok(ValidatedReleaseTrust::Enabled {
|
||||||
endpoints: vec![endpoint],
|
endpoints: vec![endpoint],
|
||||||
public_key: trust.public_key,
|
public_key: trust.public_key,
|
||||||
|
|
@ -166,7 +173,7 @@ mod tests {
|
||||||
fn accepts_https_shape_but_rejects_insecure_endpoints() {
|
fn accepts_https_shape_but_rejects_insecure_endpoints() {
|
||||||
let owned_shape = document(
|
let owned_shape = document(
|
||||||
"PROVISIONED",
|
"PROVISIONED",
|
||||||
"\"https://releases.example.test/latest.json\"",
|
"\"https://releases.example.test/hololake/releases/latest.json\"",
|
||||||
"public",
|
"public",
|
||||||
"\"releases.example.test\"",
|
"\"releases.example.test\"",
|
||||||
);
|
);
|
||||||
|
|
@ -176,18 +183,25 @@ mod tests {
|
||||||
);
|
);
|
||||||
let insecure = document(
|
let insecure = document(
|
||||||
"PROVISIONED",
|
"PROVISIONED",
|
||||||
"\"http://releases.example.test/latest.json\"",
|
"\"http://releases.example.test/hololake/releases/latest.json\"",
|
||||||
"public",
|
"public",
|
||||||
"\"releases.example.test\"",
|
"\"releases.example.test\"",
|
||||||
);
|
);
|
||||||
assert!(validate_release_trust(&insecure).is_err());
|
assert!(validate_release_trust(&insecure).is_err());
|
||||||
let mismatched_host = document(
|
let mismatched_host = document(
|
||||||
"PROVISIONED",
|
"PROVISIONED",
|
||||||
"\"https://upstream.example.test/latest.json\"",
|
"\"https://upstream.example.test/hololake/releases/latest.json\"",
|
||||||
"public",
|
"public",
|
||||||
"\"releases.example.test\"",
|
"\"releases.example.test\"",
|
||||||
);
|
);
|
||||||
assert!(validate_release_trust(&mismatched_host).is_err());
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1";
|
||||||
const RECOVERY_SCHEMA: &str = "hololake.release-recovery/v1";
|
const RECOVERY_SCHEMA: &str = "hololake.release-recovery/v1";
|
||||||
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
|
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
|
||||||
const EXPECTED_BUNDLE_IDENTIFIER: &str = "world.guanghu.hololake";
|
const EXPECTED_BUNDLE_IDENTIFIER: &str = "world.guanghu.hololake";
|
||||||
|
const PUBLIC_RELEASE_PACKAGE_PREFIX: &str = "/hololake/releases/";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
|
@ -472,7 +473,12 @@ fn validate_update(update: &Update, allowed_host: &str) -> Result<UpdateSnapshot
|
||||||
return Err("HOLOLAKE_RELEASE_BROADCAST_POLICY_INVALID".into());
|
return Err("HOLOLAKE_RELEASE_BROADCAST_POLICY_INVALID".into());
|
||||||
}
|
}
|
||||||
let url = &update.download_url;
|
let url = &update.download_url;
|
||||||
if url.scheme() != "https" || url.host_str() != Some(allowed_host) {
|
if url.scheme() != "https"
|
||||||
|
|| url.host_str() != Some(allowed_host)
|
||||||
|
|| !url.path().starts_with(PUBLIC_RELEASE_PACKAGE_PREFIX)
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
return Err("HOLOLAKE_RELEASE_PACKAGE_HOST_NOT_TRUSTED".into());
|
return Err("HOLOLAKE_RELEASE_PACKAGE_HOST_NOT_TRUSTED".into());
|
||||||
}
|
}
|
||||||
let platform = envelope
|
let platform = envelope
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue