Files
MikrotikManager/backend/scripts/database-writer-bootstrap-operator.mjs
T

106 lines
8.0 KiB
JavaScript

/** Explicit offline operator init. No database, application imports or activation. */
import assert from 'node:assert/strict'
import {createHash, generateKeyPairSync} from 'node:crypto'
import {constants} from 'node:fs'
import {lstat, realpath, open, mkdir, link, unlink} from 'node:fs/promises'
import path from 'node:path'
import {pathToFileURL} from 'node:url'
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value)
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
const roles = ['runtime', 'operator', 'capture', 'publisher', 'control']
export function deploymentRequest(value) {
assert(exact(value, ['format', 'installationId', 'sourceHeadSha256', 'source', 'roles', 'tls']))
assert(value.format === 'application-writer-bootstrap-request-v1' && uuid(value.installationId) && hex(value.sourceHeadSha256))
assert(exact(value.source, ['systemIdentifier', 'databaseOid', 'databaseName', 'serverVersionNum', 'inRecovery']))
assert(typeof value.source.systemIdentifier === 'string' && /^[1-9][0-9]{0,19}$/.test(value.source.systemIdentifier) && BigInt(value.source.systemIdentifier) <= 18446744073709551615n)
assert(typeof value.source.databaseOid === 'string' && /^[1-9][0-9]{0,9}$/.test(value.source.databaseOid) && BigInt(value.source.databaseOid) <= 4294967295n)
assert(typeof value.source.databaseName === 'string' && /^[^\x00-\x1f\x7f]{1,63}$/.test(value.source.databaseName))
assert(Number.isInteger(value.source.serverVersionNum) && value.source.serverVersionNum >= 180000 && value.source.serverVersionNum <= 189999 && value.source.inRecovery === false)
assert(exact(value.roles, roles))
for (const role of roles) {
const pair = value.roles[role]
assert(exact(pair, ['name', 'signedOid']) && typeof pair.name === 'string' && /^[a-z_][a-z0-9_]{0,62}$/.test(pair.name))
assert(typeof pair.signedOid === 'string' && /^[1-9][0-9]{0,9}$/.test(pair.signedOid) && BigInt(pair.signedOid) <= 4294967295n)
}
assert(new Set(roles.map(role => value.roles[role].name)).size === 5 && new Set(roles.map(role => value.roles[role].signedOid)).size === 5)
assert(exact(value.tls, ['serverName', 'caSha256', 'spkiSha256']) && typeof value.tls.serverName === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName) && hex(value.tls.caSha256) && hex(value.tls.spkiSha256))
return structuredClone(value)
}
export function createBootstrapMaterial(raw, requestSha256) {
const request = deploymentRequest(raw)
assert(hex(requestSha256))
const {privateKey, publicKey} = generateKeyPairSync('ed25519')
const publicKeyPem = publicKey.export({format: 'pem', type: 'spki'}).toString()
const publicKeySpkiSha256 = digest(publicKey.export({format: 'der', type: 'spki'}))
return {
privateKeyPem: privateKey.export({format: 'pem', type: 'pkcs8'}).toString(), publicKeyPem,
bootstrap: {format: 'application-writer-bootstrap-v1', verificationSubject: {installationId: request.installationId, publicKeyPem, publicKeySpkiSha256},
deploymentRequest: request, deploymentRequestSha256: requestSha256, keyOrigin: 'explicit-offline-operator-init',
databaseObserved: false, rolesAuthenticated: false, sourceAuthenticated: false, approvedDefinitionSha256: null,
publicationAuthorized: false, generationAuthenticated: false, readyAuthorized: false},
}
}
async function protectedDirectory(directory) {
assert(process.platform !== 'win32' && typeof process.getuid === 'function', 'Protected POSIX ownership required; Windows ACL backend unavailable')
assert(path.isAbsolute(directory) && await realpath(directory) === directory)
const uid = process.getuid(), stat = await lstat(directory)
assert(stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o077) === 0)
for (let ancestor = path.dirname(directory);; ancestor = path.dirname(ancestor)) {
const metadata = await lstat(ancestor)
assert(metadata.isDirectory() && !metadata.isSymbolicLink() && (metadata.uid === uid || metadata.uid === 0))
assert((metadata.mode & 0o022) === 0 || metadata.uid === 0 && (metadata.mode & 0o1000) !== 0)
if (ancestor === path.dirname(ancestor)) break
}
return stat
}
async function publish(directory, name, bytes) {
await protectedDirectory(directory)
const temporary = path.join(directory, '.' + name + '.pending'), destination = path.join(directory, name)
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
await link(temporary, destination) // Atomic no-replace publication, never rename-overwrite.
await unlink(temporary)
const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
try { await parent.sync() } finally { await parent.close() }
}
export async function initializeBootstrap({outputDirectory, deploymentFile, deploymentSha256}) {
assert(path.isAbsolute(outputDirectory) && path.resolve(outputDirectory) === outputDirectory && hex(deploymentSha256))
await protectedDirectory(path.dirname(outputDirectory))
assert(path.isAbsolute(deploymentFile) && await realpath(deploymentFile) === deploymentFile)
const requestHandle = await open(deploymentFile, constants.O_RDONLY | constants.O_NOFOLLOW)
let bytes
try {
const before = await requestHandle.stat()
assert(before.isFile() && before.uid === process.getuid() && (before.mode & 0o077) === 0 && before.size > 0 && before.size <= 16384)
bytes = Buffer.alloc(before.size + 1)
let size = 0
while (size < bytes.length) { const read = await requestHandle.read(bytes, size, bytes.length - size, size); if (!read.bytesRead) break; size += read.bytesRead }
const after = await requestHandle.stat()
assert(size === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs)
bytes = bytes.subarray(0, size)
} finally { await requestHandle.close() }
assert(digest(bytes) === deploymentSha256, 'Explicit independently approved deployment digest required')
const request = deploymentRequest(JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)))
await mkdir(outputDirectory, {mode: 0o700}) // EEXIST refuses; never adopt/overwrite.
const material = createBootstrapMaterial(request, deploymentSha256)
await publish(outputDirectory, 'signing-private.pem', material.privateKeyPem)
await publish(outputDirectory, 'operator-public.pem', material.publicKeyPem)
await publish(outputDirectory, 'bootstrap.json', JSON.stringify(material.bootstrap, null, 2) + '\n') // Completion marker last.
return {event: 'application-writer-bootstrap-initialized', installationId: request.installationId,
publicKeySpkiSha256: material.bootstrap.verificationSubject.publicKeySpkiSha256,
deploymentRequestSha256: deploymentSha256, databaseObserved: false, publicationAuthorized: false, readyAuthorized: false}
}
export async function main(args) {
assert(args.length === 7 && args[0] === 'init')
const values = new Map()
for (let i = 1; i < args.length; i += 2) { assert(['--output', '--deployment', '--deployment-sha256'].includes(args[i]) && !values.has(args[i])); values.set(args[i], args[i + 1]) }
return initializeBootstrap({outputDirectory: values.get('--output'), deploymentFile: values.get('--deployment'), deploymentSha256: values.get('--deployment-sha256')})
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try { console.log(JSON.stringify(await main(process.argv.slice(2)))) }
catch { console.error(JSON.stringify({event: 'application-writer-bootstrap-refused', publicationAuthorized: false, readyAuthorized: false})); process.exitCode = 1 }
}