@dsh-enhanced/plugin-control-plane 0.1.31 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -4
- package/bin/dsh-local-release-adapter.js +80 -16
- package/bin/dsh-npm-registry-adapter.js +613 -0
- package/bin/dsh-source-adoption-authority.js +12 -0
- package/bin/dsh-source-approval-authority.js +16 -0
- package/bin/dsh-source-release-authority.js +12 -0
- package/bin/dsh-systemd-host-attestor.js +707 -0
- package/bin/dsh-task-observation-authority.js +15 -0
- package/lib/adapter-process.d.ts +23 -0
- package/lib/adapter-process.d.ts.map +1 -0
- package/lib/adapter-process.js +305 -0
- package/lib/adapter-process.js.map +1 -0
- package/lib/adoption-coordinator.d.ts +63 -0
- package/lib/adoption-coordinator.d.ts.map +1 -0
- package/lib/adoption-coordinator.js +241 -0
- package/lib/adoption-coordinator.js.map +1 -0
- package/lib/adoption-handoff.d.ts +16 -0
- package/lib/adoption-handoff.d.ts.map +1 -0
- package/lib/adoption-handoff.js +11 -0
- package/lib/adoption-handoff.js.map +1 -0
- package/lib/attestation.d.ts.map +1 -1
- package/lib/attestation.js +49 -1
- package/lib/attestation.js.map +1 -1
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +29 -2
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +33 -9
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +624 -178
- package/lib/cli.js.map +1 -1
- package/lib/deployment-readiness.d.ts +38 -0
- package/lib/deployment-readiness.d.ts.map +1 -0
- package/lib/deployment-readiness.js +154 -0
- package/lib/deployment-readiness.js.map +1 -0
- package/lib/effect-blocked-replay.d.ts +81 -0
- package/lib/effect-blocked-replay.d.ts.map +1 -0
- package/lib/effect-blocked-replay.js +259 -0
- package/lib/effect-blocked-replay.js.map +1 -0
- package/lib/errors.d.ts +6 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +9 -0
- package/lib/errors.js.map +1 -0
- package/lib/foreground-deployment-runtime.d.ts +20 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -0
- package/lib/foreground-deployment-runtime.js +79 -0
- package/lib/foreground-deployment-runtime.js.map +1 -0
- package/lib/foreground-deployment.d.ts +17 -0
- package/lib/foreground-deployment.d.ts.map +1 -0
- package/lib/foreground-deployment.js +43 -0
- package/lib/foreground-deployment.js.map +1 -0
- package/lib/host-attestor.d.ts +1 -1
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +51 -57
- package/lib/host-attestor.js.map +1 -1
- package/lib/index.d.ts +25 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +11 -2
- package/lib/index.js.map +1 -1
- package/lib/npm-publish.d.ts +25 -0
- package/lib/npm-publish.d.ts.map +1 -0
- package/lib/npm-publish.js +280 -0
- package/lib/npm-publish.js.map +1 -0
- package/lib/owner-task-gap-types.d.ts +21 -0
- package/lib/owner-task-gap-types.d.ts.map +1 -0
- package/lib/owner-task-gap-types.js +2 -0
- package/lib/owner-task-gap-types.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +24 -0
- package/lib/owner-task-gaps.d.ts.map +1 -0
- package/lib/owner-task-gaps.js +74 -0
- package/lib/owner-task-gaps.js.map +1 -0
- package/lib/post-activation.d.ts +33 -0
- package/lib/post-activation.d.ts.map +1 -0
- package/lib/post-activation.js +219 -0
- package/lib/post-activation.js.map +1 -0
- package/lib/registry-fetch.d.ts +50 -0
- package/lib/registry-fetch.d.ts.map +1 -0
- package/lib/registry-fetch.js +267 -0
- package/lib/registry-fetch.js.map +1 -0
- package/lib/release.d.ts +4 -4
- package/lib/release.d.ts.map +1 -1
- package/lib/release.js +142 -56
- package/lib/release.js.map +1 -1
- package/lib/replay-endpoint-protocol.d.ts +65 -0
- package/lib/replay-endpoint-protocol.d.ts.map +1 -0
- package/lib/replay-endpoint-protocol.js +183 -0
- package/lib/replay-endpoint-protocol.js.map +1 -0
- package/lib/replay-endpoint.d.ts +7 -0
- package/lib/replay-endpoint.d.ts.map +1 -0
- package/lib/replay-endpoint.js +193 -0
- package/lib/replay-endpoint.js.map +1 -0
- package/lib/replay-grant.d.ts +62 -0
- package/lib/replay-grant.d.ts.map +1 -0
- package/lib/replay-grant.js +208 -0
- package/lib/replay-grant.js.map +1 -0
- package/lib/replay-journal.d.ts +25 -0
- package/lib/replay-journal.d.ts.map +1 -0
- package/lib/replay-journal.js +275 -0
- package/lib/replay-journal.js.map +1 -0
- package/lib/runtime-observer-protocol.d.ts +63 -0
- package/lib/runtime-observer-protocol.d.ts.map +1 -0
- package/lib/runtime-observer-protocol.js +280 -0
- package/lib/runtime-observer-protocol.js.map +1 -0
- package/lib/runtime-observer.d.ts +8 -0
- package/lib/runtime-observer.d.ts.map +1 -0
- package/lib/runtime-observer.js +182 -0
- package/lib/runtime-observer.js.map +1 -0
- package/lib/service.d.ts +145 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +734 -6
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +65 -0
- package/lib/source-adoption-authority.d.ts.map +1 -0
- package/lib/source-adoption-authority.js +271 -0
- package/lib/source-adoption-authority.js.map +1 -0
- package/lib/source-adoption-runner.d.ts +28 -0
- package/lib/source-adoption-runner.d.ts.map +1 -0
- package/lib/source-adoption-runner.js +197 -0
- package/lib/source-adoption-runner.js.map +1 -0
- package/lib/source-approval-authority.d.ts +50 -0
- package/lib/source-approval-authority.d.ts.map +1 -0
- package/lib/source-approval-authority.js +412 -0
- package/lib/source-approval-authority.js.map +1 -0
- package/lib/source-approval-client.d.ts +32 -0
- package/lib/source-approval-client.d.ts.map +1 -0
- package/lib/source-approval-client.js +175 -0
- package/lib/source-approval-client.js.map +1 -0
- package/lib/source-build.d.ts +60 -0
- package/lib/source-build.d.ts.map +1 -0
- package/lib/source-build.js +311 -0
- package/lib/source-build.js.map +1 -0
- package/lib/source-context.d.ts +24 -0
- package/lib/source-context.d.ts.map +1 -0
- package/lib/source-context.js +99 -0
- package/lib/source-context.js.map +1 -0
- package/lib/source-job-types.d.ts +87 -0
- package/lib/source-job-types.d.ts.map +1 -0
- package/lib/source-job-types.js +2 -0
- package/lib/source-job-types.js.map +1 -0
- package/lib/source-jobs.d.ts +113 -0
- package/lib/source-jobs.d.ts.map +1 -0
- package/lib/source-jobs.js +535 -0
- package/lib/source-jobs.js.map +1 -0
- package/lib/source-release-authority.d.ts +35 -0
- package/lib/source-release-authority.d.ts.map +1 -0
- package/lib/source-release-authority.js +271 -0
- package/lib/source-release-authority.js.map +1 -0
- package/lib/source-release-client.d.ts +10 -0
- package/lib/source-release-client.d.ts.map +1 -0
- package/lib/source-release-client.js +24 -0
- package/lib/source-release-client.js.map +1 -0
- package/lib/source-release-runner.d.ts +31 -0
- package/lib/source-release-runner.d.ts.map +1 -0
- package/lib/source-release-runner.js +125 -0
- package/lib/source-release-runner.js.map +1 -0
- package/lib/source-versioning.d.ts +24 -0
- package/lib/source-versioning.d.ts.map +1 -0
- package/lib/source-versioning.js +213 -0
- package/lib/source-versioning.js.map +1 -0
- package/lib/source-workspace.d.ts +117 -0
- package/lib/source-workspace.d.ts.map +1 -0
- package/lib/source-workspace.js +458 -0
- package/lib/source-workspace.js.map +1 -0
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +509 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +229 -6
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2431 -383
- package/lib/store.js.map +1 -1
- package/lib/task-observation-authority.d.ts +29 -0
- package/lib/task-observation-authority.d.ts.map +1 -0
- package/lib/task-observation-authority.js +370 -0
- package/lib/task-observation-authority.js.map +1 -0
- package/lib/task-observation-client.d.ts +4 -0
- package/lib/task-observation-client.d.ts.map +1 -0
- package/lib/task-observation-client.js +20 -0
- package/lib/task-observation-client.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +59 -0
- package/lib/task-observation-runtime.d.ts.map +1 -0
- package/lib/task-observation-runtime.js +331 -0
- package/lib/task-observation-runtime.js.map +1 -0
- package/lib/task-observation-store.d.ts +17 -0
- package/lib/task-observation-store.d.ts.map +1 -0
- package/lib/task-observation-store.js +115 -0
- package/lib/task-observation-store.js.map +1 -0
- package/lib/task-observation-types.d.ts +67 -0
- package/lib/task-observation-types.d.ts.map +1 -0
- package/lib/task-observation-types.js +2 -0
- package/lib/task-observation-types.js.map +1 -0
- package/lib/trust.d.ts +3 -0
- package/lib/trust.d.ts.map +1 -1
- package/lib/trust.js +0 -0
- package/lib/trust.js.map +1 -1
- package/lib/types.d.ts +275 -8
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +40 -5
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
#!/usr/bin/node
|
|
2
|
+
// Owner-operated supervisor boundary. This executable never runs in the Host Fiber.
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'
|
|
4
|
+
import { closeSync, constants as F, existsSync, fsyncSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, readSync, realpathSync, statfsSync } from 'node:fs'
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
export const SYSTEMD_HOST_ATTESTOR_VERSION = 'dsh-systemd-host-attestor-5'
|
|
10
|
+
const DIGEST = /^[a-f0-9]{64}$/u
|
|
11
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u
|
|
12
|
+
const UNIT_PROPERTIES = ['FragmentPath', 'DropInPaths', 'ExecStart', 'Environment', 'WorkingDirectory', 'User', 'Group', 'Type', 'KillMode']
|
|
13
|
+
const STATUS_PROPERTIES = ['Id', 'LoadState', 'ActiveState', 'SubState', 'MainPID', 'ControlPID', 'InvocationID', 'NRestarts', 'ControlGroup']
|
|
14
|
+
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(',')}]`
|
|
15
|
+
: value !== null && typeof value === 'object' ? `{${Object.entries(value).filter(([, item]) => item !== undefined)
|
|
16
|
+
.sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}` : JSON.stringify(value)
|
|
17
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
18
|
+
const digest = value => hash(canonical(value))
|
|
19
|
+
const fail = message => { throw new Error(`systemd Host attestor: ${message}`) }
|
|
20
|
+
const within = (root, path) => path === root || path.startsWith(`${root}/`)
|
|
21
|
+
function object(value, keys, label) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
23
|
+
|| Object.keys(value).sort().join('\0') !== [...keys].sort().join('\0')) fail(`${label} fields are invalid`)
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
function text(value, label, pattern = /^[^\r\n]*$/u) {
|
|
27
|
+
if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 16384 || !pattern.test(value)) fail(`${label} is invalid`)
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
function integer(value, label, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
31
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) fail(`${label} is invalid`)
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
function canonicalPath(path, label) {
|
|
35
|
+
text(path, label)
|
|
36
|
+
if (!isAbsolute(path) || path === '/' || resolve(path) !== path || realpathSync(path) !== path) fail(`${label} is not canonical`)
|
|
37
|
+
return path
|
|
38
|
+
}
|
|
39
|
+
function privateDirectory(path) {
|
|
40
|
+
canonicalPath(path, 'private directory'); const stat = lstatSync(path)
|
|
41
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) fail('directory is not owner-private')
|
|
42
|
+
}
|
|
43
|
+
function syncDirectory(path) {
|
|
44
|
+
const fd = openSync(path, F.O_RDONLY | F.O_DIRECTORY)
|
|
45
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
46
|
+
}
|
|
47
|
+
function descriptorBytes(fd, maximum) {
|
|
48
|
+
const before = fstatSync(fd, { bigint: true })
|
|
49
|
+
if (!before.isFile() || before.nlink !== 1n || before.size > BigInt(maximum) || (before.mode & 0o022n) !== 0n
|
|
50
|
+
|| ![0n, BigInt(process.getuid())].includes(before.uid)) fail('unsafe file descriptor')
|
|
51
|
+
const bytes = Buffer.alloc(Number(before.size)); let offset = 0
|
|
52
|
+
while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!count) fail('short file read'); offset += count }
|
|
53
|
+
const after = fstatSync(fd, { bigint: true })
|
|
54
|
+
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size
|
|
55
|
+
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) fail('file changed during read')
|
|
56
|
+
return bytes
|
|
57
|
+
}
|
|
58
|
+
function readSafe(path, maximum = 65536, privateMode = false) {
|
|
59
|
+
canonicalPath(path, 'file'); const before = lstatSync(path, { bigint: true })
|
|
60
|
+
if (privateMode) {
|
|
61
|
+
privateDirectory(dirname(path))
|
|
62
|
+
if (before.uid !== BigInt(process.getuid()) || (before.mode & 0o077n) !== 0n) fail('file is not owner-private')
|
|
63
|
+
}
|
|
64
|
+
const fd = openSync(path, F.O_RDONLY | F.O_NOFOLLOW)
|
|
65
|
+
try {
|
|
66
|
+
const opened = fstatSync(fd, { bigint: true }); const bytes = descriptorBytes(fd, maximum); const after = lstatSync(path, { bigint: true })
|
|
67
|
+
if (before.dev !== opened.dev || before.ino !== opened.ino || after.dev !== opened.dev || after.ino !== opened.ino) fail('file pathname changed')
|
|
68
|
+
return bytes
|
|
69
|
+
} finally { closeSync(fd) }
|
|
70
|
+
}
|
|
71
|
+
function pin(spec, label, executable = false) {
|
|
72
|
+
object(spec, ['path', 'sha256'], label); text(spec.sha256, `${label} digest`, DIGEST)
|
|
73
|
+
if (hash(readSafe(spec.path, 268435456)) !== spec.sha256) fail(`${label} digest changed`)
|
|
74
|
+
if (executable && (lstatSync(spec.path).mode & 0o111) === 0) fail(`${label} is not executable`)
|
|
75
|
+
return spec
|
|
76
|
+
}
|
|
77
|
+
function baselinePin(spec) {
|
|
78
|
+
object(spec, ['path', 'sha256'], 'baseline pin')
|
|
79
|
+
if (spec.sha256 !== null) return pin(spec, 'baseline file')
|
|
80
|
+
text(spec.path, 'absent baseline path')
|
|
81
|
+
if (!isAbsolute(spec.path) || resolve(spec.path) !== spec.path) fail('absent baseline path is invalid')
|
|
82
|
+
canonicalPath(dirname(spec.path), 'baseline parent')
|
|
83
|
+
try { lstatSync(spec.path) } catch (error) { if (error.code === 'ENOENT') return spec; throw error }
|
|
84
|
+
fail('originally absent baseline file exists')
|
|
85
|
+
}
|
|
86
|
+
function runningHash(path) {
|
|
87
|
+
const match = path.match(/^\/proc\/self\/fd\/(\d+)$/u)
|
|
88
|
+
return hash(match ? descriptorBytes(Number(match[1]), 268435456) : readSafe(path, 268435456))
|
|
89
|
+
}
|
|
90
|
+
function parseJson(bytes, label) { try { return JSON.parse(bytes.toString('utf8')) } catch { fail(`${label} JSON is invalid`) } }
|
|
91
|
+
|
|
92
|
+
function assertAbsentProfile(path) {
|
|
93
|
+
text(path, 'absent profile')
|
|
94
|
+
if (!isAbsolute(path) || resolve(path) !== path || path === '/') fail('absent profile path is invalid')
|
|
95
|
+
canonicalPath(dirname(path), 'profile parent')
|
|
96
|
+
try { lstatSync(path) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
97
|
+
fail('originally absent profile still exists')
|
|
98
|
+
}
|
|
99
|
+
function validateReadiness(ready, profilePath, withReload) {
|
|
100
|
+
object(ready, ['client', 'observer', 'deploymentFiles', ...(withReload ? ['reloadOperationId'] : [])], 'readiness')
|
|
101
|
+
pin(ready.client, 'observer client')
|
|
102
|
+
if (!Array.isArray(ready.deploymentFiles) || !ready.deploymentFiles.length || ready.deploymentFiles.length > 128) fail('deployment pins required')
|
|
103
|
+
for (const spec of ready.deploymentFiles) pin(spec, 'deployment file')
|
|
104
|
+
if (new Set(ready.deploymentFiles.map(spec => spec.path)).size !== ready.deploymentFiles.length) fail('duplicate deployment pin')
|
|
105
|
+
if (ready.observer?.profilePath !== profilePath) fail('observer profile differs')
|
|
106
|
+
}
|
|
107
|
+
function loadConfig(environment, request) {
|
|
108
|
+
const config = parseJson(readSafe(environment.DSH_SYSTEMD_HOST_ATTESTOR_CONFIG, 65536, true), 'config')
|
|
109
|
+
object(config, ['schemaVersion', 'authority', 'keyId', 'privateKeyPath', 'stateRoot', 'executable', 'interpreter', 'processHelper',
|
|
110
|
+
'systemctl', 'scope', 'unit', 'unitProperties', 'profileFiles', 'authorization', 'timeoutMs', 'stableWindowMs', 'pollIntervalMs',
|
|
111
|
+
...([2, 3].includes(config.schemaVersion) ? ['readiness'] : [])], 'config')
|
|
112
|
+
if (![1, 2, 3].includes(config.schemaVersion)) fail('config schema is unsupported')
|
|
113
|
+
text(config.authority, 'authority', ID); text(config.keyId, 'keyId', ID)
|
|
114
|
+
integer(config.timeoutMs, 'timeout', 1000, 60000); integer(config.stableWindowMs, 'stable window', 50, 10000)
|
|
115
|
+
integer(config.pollIntervalMs, 'poll interval', 25, 1000)
|
|
116
|
+
if (config.stableWindowMs + config.pollIntervalMs >= config.timeoutMs) fail('window exceeds deadline')
|
|
117
|
+
if (!['user', 'system'].includes(config.scope)) fail('unsupported supervisor scope')
|
|
118
|
+
const auth = object(config.authorization, ['installationId', 'ledger', 'profile', 'plan', 'activation', config.schemaVersion === 2 ? 'hostGeneration' : 'previousHostGeneration', 'requestDigest', 'notBefore', 'expiresAt'], 'authorization')
|
|
119
|
+
text(auth.requestDigest, 'authorized request digest', DIGEST)
|
|
120
|
+
if (digest(request) !== auth.requestDigest) fail('exact request is not authorized')
|
|
121
|
+
object(auth.ledger, ['id', 'path'], 'ledger'); object(auth.profile, ['name', 'path'], 'profile')
|
|
122
|
+
object(auth.plan, ['id', 'digest'], 'plan'); object(auth.activation, ['id', 'fence'], 'activation')
|
|
123
|
+
text(auth.installationId, 'installation', /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u)
|
|
124
|
+
for (const value of [auth.ledger.id, auth.plan.id, auth.activation.id]) text(value, 'identity', ID)
|
|
125
|
+
text(auth.plan.digest, 'plan digest', DIGEST); text(auth.profile.name, 'profile name', /^[a-z0-9][a-z0-9-]{0,63}$/u)
|
|
126
|
+
if (config.schemaVersion === 3 && request.requirements?.action === 'stop') assertAbsentProfile(auth.profile.path)
|
|
127
|
+
else canonicalPath(auth.profile.path, 'profile')
|
|
128
|
+
canonicalPath(auth.ledger.path, 'ledger')
|
|
129
|
+
if (basename(auth.profile.path) !== auth.profile.name || basename(dirname(auth.profile.path)) !== 'profiles') fail('profile path and name differ')
|
|
130
|
+
integer(auth.activation.fence, 'fence', 1)
|
|
131
|
+
integer(config.schemaVersion === 2 ? auth.hostGeneration : auth.previousHostGeneration, 'authorized generation', config.schemaVersion === 2 ? 1 : 0, Number.MAX_SAFE_INTEGER - 1)
|
|
132
|
+
integer(auth.notBefore, 'authorization start'); integer(auth.expiresAt, 'authorization expiry', auth.notBefore + 1)
|
|
133
|
+
if (config.unit !== `dsh-profile-${auth.profile.name}.service`) fail('unit does not bind the profile')
|
|
134
|
+
object(config.unitProperties, UNIT_PROPERTIES, 'unit properties')
|
|
135
|
+
for (const property of UNIT_PROPERTIES) text(config.unitProperties[property], property)
|
|
136
|
+
if (!['simple', 'exec', 'notify'].includes(config.unitProperties.Type) || config.unitProperties.KillMode !== 'control-group') fail('unsupported unit lifecycle')
|
|
137
|
+
const stopping = config.schemaVersion === 3 && request.requirements?.action === 'stop'
|
|
138
|
+
if (!Array.isArray(config.profileFiles) || config.profileFiles.length < (stopping ? 0 : 3) || config.profileFiles.length > (stopping ? 0 : 32)) fail('profile pins are invalid')
|
|
139
|
+
const paths = config.profileFiles.map(spec => {
|
|
140
|
+
if (config.schemaVersion === 3) baselinePin(spec); else pin(spec, 'profile file')
|
|
141
|
+
if (!within(auth.profile.path, spec.path)) fail('profile pin is outside profile')
|
|
142
|
+
return spec.path
|
|
143
|
+
})
|
|
144
|
+
if (new Set(paths).size !== paths.length || (!stopping && !['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'].every(name => paths.includes(join(auth.profile.path, name))))) fail('profile manifest, lockfile and patch pins are required')
|
|
145
|
+
privateDirectory(config.stateRoot)
|
|
146
|
+
for (const path of [environment.DSH_SYSTEMD_HOST_ATTESTOR_CONFIG, config.privateKeyPath, config.stateRoot]) {
|
|
147
|
+
if (within(auth.profile.path, path)) fail('attestor authority must be outside the candidate profile')
|
|
148
|
+
}
|
|
149
|
+
const privateKey = createPrivateKey(readSafe(config.privateKeyPath, 16384, true))
|
|
150
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') fail('signing key must be Ed25519')
|
|
151
|
+
pin(config.executable, 'attestor executable', true); pin(config.interpreter, 'attestor interpreter', true); pin(config.processHelper, 'process helper')
|
|
152
|
+
if (runningHash(process.argv[1]) !== config.executable.sha256 || runningHash(process.execPath) !== config.interpreter.sha256) fail('running attestor identity differs')
|
|
153
|
+
object(config.systemctl, ['path', 'sha256', 'interpreter'], 'systemctl')
|
|
154
|
+
pin({ path: config.systemctl.path, sha256: config.systemctl.sha256 }, 'systemctl', true)
|
|
155
|
+
if (config.systemctl.interpreter !== null) pin(config.systemctl.interpreter, 'systemctl interpreter', true)
|
|
156
|
+
object(request, ['schemaVersion', 'kind', 'operationId', 'requestedAt', 'receiptTtlMs', 'installationId', 'ledger', 'plan', 'activation', 'profile', 'issuer', 'phase', 'requirements', 'predecessor'], 'request')
|
|
157
|
+
if (request.schemaVersion !== 2 || request.kind !== 'dsh-host-attestation-request'
|
|
158
|
+
|| request.phase !== (config.schemaVersion === 1 ? 'reload' : config.schemaVersion === 2 ? 'readiness' : 'rollback')) fail('phase does not match configured authority')
|
|
159
|
+
if (request.predecessor !== null) {
|
|
160
|
+
const prior = object(request.predecessor, ['operationId', 'receiptId', 'phase', 'receiptDigest', 'hostGeneration'], 'predecessor')
|
|
161
|
+
text(prior.operationId, 'predecessor operation', ID); text(prior.receiptId, 'predecessor receipt', ID)
|
|
162
|
+
text(prior.receiptDigest, 'predecessor receipt digest', DIGEST)
|
|
163
|
+
integer(prior.hostGeneration, 'predecessor generation', 1)
|
|
164
|
+
if (!['reload', 'readiness', 'effect-blocked-replay', 'shadow', 'canary', 'soak', 'health'].includes(prior.phase)
|
|
165
|
+
|| prior.operationId === request.operationId) fail('predecessor phase or operation is invalid')
|
|
166
|
+
}
|
|
167
|
+
if (config.schemaVersion === 1) {
|
|
168
|
+
if (request.predecessor !== null) fail('reload cannot have a predecessor')
|
|
169
|
+
object(request.requirements, ['kind', 'previousHostGeneration'], 'requirements')
|
|
170
|
+
if (request.requirements.kind !== 'reload' || request.requirements.previousHostGeneration !== auth.previousHostGeneration) fail('generation is not authorized')
|
|
171
|
+
} else if (config.schemaVersion === 3) {
|
|
172
|
+
const requirements = object(request.requirements, ['kind', 'previousHostGeneration', 'action', 'baselineFiles', 'minimumChecks'], 'rollback requirements')
|
|
173
|
+
if (requirements.kind !== 'rollback' || !['restore', 'stop'].includes(requirements.action)
|
|
174
|
+
|| requirements.previousHostGeneration !== auth.previousHostGeneration
|
|
175
|
+
|| canonical(requirements.baselineFiles) !== canonical(config.profileFiles)) fail('rollback baseline or action is not authorized')
|
|
176
|
+
if (request.predecessor !== null && request.predecessor.hostGeneration > requirements.previousHostGeneration) fail('rollback predecessor generation is ahead of recovery')
|
|
177
|
+
integer(requirements.minimumChecks, 'minimum checks', 1, 256)
|
|
178
|
+
if (stopping) { if (config.readiness !== null) fail('stop cannot request runtime readiness') }
|
|
179
|
+
else {
|
|
180
|
+
if (config.profileFiles.length !== 3) fail('rollback requires exactly three baseline pins')
|
|
181
|
+
validateReadiness(config.readiness, auth.profile.path, false)
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
object(request.requirements, ['kind', 'minimumChecks'], 'requirements')
|
|
185
|
+
if (request.requirements.kind !== 'readiness') fail('readiness requirements required')
|
|
186
|
+
integer(request.requirements.minimumChecks, 'minimum checks', 1, 256)
|
|
187
|
+
const ready = object(config.readiness, ['client', 'observer', 'deploymentFiles', 'reloadOperationId'], 'readiness')
|
|
188
|
+
if (request.predecessor === null || request.predecessor.phase !== 'reload'
|
|
189
|
+
|| request.predecessor.operationId !== ready.reloadOperationId
|
|
190
|
+
|| request.predecessor.hostGeneration !== auth.hostGeneration) fail('readiness predecessor does not bind the authorized reload')
|
|
191
|
+
text(ready.reloadOperationId, 'reload operation', ID); pin(ready.client, 'observer client')
|
|
192
|
+
if (!Array.isArray(ready.deploymentFiles) || !ready.deploymentFiles.length || ready.deploymentFiles.length > 128) fail('deployment pins required')
|
|
193
|
+
for (const spec of ready.deploymentFiles) pin(spec, 'deployment file')
|
|
194
|
+
if (new Set(ready.deploymentFiles.map(spec => spec.path)).size !== ready.deploymentFiles.length) fail('duplicate deployment pin')
|
|
195
|
+
if (ready.observer?.profilePath !== auth.profile.path) fail('observer profile differs')
|
|
196
|
+
}
|
|
197
|
+
text(request.operationId, 'operation', ID)
|
|
198
|
+
if (request.operationId.length > 152) fail('operation is too long for receipt identity')
|
|
199
|
+
integer(request.requestedAt, 'requestedAt'); integer(request.receiptTtlMs, 'receipt TTL', 1, 3600000)
|
|
200
|
+
for (const field of ['installationId', 'ledger', 'profile', 'plan', 'activation']) if (canonical(request[field]) !== canonical(auth[field])) fail(`${field} is not authorized`)
|
|
201
|
+
object(request.issuer, ['mode', 'id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'issuer')
|
|
202
|
+
text(request.issuer.id, 'issuer ID', ID)
|
|
203
|
+
if (request.issuer.mode !== 'configured-executable' || request.issuer.version !== SYSTEMD_HOST_ATTESTOR_VERSION
|
|
204
|
+
|| request.issuer.path !== config.executable.path || request.issuer.sha256 !== config.executable.sha256
|
|
205
|
+
|| canonical(request.issuer.interpreter) !== canonical(config.interpreter)
|
|
206
|
+
|| request.issuer.authority !== config.authority || request.issuer.keyId !== config.keyId) fail('issuer differs from owner configuration')
|
|
207
|
+
assertCurrent(config, request)
|
|
208
|
+
return { config, privateKey }
|
|
209
|
+
}
|
|
210
|
+
function assertCurrent(config, request) {
|
|
211
|
+
const now = Date.now(); const auth = config.authorization
|
|
212
|
+
if (request.requestedAt < auth.notBefore || request.requestedAt > now || now > auth.expiresAt) fail('authorization expired or not yet valid')
|
|
213
|
+
}
|
|
214
|
+
function assertProfile(config) {
|
|
215
|
+
if (config.schemaVersion === 3 && config.readiness === null) assertAbsentProfile(config.authorization.profile.path)
|
|
216
|
+
for (const spec of config.profileFiles) { if (config.schemaVersion === 3) baselinePin(spec); else pin(spec, 'profile file') }
|
|
217
|
+
for (const spec of config.readiness?.deploymentFiles ?? []) pin(spec, 'deployment file')
|
|
218
|
+
}
|
|
219
|
+
function remaining(deadline) { const value = Math.floor(deadline - performance.now()); if (value <= 0) fail('observation deadline exceeded; outcome needs reconciliation'); return value }
|
|
220
|
+
function normalizeExecStart(value) {
|
|
221
|
+
// systemctl appends per-invocation runtime fields after ignore_errors.
|
|
222
|
+
const match = value.match(/^(\{ path=[^\r\n]* ; argv\[\]=[^\r\n]* ; ignore_errors=(?:yes|no)) ; (?:start_time=[^\r\n]* )?\}$/u)
|
|
223
|
+
if (!match || value.split('{ path=').length !== 2) fail('unsupported ExecStart representation')
|
|
224
|
+
return `${match[1]} ; }`
|
|
225
|
+
}
|
|
226
|
+
async function command(config, execute, args, deadline) {
|
|
227
|
+
const specs = [{ path: config.systemctl.path, sha256: config.systemctl.sha256 }, ...(config.systemctl.interpreter ? [config.systemctl.interpreter] : [])]
|
|
228
|
+
const descriptors = []
|
|
229
|
+
try {
|
|
230
|
+
for (const spec of specs) {
|
|
231
|
+
pin(spec, 'systemctl command', true)
|
|
232
|
+
const fd = openSync(spec.path, F.O_RDONLY | F.O_NOFOLLOW); descriptors.push(fd)
|
|
233
|
+
if (hash(descriptorBytes(fd, 268435456)) !== spec.sha256) fail('command inode changed')
|
|
234
|
+
}
|
|
235
|
+
const env = { LANG: 'C', LC_ALL: 'C', SYSTEMD_PAGER: '', SYSTEMD_COLORS: '0' }
|
|
236
|
+
if (config.scope === 'user') {
|
|
237
|
+
const runtime = `/run/user/${process.getuid()}`; privateDirectory(runtime)
|
|
238
|
+
env.XDG_RUNTIME_DIR = runtime; env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${runtime}/bus`
|
|
239
|
+
}
|
|
240
|
+
const result = await execute({ command: descriptors.length === 2 ? '/proc/self/fd/4' : '/proc/self/fd/3',
|
|
241
|
+
args: [...(descriptors.length === 2 ? ['/proc/self/fd/3'] : []), `--${config.scope}`, ...args], env,
|
|
242
|
+
stdio: ['pipe', 'pipe', 'ignore', ...descriptors], stdin: undefined, maximumOutput: 65536, timeoutMs: remaining(deadline) })
|
|
243
|
+
for (let index = 0; index < descriptors.length; index++) if (hash(descriptorBytes(descriptors[index], 268435456)) !== specs[index].sha256) fail('command mutated')
|
|
244
|
+
remaining(deadline)
|
|
245
|
+
return result
|
|
246
|
+
} finally { for (const fd of descriptors) closeSync(fd) }
|
|
247
|
+
}
|
|
248
|
+
async function observe(config, execute, deadline, allowRemoved = false) {
|
|
249
|
+
const keys = [...STATUS_PROPERTIES, ...UNIT_PROPERTIES, ...(config.schemaVersion === 3 ? ['Job'] : [])]
|
|
250
|
+
const source = await command(config, execute, ['show', config.unit, '--no-pager', ...keys.map(key => `--property=${key}`)], deadline)
|
|
251
|
+
const fields = {}
|
|
252
|
+
for (const line of source.trimEnd().split('\n')) {
|
|
253
|
+
const separator = line.indexOf('='); const key = line.slice(0, separator)
|
|
254
|
+
if (separator < 0 || !keys.includes(key) || Object.hasOwn(fields, key)) fail('invalid or duplicate supervisor property')
|
|
255
|
+
fields[key] = line.slice(separator + 1)
|
|
256
|
+
}
|
|
257
|
+
const removed = allowRemoved && config.schemaVersion === 3 && config.readiness === null && fields.LoadState === 'not-found'
|
|
258
|
+
// systemd omits service-only properties once a transient unit is unloaded.
|
|
259
|
+
if (removed) for (const key of UNIT_PROPERTIES) if (!Object.hasOwn(fields, key)) fields[key] = ''
|
|
260
|
+
object(fields, keys, 'supervisor observation')
|
|
261
|
+
if (fields.Id !== config.unit || (!removed && fields.LoadState !== 'loaded')) fail('unit identity or load state changed')
|
|
262
|
+
if (removed) {
|
|
263
|
+
if (fields.ActiveState !== 'inactive' || fields.SubState !== 'dead' || fields.MainPID !== '0'
|
|
264
|
+
|| fields.ControlPID !== '0' || fields.ControlGroup !== '' || !['', '0'].includes(fields.Job)) fail('removed unit retains runtime state')
|
|
265
|
+
} else {
|
|
266
|
+
fields.ExecStart = normalizeExecStart(fields.ExecStart)
|
|
267
|
+
for (const key of UNIT_PROPERTIES) if (fields[key] !== config.unitProperties[key]) fail('effective unit configuration drifted')
|
|
268
|
+
}
|
|
269
|
+
for (const key of ['MainPID', 'ControlPID', 'NRestarts']) {
|
|
270
|
+
if (!/^(0|[1-9]\d*)$/u.test(fields[key])) fail('invalid supervisor counter')
|
|
271
|
+
fields[key] = integer(Number(fields[key]), key)
|
|
272
|
+
}
|
|
273
|
+
if (!(config.schemaVersion === 3 && fields.ControlGroup === '' && fields.MainPID === 0 && fields.ControlPID === 0)
|
|
274
|
+
&& (!/^\/[A-Za-z0-9_.@:/\\-]+$/u.test(fields.ControlGroup) || fields.ControlGroup.includes('..'))) fail('invalid unit cgroup')
|
|
275
|
+
const memberships = readFileSync('/proc/self/cgroup', 'utf8').trim().split('\n')
|
|
276
|
+
let ownHierarchyObserved = false
|
|
277
|
+
for (const line of memberships) {
|
|
278
|
+
const match = line.match(/^\d+:([^:]*):(.*)$/u)
|
|
279
|
+
if (match && (match[1] === '' || match[1].split(',').includes('name=systemd'))) {
|
|
280
|
+
if (!match[2].startsWith('/') || match[2].includes('..')) fail('cannot establish attestor cgroup membership')
|
|
281
|
+
ownHierarchyObserved = true
|
|
282
|
+
if (fields.ControlGroup && within(fields.ControlGroup, match[2])) fail('attestor must run outside the target service cgroup')
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!ownHierarchyObserved) fail('cannot establish attestor cgroup membership')
|
|
286
|
+
if (config.schemaVersion === 3 && !['', '0'].includes(fields.Job)) fail('unit has an unsettled supervisor job')
|
|
287
|
+
assertProfile(config)
|
|
288
|
+
return fields
|
|
289
|
+
}
|
|
290
|
+
function active(observation) {
|
|
291
|
+
return observation.ActiveState === 'active' && observation.SubState === 'running' && observation.MainPID > 0
|
|
292
|
+
&& observation.ControlPID === 0 && /^[a-f0-9]{32}$/u.test(observation.InvocationID) && !/^0+$/u.test(observation.InvocationID)
|
|
293
|
+
}
|
|
294
|
+
function journal(config) {
|
|
295
|
+
const path = join(config.stateRoot, 'reload.sqlite')
|
|
296
|
+
for (const suffix of ['', '-wal', '-shm', '-journal']) if (existsSync(path + suffix)) readSafe(path + suffix, 67108864, true)
|
|
297
|
+
const oldMask = process.umask(0o077); let db
|
|
298
|
+
try { db = new DatabaseSync(path) } finally { process.umask(oldMask) }
|
|
299
|
+
try {
|
|
300
|
+
db.exec(`PRAGMA busy_timeout=1000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
301
|
+
CREATE TABLE IF NOT EXISTS reloads (
|
|
302
|
+
operation_id TEXT PRIMARY KEY, request_digest TEXT NOT NULL, config_digest TEXT NOT NULL,
|
|
303
|
+
scope_id TEXT NOT NULL, generation INTEGER NOT NULL, activation_id TEXT NOT NULL,
|
|
304
|
+
prior TEXT NOT NULL, observation TEXT, receipt TEXT, request TEXT, config TEXT,
|
|
305
|
+
UNIQUE(scope_id, generation), UNIQUE(scope_id, activation_id));`)
|
|
306
|
+
transaction(db, () => {
|
|
307
|
+
const columns = db.prepare('PRAGMA table_info(reloads)').all().map(column => column.name)
|
|
308
|
+
for (const name of ['request', 'config']) if (!columns.includes(name)) db.exec(`ALTER TABLE reloads ADD COLUMN ${name} TEXT`)
|
|
309
|
+
db.exec(`CREATE TABLE IF NOT EXISTS readiness (
|
|
310
|
+
operation_id TEXT PRIMARY KEY, request_digest TEXT NOT NULL, config_digest TEXT NOT NULL,
|
|
311
|
+
reload_id TEXT NOT NULL UNIQUE, observation TEXT, receipt TEXT);`)
|
|
312
|
+
})
|
|
313
|
+
syncDirectory(config.stateRoot)
|
|
314
|
+
return db
|
|
315
|
+
} catch { db.close(); fail('private supervisor journal could not be opened') }
|
|
316
|
+
}
|
|
317
|
+
function transaction(db, action) {
|
|
318
|
+
db.exec('BEGIN IMMEDIATE')
|
|
319
|
+
try { const result = action(); db.exec('COMMIT'); return result } catch (error) { db.exec('ROLLBACK'); throw error }
|
|
320
|
+
}
|
|
321
|
+
function cachedReceipt(row, request, config, privateKey) {
|
|
322
|
+
if (row.request_digest !== digest(request) || row.config_digest !== digest(config)) fail('operation identity changed')
|
|
323
|
+
if (row.receipt === null) return undefined
|
|
324
|
+
const receipt = parseJson(Buffer.from(row.receipt), 'cached receipt'); const { signature, ...unsigned } = receipt
|
|
325
|
+
if (receipt.requestDigest !== row.request_digest || !verify(null, Buffer.from(canonical(unsigned)), createPublicKey(privateKey), Buffer.from(signature, 'base64'))) fail('cached receipt is invalid')
|
|
326
|
+
if (Date.now() > receipt.expiresAt) fail('cached receipt expired; owner reconciliation required')
|
|
327
|
+
return receipt
|
|
328
|
+
}
|
|
329
|
+
function boundReload(db, request, config, privateKey) {
|
|
330
|
+
const row = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1')
|
|
331
|
+
.get(digest({ installation: request.installationId }))
|
|
332
|
+
if (!row || row.operation_id !== config.readiness.reloadOperationId || row.generation !== config.authorization.hostGeneration
|
|
333
|
+
|| !row.receipt || !row.observation || !row.request || !row.config) fail('latest reload is unresolved, superseded or lacks retained context')
|
|
334
|
+
const priorRequest = parseJson(Buffer.from(row.request), 'reload request')
|
|
335
|
+
const priorConfig = parseJson(Buffer.from(row.config), 'reload config')
|
|
336
|
+
for (const field of ['installationId', 'ledger', 'profile', 'plan', 'activation']) {
|
|
337
|
+
if (canonical(priorRequest[field]) !== canonical(request[field])) fail('reload context differs')
|
|
338
|
+
}
|
|
339
|
+
for (const field of ['authority', 'keyId', 'privateKeyPath', 'stateRoot', 'scope', 'unit', 'unitProperties', 'profileFiles', 'systemctl']) {
|
|
340
|
+
if (canonical(priorConfig[field]) !== canonical(config[field])) fail('reload deployment differs')
|
|
341
|
+
}
|
|
342
|
+
const receipt = cachedReceipt(row, priorRequest, priorConfig, privateKey)
|
|
343
|
+
const observation = parseJson(Buffer.from(row.observation), 'reload observation')
|
|
344
|
+
if (priorRequest.phase !== 'reload' || priorRequest.operationId !== row.operation_id
|
|
345
|
+
|| receipt.phase !== 'reload' || receipt.outcome !== 'passed' || receipt.hostGeneration !== row.generation
|
|
346
|
+
|| receipt.operationId !== row.operation_id || receipt.installationId !== request.installationId
|
|
347
|
+
|| receipt.planId !== request.plan.id || receipt.planDigest !== request.plan.digest
|
|
348
|
+
|| receipt.activationId !== request.activation.id || receipt.fence !== request.activation.fence
|
|
349
|
+
|| receipt.authority !== config.authority || receipt.keyId !== config.keyId
|
|
350
|
+
|| receipt.evidenceDigest !== digest(receipt.evidence) || receipt.evidence.kind !== 'reload'
|
|
351
|
+
|| receipt.evidence.reloaded !== true || receipt.evidence.currentHostGeneration !== row.generation
|
|
352
|
+
|| receipt.evidence.previousHostGeneration !== priorRequest.requirements.previousHostGeneration
|
|
353
|
+
|| receipt.evidence.probeDigest !== digest(observation) || observation.requestDigest !== row.request_digest
|
|
354
|
+
|| observation.configDigest !== row.config_digest || !active(observation.successor)) fail('reload evidence binding is invalid')
|
|
355
|
+
if (canonical(request.predecessor) !== canonical({ operationId: receipt.operationId, receiptId: receipt.receiptId,
|
|
356
|
+
phase: receipt.phase, receiptDigest: digest(receipt), hostGeneration: receipt.hostGeneration })) fail('readiness predecessor differs from the signed reload')
|
|
357
|
+
return { operationId: row.operation_id, generation: row.generation, receiptDigest: digest(receipt), successor: observation.successor }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function attestReadiness(request, config, privateKey, db, execute, deadline) {
|
|
361
|
+
const ready = config.readiness
|
|
362
|
+
const bytes = readSafe(ready.client.path, 1048576)
|
|
363
|
+
if (hash(bytes) !== ready.client.sha256) fail('observer client changed')
|
|
364
|
+
const client = await import(`data:text/javascript;base64,${bytes.toString('base64')}`)
|
|
365
|
+
client.validateRuntimeObserverConfig(ready.observer)
|
|
366
|
+
const observerDigest = client.runtimeConfigDigest(ready.observer)
|
|
367
|
+
// Pin the channel key as well as its configured path for this entire attempt.
|
|
368
|
+
const channelDigest = hash(readSafe(ready.observer.keyPath, 32, true))
|
|
369
|
+
const reload = boundReload(db, request, config, privateKey)
|
|
370
|
+
const reserved = transaction(db, () => {
|
|
371
|
+
const row = db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
|
|
372
|
+
if (row) { cachedReceipt(row, request, config, privateKey); return row }
|
|
373
|
+
db.prepare('INSERT INTO readiness(operation_id, request_digest, config_digest, reload_id) VALUES (?, ?, ?, ?)')
|
|
374
|
+
.run(request.operationId, digest(request), digest(config), reload.operationId)
|
|
375
|
+
return db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
|
|
376
|
+
})
|
|
377
|
+
const cached = cachedReceipt(reserved, request, config, privateKey)
|
|
378
|
+
const retained = cached ? parseJson(Buffer.from(reserved.observation), 'readiness observation') : undefined
|
|
379
|
+
if (retained && (cached.evidence.probeDigest !== digest(retained) || canonical(retained.reload) !== canonical(reload))) fail('retained readiness binding differs')
|
|
380
|
+
let stable; let started; let failures = 0; const samples = []; const challenges = new Set()
|
|
381
|
+
for (;;) {
|
|
382
|
+
assertCurrent(config, request); remaining(deadline)
|
|
383
|
+
if (canonical(boundReload(db, request, config, privateKey)) !== canonical(reload)) fail('reload changed during readiness')
|
|
384
|
+
const before = await observe(config, execute, deadline)
|
|
385
|
+
if (canonical(before) !== canonical(reload.successor)) fail('supervisor no longer matches the reload successor')
|
|
386
|
+
if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
|
|
387
|
+
const queryStarted = Date.now()
|
|
388
|
+
const sample = await client.queryRuntimeObserver({ ...ready.observer, signal: AbortSignal.timeout(remaining(deadline)) })
|
|
389
|
+
if (sample.profilePath !== request.profile.path || sample.observerConfigDigest !== observerDigest
|
|
390
|
+
|| sample.processId !== before.MainPID || sample.invocationId !== before.InvocationID
|
|
391
|
+
|| sample.observedAt < queryStarted || sample.observedAt > Date.now() || challenges.has(sample.challenge)
|
|
392
|
+
|| sample.entries.length !== ready.observer.targets.length) fail('observer identity or freshness differs')
|
|
393
|
+
challenges.add(sample.challenge)
|
|
394
|
+
let sampleFailed = false
|
|
395
|
+
for (const target of ready.observer.targets) {
|
|
396
|
+
const entry = sample.entries.find(item => item.entryId === target.entryId)
|
|
397
|
+
if (!entry || entry.module !== target.module || entry.configDigest !== target.configDigest
|
|
398
|
+
|| canonical(entry.services.map(service => service.name).sort()) !== canonical([...target.services].sort())) fail('candidate identity differs')
|
|
399
|
+
if (entry.active === true) {
|
|
400
|
+
if (!entry.instance || [...entry.dependencies, ...entry.services].some(service => !service.instance)) fail('active candidate is not ready')
|
|
401
|
+
} else if (entry.active === false) {
|
|
402
|
+
// A disabled or pending Fiber has no instance by contract. It is a
|
|
403
|
+
// signed negative result only after the complete authenticated identity
|
|
404
|
+
// remains stable for the full window; malformed identities still abort.
|
|
405
|
+
sampleFailed = true
|
|
406
|
+
} else fail('candidate active state is invalid')
|
|
407
|
+
}
|
|
408
|
+
const { challenge: _challenge, observedAt: _observedAt, ...identity } = sample
|
|
409
|
+
if (stable === undefined) { stable = identity; started = performance.now() }
|
|
410
|
+
else if (canonical(identity) !== canonical(stable)) fail('runtime instance changed during readiness')
|
|
411
|
+
if (retained && canonical(identity) !== canonical(retained.runtime)) fail('cached readiness runtime changed')
|
|
412
|
+
samples.push({ challenge: sample.challenge, observedAt: sample.observedAt, digest: digest(sample) })
|
|
413
|
+
if (sampleFailed) failures++
|
|
414
|
+
const after = await observe(config, execute, deadline)
|
|
415
|
+
if (canonical(after) !== canonical(reload.successor)) fail('supervisor changed during runtime query')
|
|
416
|
+
if (samples.length >= Math.max(2, request.requirements.minimumChecks) && performance.now() - started >= config.stableWindowMs) break
|
|
417
|
+
if (samples.length >= 256) fail('readiness sample limit exceeded')
|
|
418
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
|
|
419
|
+
}
|
|
420
|
+
assertCurrent(config, request); assertProfile(config); remaining(deadline)
|
|
421
|
+
if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
|
|
422
|
+
const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), reload,
|
|
423
|
+
channelDigest, runtime: stable, samples, stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
|
|
424
|
+
const evidence = { kind: 'readiness', checks: samples.length, failures, probeDigest: digest(observation) }
|
|
425
|
+
const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
|
|
426
|
+
installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
|
|
427
|
+
activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
|
|
428
|
+
requestDigest: digest(request), phase: 'readiness', outcome: failures === 0 ? 'passed' : 'failed', hostGeneration: reload.generation,
|
|
429
|
+
evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
|
|
430
|
+
expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
|
|
431
|
+
if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
|
|
432
|
+
return transaction(db, () => {
|
|
433
|
+
assertCurrent(config, request); remaining(deadline)
|
|
434
|
+
if (canonical(boundReload(db, request, config, privateKey)) !== canonical(reload)) fail('reload superseded before signing')
|
|
435
|
+
const row = db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
|
|
436
|
+
const replay = cachedReceipt(row, request, config, privateKey)
|
|
437
|
+
if (replay) {
|
|
438
|
+
const prior = parseJson(Buffer.from(row.observation), 'readiness observation')
|
|
439
|
+
if (replay.evidence.probeDigest !== digest(prior) || canonical(prior.runtime) !== canonical(stable)
|
|
440
|
+
|| canonical(prior.reload) !== canonical(reload) || prior.channelDigest !== channelDigest) fail('concurrent readiness binding differs')
|
|
441
|
+
return replay
|
|
442
|
+
}
|
|
443
|
+
const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
|
|
444
|
+
db.prepare('UPDATE readiness SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
|
|
445
|
+
.run(canonical(observation), canonical(receipt), request.operationId)
|
|
446
|
+
return cachedReceipt(db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
function assertRetiredProcess(prior) {
|
|
450
|
+
if (prior.MainPID === 0) return
|
|
451
|
+
try { process.kill(prior.MainPID, 0) } catch (error) { if (error.code === 'ESRCH') return; throw error }
|
|
452
|
+
fail('prior Host process still exists')
|
|
453
|
+
}
|
|
454
|
+
function visibleCgroupMount(path) {
|
|
455
|
+
const unified = statfsSync('/sys/fs/cgroup').type === 0x63677270
|
|
456
|
+
const root = unified ? '/sys/fs/cgroup' : '/sys/fs/cgroup/systemd'
|
|
457
|
+
if (!unified && statfsSync(root).type !== 0x27e0eb) fail('systemd cgroup mount is unavailable')
|
|
458
|
+
const own = readFileSync('/proc/self/cgroup', 'utf8').trim().split('\n')
|
|
459
|
+
.map(line => line.match(/^\d+:([^:]*):(.*)$/u)).find(match => match && (unified ? match[1] === '' : match[1].split(',').includes('name=systemd')))?.[2]
|
|
460
|
+
if (!own || !own.startsWith('/') || own.includes('..')) fail('cannot map attestor cgroup hierarchy')
|
|
461
|
+
const parent = dirname(path)
|
|
462
|
+
if (own === '/' && parent === '/') fail('root-only cgroup namespace cannot prove target visibility')
|
|
463
|
+
const members = readFileSync(join(root, own, unified ? 'cgroup.procs' : 'tasks'), 'utf8').trim().split('\n')
|
|
464
|
+
if (!members.includes(String(process.pid))) fail('cgroup mount does not map the attestor process')
|
|
465
|
+
const anchor = join(root, parent)
|
|
466
|
+
if (realpathSync(anchor) !== anchor || !lstatSync(anchor).isDirectory()
|
|
467
|
+
|| statfsSync(anchor).type !== statfsSync(root).type) fail('target cgroup parent is not visible on the supervisor hierarchy')
|
|
468
|
+
return { root, unified }
|
|
469
|
+
}
|
|
470
|
+
function assertEmptyCgroup(path) {
|
|
471
|
+
if (!path) return
|
|
472
|
+
// Only actual kernel cgroup mounts count as evidence. cgroup v2 populated
|
|
473
|
+
// covers descendants; legacy systemd uses its named v1 hierarchy instead.
|
|
474
|
+
const { root, unified } = visibleCgroupMount(path)
|
|
475
|
+
if (unified) {
|
|
476
|
+
let events
|
|
477
|
+
try { events = readFileSync(join(root, path, 'cgroup.events'), 'utf8') }
|
|
478
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
479
|
+
if (events.length > 4096 || events.split('\n').filter(line => line === 'populated 0').length !== 1
|
|
480
|
+
|| events.split('\n').some(line => line.startsWith('populated ') && line !== 'populated 0')) fail('stopped service retains cgroup processes')
|
|
481
|
+
} else {
|
|
482
|
+
let visited = 0
|
|
483
|
+
const visit = directory => {
|
|
484
|
+
if (++visited > 256) fail('systemd cgroup subtree exceeds bound')
|
|
485
|
+
let entries
|
|
486
|
+
try { entries = readdirSync(directory, { withFileTypes: true }) }
|
|
487
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
488
|
+
if (readFileSync(join(directory, 'tasks'), 'utf8').trim()) fail('stopped service retains cgroup tasks')
|
|
489
|
+
for (const entry of entries) if (entry.isDirectory()) visit(join(directory, entry.name))
|
|
490
|
+
}
|
|
491
|
+
visit(join(root, path))
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function latestRecovery(db, row) {
|
|
495
|
+
const latest = db.prepare('SELECT operation_id FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(row.scope_id)
|
|
496
|
+
if (!latest || latest.operation_id !== row.operation_id) fail('recovery generation was superseded')
|
|
497
|
+
}
|
|
498
|
+
async function recoveryRuntime(config, request, successor, execute, deadline, retained) {
|
|
499
|
+
const ready = config.readiness
|
|
500
|
+
const bytes = readSafe(ready.client.path, 1048576)
|
|
501
|
+
if (hash(bytes) !== ready.client.sha256) fail('observer client changed')
|
|
502
|
+
const client = await import(`data:text/javascript;base64,${bytes.toString('base64')}`)
|
|
503
|
+
client.validateRuntimeObserverConfig(ready.observer)
|
|
504
|
+
const observerDigest = client.runtimeConfigDigest(ready.observer)
|
|
505
|
+
const channelDigest = hash(readSafe(ready.observer.keyPath, 32, true))
|
|
506
|
+
let runtime; let started; const samples = []; const challenges = new Set()
|
|
507
|
+
for (;;) {
|
|
508
|
+
assertCurrent(config, request); remaining(deadline)
|
|
509
|
+
if (canonical(await observe(config, execute, deadline)) !== canonical(successor)) fail('recovery supervisor changed')
|
|
510
|
+
if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
|
|
511
|
+
const queriedAt = Date.now()
|
|
512
|
+
// After restart the observer may still be starting. No observation failure
|
|
513
|
+
// becomes proof: only a complete later authenticated stable window counts.
|
|
514
|
+
let sample
|
|
515
|
+
try { sample = await client.queryRuntimeObserver({ ...ready.observer, signal: AbortSignal.timeout(remaining(deadline)) }) }
|
|
516
|
+
catch (error) {
|
|
517
|
+
if (runtime !== undefined) throw error
|
|
518
|
+
remaining(deadline)
|
|
519
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
if (sample.profilePath !== request.profile.path || sample.observerConfigDigest !== observerDigest
|
|
523
|
+
|| sample.processId !== successor.MainPID || sample.invocationId !== successor.InvocationID
|
|
524
|
+
|| sample.observedAt < queriedAt || sample.observedAt > Date.now() || challenges.has(sample.challenge)
|
|
525
|
+
|| sample.entries.length !== ready.observer.targets.length) fail('restored runtime identity differs')
|
|
526
|
+
challenges.add(sample.challenge)
|
|
527
|
+
for (const target of ready.observer.targets) {
|
|
528
|
+
const entry = sample.entries.find(item => item.entryId === target.entryId)
|
|
529
|
+
if (!entry || entry.module !== target.module || entry.configDigest !== target.configDigest || entry.active !== true
|
|
530
|
+
|| !entry.instance || [...entry.dependencies, ...entry.services].some(service => !service.instance)
|
|
531
|
+
|| canonical(entry.services.map(service => service.name).sort()) !== canonical([...target.services].sort())) fail('restored candidate is not ready')
|
|
532
|
+
}
|
|
533
|
+
const { challenge: _challenge, observedAt: _observedAt, ...identity } = sample
|
|
534
|
+
if (runtime === undefined) { runtime = identity; started = performance.now() }
|
|
535
|
+
else if (canonical(identity) !== canonical(runtime)) fail('restored runtime changed during observation')
|
|
536
|
+
if (retained && (canonical(identity) !== canonical(retained.runtime) || channelDigest !== retained.channelDigest)) fail('cached recovery runtime changed')
|
|
537
|
+
samples.push({ challenge: sample.challenge, observedAt: sample.observedAt, digest: digest(sample) })
|
|
538
|
+
if (canonical(await observe(config, execute, deadline)) !== canonical(successor)) fail('recovery supervisor changed during query')
|
|
539
|
+
if (samples.length >= Math.max(2, request.requirements.minimumChecks) && performance.now() - started >= config.stableWindowMs) break
|
|
540
|
+
if (samples.length >= 256) fail('recovery runtime sample limit exceeded')
|
|
541
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
|
|
542
|
+
}
|
|
543
|
+
if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
|
|
544
|
+
return { runtime, channelDigest, samples }
|
|
545
|
+
}
|
|
546
|
+
async function attestRollback(request, config, privateKey, db, execute, deadline) {
|
|
547
|
+
const existing = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
|
|
548
|
+
if (existing) { cachedReceipt(existing, request, config, privateKey); latestRecovery(db, existing) }
|
|
549
|
+
const observed = await observe(config, execute, deadline, Boolean(existing))
|
|
550
|
+
if (observed.ControlGroup) visibleCgroupMount(observed.ControlGroup)
|
|
551
|
+
const scopeId = digest({ installation: request.installationId })
|
|
552
|
+
const reserved = transaction(db, () => {
|
|
553
|
+
const row = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
|
|
554
|
+
if (row) { cachedReceipt(row, request, config, privateKey); latestRecovery(db, row); return { row, dispatch: false } }
|
|
555
|
+
const latest = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(scopeId)
|
|
556
|
+
if (latest && (latest.receipt === null || latest.generation !== request.requirements.previousHostGeneration)) fail('previous generation is unresolved or stale')
|
|
557
|
+
const generation = request.requirements.previousHostGeneration + 1
|
|
558
|
+
const activationId = digest({ purpose: 'rollback', plan: request.plan, activation: request.activation })
|
|
559
|
+
db.prepare('INSERT INTO reloads(operation_id, request_digest, config_digest, scope_id, generation, activation_id, prior, request, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
560
|
+
.run(request.operationId, digest(request), digest(config), scopeId, generation, activationId, canonical(observed), canonical(request), canonical(config))
|
|
561
|
+
return { row: db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), dispatch: true }
|
|
562
|
+
})
|
|
563
|
+
const row = reserved.row; const prior = parseJson(Buffer.from(row.prior), 'prior recovery observation')
|
|
564
|
+
const cached = cachedReceipt(row, request, config, privateKey)
|
|
565
|
+
const retained = cached ? parseJson(Buffer.from(row.observation), 'cached recovery observation') : undefined
|
|
566
|
+
if (retained && cached.evidence.probeDigest !== digest(retained)) fail('cached recovery observation differs')
|
|
567
|
+
if (reserved.dispatch) {
|
|
568
|
+
syncDirectory(config.stateRoot); assertCurrent(config, request); assertProfile(config); latestRecovery(db, row)
|
|
569
|
+
await command(config, execute, [request.requirements.action === 'restore' ? 'restart' : 'stop', config.unit, '--no-ask-password', '--job-mode=fail'], deadline)
|
|
570
|
+
}
|
|
571
|
+
let successor; let stableAt; let samples = 0
|
|
572
|
+
for (;;) {
|
|
573
|
+
assertCurrent(config, request); remaining(deadline); latestRecovery(db, row)
|
|
574
|
+
const current = await observe(config, execute, deadline, true)
|
|
575
|
+
const settled = request.requirements.action === 'restore'
|
|
576
|
+
? active(current) && current.InvocationID !== prior.InvocationID && current.MainPID !== prior.MainPID
|
|
577
|
+
: current.ActiveState === 'inactive' && current.SubState === 'dead' && current.MainPID === 0 && current.ControlPID === 0
|
|
578
|
+
if (settled) {
|
|
579
|
+
assertRetiredProcess(prior)
|
|
580
|
+
if (request.requirements.action === 'stop') { assertEmptyCgroup(prior.ControlGroup); assertEmptyCgroup(current.ControlGroup) }
|
|
581
|
+
if (successor === undefined) { successor = current; stableAt = performance.now() }
|
|
582
|
+
else if (canonical(successor) !== canonical(current)) fail('recovered supervisor changed during observation')
|
|
583
|
+
if (retained && canonical(current) !== canonical(retained.successor)) fail('cached recovery supervisor changed')
|
|
584
|
+
samples++
|
|
585
|
+
if (samples >= Math.max(2, request.requirements.minimumChecks) && performance.now() - stableAt >= config.stableWindowMs) break
|
|
586
|
+
} else if (successor !== undefined || cached) fail('recovered supervisor is no longer settled')
|
|
587
|
+
if (samples >= 256) fail('recovery supervisor sample limit exceeded')
|
|
588
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
|
|
589
|
+
}
|
|
590
|
+
const runtime = request.requirements.action === 'restore'
|
|
591
|
+
? await recoveryRuntime(config, request, successor, execute, deadline, retained?.runtimeObservation) : null
|
|
592
|
+
assertRetiredProcess(prior); assertProfile(config); assertCurrent(config, request); remaining(deadline)
|
|
593
|
+
const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), prior, successor,
|
|
594
|
+
samples, runtimeObservation: runtime, stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
|
|
595
|
+
const evidence = { kind: 'rollback', action: request.requirements.action, previousHostGeneration: request.requirements.previousHostGeneration,
|
|
596
|
+
currentHostGeneration: row.generation, checks: runtime?.samples.length ?? samples, failures: 0, profileRestored: true, probeDigest: digest(observation) }
|
|
597
|
+
const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
|
|
598
|
+
installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
|
|
599
|
+
activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
|
|
600
|
+
requestDigest: digest(request), phase: 'rollback', outcome: 'passed', hostGeneration: row.generation,
|
|
601
|
+
evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
|
|
602
|
+
expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
|
|
603
|
+
if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
|
|
604
|
+
return transaction(db, () => {
|
|
605
|
+
latestRecovery(db, row); assertCurrent(config, request); remaining(deadline)
|
|
606
|
+
const current = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
|
|
607
|
+
const replay = cachedReceipt(current, request, config, privateKey)
|
|
608
|
+
if (replay) {
|
|
609
|
+
const priorObservation = parseJson(Buffer.from(current.observation), 'concurrent recovery observation')
|
|
610
|
+
if (replay.evidence.probeDigest !== digest(priorObservation) || canonical(priorObservation.successor) !== canonical(successor)
|
|
611
|
+
|| canonical(priorObservation.runtimeObservation?.runtime) !== canonical(runtime?.runtime)
|
|
612
|
+
|| priorObservation.runtimeObservation?.channelDigest !== runtime?.channelDigest) fail('concurrent recovery binding differs')
|
|
613
|
+
return replay
|
|
614
|
+
}
|
|
615
|
+
const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
|
|
616
|
+
db.prepare('UPDATE reloads SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
|
|
617
|
+
.run(canonical(observation), canonical(receipt), request.operationId)
|
|
618
|
+
return cachedReceipt(db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
async function attest(request, config, privateKey) {
|
|
622
|
+
const helperBytes = readSafe(config.processHelper.path, 1048576)
|
|
623
|
+
if (hash(helperBytes) !== config.processHelper.sha256) fail('process helper changed')
|
|
624
|
+
const { executeControlledProcess: execute } = await import(`data:text/javascript;base64,${helperBytes.toString('base64')}`)
|
|
625
|
+
if (typeof execute !== 'function') fail('process helper contract is unavailable')
|
|
626
|
+
const deadline = performance.now() + config.timeoutMs
|
|
627
|
+
const db = journal(config)
|
|
628
|
+
try {
|
|
629
|
+
if (request.phase === 'rollback') return await attestRollback(request, config, privateKey, db, execute, deadline)
|
|
630
|
+
if (request.phase === 'readiness') return await attestReadiness(request, config, privateKey, db, execute, deadline)
|
|
631
|
+
const existing = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
|
|
632
|
+
if (existing) { const receipt = cachedReceipt(existing, request, config, privateKey); if (receipt) return receipt }
|
|
633
|
+
const observed = await observe(config, execute, deadline)
|
|
634
|
+
if (!existing && !active(observed)) fail('target service is not stably active before restart')
|
|
635
|
+
// Control Plane generations are installation-wide, including when its
|
|
636
|
+
// profiles alternate. The retained request/config digests bind each unit.
|
|
637
|
+
const scopeId = digest({ installation: request.installationId })
|
|
638
|
+
const activationId = digest({ plan: request.plan, activation: request.activation })
|
|
639
|
+
const reserved = transaction(db, () => {
|
|
640
|
+
const row = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
|
|
641
|
+
if (row) { cachedReceipt(row, request, config, privateKey); return { row, dispatch: false } }
|
|
642
|
+
const latest = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(scopeId)
|
|
643
|
+
if (latest && (latest.receipt === null || latest.generation !== request.requirements.previousHostGeneration)) fail('previous generation is unresolved or stale')
|
|
644
|
+
const generation = request.requirements.previousHostGeneration + 1
|
|
645
|
+
db.prepare('INSERT INTO reloads(operation_id, request_digest, config_digest, scope_id, generation, activation_id, prior, request, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
646
|
+
.run(request.operationId, digest(request), digest(config), scopeId, generation, activationId, canonical(observed), canonical(request), canonical(config))
|
|
647
|
+
return { row: db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), dispatch: true }
|
|
648
|
+
})
|
|
649
|
+
const replay = cachedReceipt(reserved.row, request, config, privateKey); if (replay) return replay
|
|
650
|
+
const prior = parseJson(Buffer.from(reserved.row.prior), 'prior observation')
|
|
651
|
+
if (reserved.dispatch) {
|
|
652
|
+
// Reservation is durable before crossing the supervisor boundary. Any
|
|
653
|
+
// later failure is ambiguous; replay must never call restart again.
|
|
654
|
+
syncDirectory(config.stateRoot); assertCurrent(config, request); assertProfile(config)
|
|
655
|
+
await command(config, execute, ['restart', config.unit, '--no-ask-password', '--job-mode=fail'], deadline)
|
|
656
|
+
}
|
|
657
|
+
let successor; let stableAt = 0; let samples = 0
|
|
658
|
+
for (;;) {
|
|
659
|
+
assertCurrent(config, request); remaining(deadline)
|
|
660
|
+
const current = await observe(config, execute, deadline)
|
|
661
|
+
if (active(current) && current.InvocationID !== prior.InvocationID && current.MainPID !== prior.MainPID) {
|
|
662
|
+
if (successor === undefined) { successor = current; stableAt = performance.now(); samples = 1 }
|
|
663
|
+
else if (canonical(current) !== canonical(successor)) fail('successor changed during the stability window')
|
|
664
|
+
else samples++
|
|
665
|
+
if (performance.now() - stableAt >= config.stableWindowMs && samples >= 2) break
|
|
666
|
+
} else if (successor !== undefined) fail('successor left the active state')
|
|
667
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
|
|
668
|
+
}
|
|
669
|
+
assertCurrent(config, request); assertProfile(config); remaining(deadline)
|
|
670
|
+
const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), prior, successor, samples,
|
|
671
|
+
stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
|
|
672
|
+
const evidence = { kind: 'reload', reloaded: true, previousHostGeneration: request.requirements.previousHostGeneration,
|
|
673
|
+
currentHostGeneration: reserved.row.generation, probeDigest: digest(observation) }
|
|
674
|
+
const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
|
|
675
|
+
installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
|
|
676
|
+
activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
|
|
677
|
+
requestDigest: digest(request), phase: 'reload', outcome: 'passed', hostGeneration: reserved.row.generation,
|
|
678
|
+
evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
|
|
679
|
+
expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
|
|
680
|
+
if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
|
|
681
|
+
const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
|
|
682
|
+
return transaction(db, () => {
|
|
683
|
+
db.prepare('UPDATE reloads SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
|
|
684
|
+
.run(canonical(observation), canonical(receipt), request.operationId)
|
|
685
|
+
return cachedReceipt(db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
|
|
686
|
+
})
|
|
687
|
+
} finally { db.close() }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export async function runSystemdHostAttestor(argv = process.argv.slice(2), environment = process.env) {
|
|
691
|
+
if (argv.length === 1 && argv[0] === '--version') return SYSTEMD_HOST_ATTESTOR_VERSION
|
|
692
|
+
if (process.platform !== 'linux' || argv.length !== 1 || argv[0] !== 'attest') fail('Linux attest command required')
|
|
693
|
+
let bytes = 0; const chunks = []
|
|
694
|
+
for await (const chunk of process.stdin) { bytes += chunk.length; if (bytes > 65536) fail('request exceeds byte limit'); chunks.push(chunk) }
|
|
695
|
+
const request = parseJson(Buffer.concat(chunks), 'request')
|
|
696
|
+
if (!['reload', 'readiness', 'rollback'].includes(request?.phase)) fail('only reload, readiness and rollback requests are supported')
|
|
697
|
+
const { config, privateKey } = loadConfig(environment, request)
|
|
698
|
+
return attest(request, config, privateKey)
|
|
699
|
+
}
|
|
700
|
+
if (process.argv[1] && (/^\/proc\/self\/fd\/\d+$/u.test(process.argv[1]) || realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))) {
|
|
701
|
+
try { const result = await runSystemdHostAttestor(); process.stdout.write(`${typeof result === 'string' ? result : JSON.stringify(result)}\n`) }
|
|
702
|
+
catch (error) {
|
|
703
|
+
const reason = error instanceof Error && error.message.startsWith('systemd Host attestor:') ? error.message
|
|
704
|
+
: 'systemd Host attestor refused the request; inspect owner configuration and durable observations before retrying'
|
|
705
|
+
process.stderr.write(`${reason}\n`); process.exitCode = 1
|
|
706
|
+
}
|
|
707
|
+
}
|