196 lines
7.8 KiB
JavaScript
196 lines
7.8 KiB
JavaScript
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
|
|
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
|
import { defaultProxyConfiguration, validateProxyConfiguration } from './contracts.mjs'
|
|
import { ProxyConfigurationError } from './errors.mjs'
|
|
|
|
function hash(bytes) {
|
|
return createHash('sha256').update(bytes).digest('hex')
|
|
}
|
|
|
|
async function syncDirectory(path) {
|
|
const handle = await open(path, 'r')
|
|
try { await handle.sync() } finally { await handle.close() }
|
|
}
|
|
|
|
async function writeSynced(path, bytes, mode) {
|
|
const handle = await open(path, 'wx', mode)
|
|
try {
|
|
await handle.writeFile(bytes)
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
function revision() {
|
|
return randomBytes(32).toString('base64url')
|
|
}
|
|
|
|
function parse(bytes, label) {
|
|
try { return JSON.parse(bytes.toString('utf8')) } catch { throw new ProxyConfigurationError(`${label} is corrupt`, { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) }
|
|
}
|
|
|
|
export class ProxyConfigurationStore {
|
|
constructor(root, { now = () => new Date() } = {}) {
|
|
this.root = root
|
|
this.revisionsRoot = join(root, 'revisions')
|
|
this.currentPath = join(root, 'current.json')
|
|
this.now = now
|
|
this.queue = Promise.resolve()
|
|
}
|
|
|
|
exclusive(operation) {
|
|
const result = this.queue.then(operation, operation)
|
|
this.queue = result.then(() => undefined, () => undefined)
|
|
return result
|
|
}
|
|
|
|
async prepare() {
|
|
await mkdir(this.revisionsRoot, { recursive: true, mode: 0o700 })
|
|
await chmod(this.root, 0o700)
|
|
await chmod(this.revisionsRoot, 0o700)
|
|
}
|
|
|
|
async readPointer() {
|
|
try {
|
|
const pointer = parse(await readFile(this.currentPath), 'proxy current pointer')
|
|
if (pointer?.schema !== 1 || typeof pointer.revision !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(pointer.revision)) {
|
|
throw new ProxyConfigurationError('proxy current pointer is corrupt', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
|
}
|
|
return pointer.revision
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async readRevision(id) {
|
|
const root = join(this.revisionsRoot, id)
|
|
const [configBytes, credentialsBytes, manifestBytes] = await Promise.all([
|
|
readFile(join(root, 'config.json')),
|
|
readFile(join(root, 'credentials.json')),
|
|
readFile(join(root, 'manifest.json')),
|
|
])
|
|
const manifest = parse(manifestBytes, 'proxy revision manifest')
|
|
if (
|
|
manifest?.schema !== 1 || manifest.revision !== id
|
|
|| manifest.files?.config !== hash(configBytes)
|
|
|| manifest.files?.credentials !== hash(credentialsBytes)
|
|
) throw new ProxyConfigurationError('proxy revision manifest does not match its files', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
|
const configuration = parse(configBytes, 'proxy configuration')
|
|
const credentials = parse(credentialsBytes, 'proxy credentials')
|
|
if (credentials?.schema !== 1 || credentials.username !== configuration?.proxy?.username || !('password' in credentials)) {
|
|
throw new ProxyConfigurationError('proxy credentials do not match configuration', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
|
}
|
|
const validated = validateProxyConfiguration({
|
|
...configuration,
|
|
proxy: { ...configuration.proxy, password: credentials.password },
|
|
noProxy: { user: configuration.noProxy.user },
|
|
})
|
|
if (validated.configuration.proxy.passwordConfigured !== configuration.proxy.passwordConfigured) {
|
|
throw new ProxyConfigurationError('proxy password state is inconsistent', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
|
}
|
|
return Object.freeze({ revision: id, configuration: validated.configuration, credentials: validated.credentials, createdAt: manifest.createdAt })
|
|
}
|
|
|
|
async recoveryCandidates() {
|
|
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
|
const candidates = []
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
try {
|
|
const loaded = await this.readRevision(entry.name)
|
|
candidates.push(loaded)
|
|
} catch {}
|
|
}
|
|
return candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
}
|
|
|
|
async load() {
|
|
await this.prepare()
|
|
let current = null
|
|
let corruptPointer = false
|
|
try {
|
|
current = await this.readPointer()
|
|
} catch (error) {
|
|
if (error?.code !== 'PROXY_STATE_CORRUPT') throw error
|
|
corruptPointer = true
|
|
}
|
|
if (current !== null) {
|
|
try { return Object.freeze({ ...(await this.readRevision(current)), recovery: 'none' }) } catch (error) {
|
|
if (error?.code !== 'PROXY_STATE_CORRUPT' && error?.code !== 'ENOENT') throw error
|
|
}
|
|
}
|
|
const recovered = (await this.recoveryCandidates())[0]
|
|
if (recovered !== undefined) {
|
|
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: recovered.revision }), 0o640)
|
|
return Object.freeze({ ...recovered, recovery: 'previous' })
|
|
}
|
|
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
|
const hadPublishedState = corruptPointer || entries.some(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
if (corruptPointer) {
|
|
await rename(this.currentPath, join(this.root, `current.corrupt.${randomUUID()}.json`))
|
|
await syncDirectory(this.root)
|
|
}
|
|
const initialized = await this.commit({ baseRevision: null, value: defaultProxyConfiguration() })
|
|
return Object.freeze({ ...initialized, recovery: hadPublishedState ? 'reset-disabled' : 'none' })
|
|
}
|
|
|
|
commit(request) {
|
|
return this.exclusive(() => this.commitUnlocked(request))
|
|
}
|
|
|
|
async commitUnlocked({ baseRevision, value }) {
|
|
await this.prepare()
|
|
let current = null
|
|
try { current = await this.loadCurrentWithoutRecovery() } catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error
|
|
}
|
|
if ((current?.revision ?? null) !== baseRevision) {
|
|
throw new ProxyConfigurationError('proxy configuration changed', {
|
|
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate',
|
|
})
|
|
}
|
|
const validated = validateProxyConfiguration(value, { existingPassword: current?.credentials.password ?? null })
|
|
const id = revision()
|
|
const staging = join(this.revisionsRoot, `.${id}.${randomUUID()}.tmp`)
|
|
const published = join(this.revisionsRoot, id)
|
|
await mkdir(staging, { mode: 0o700 })
|
|
try {
|
|
const configBytes = canonicalJson(validated.configuration)
|
|
const credentialsBytes = canonicalJson({ schema: 1, ...validated.credentials })
|
|
const manifestBytes = canonicalJson({
|
|
schema: 1,
|
|
revision: id,
|
|
createdAt: this.now().toISOString(),
|
|
files: { config: hash(configBytes), credentials: hash(credentialsBytes) },
|
|
})
|
|
await writeSynced(join(staging, 'config.json'), configBytes, 0o640)
|
|
await writeSynced(join(staging, 'credentials.json'), credentialsBytes, 0o600)
|
|
await writeSynced(join(staging, 'manifest.json'), manifestBytes, 0o640)
|
|
await syncDirectory(staging)
|
|
await rename(staging, published)
|
|
await syncDirectory(this.revisionsRoot)
|
|
const candidate = await this.readRevision(id)
|
|
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: id }), 0o640)
|
|
return candidate
|
|
} catch (error) {
|
|
await rm(staging, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async loadCurrentWithoutRecovery() {
|
|
const current = await this.readPointer()
|
|
if (current === null) {
|
|
const error = new Error('proxy configuration is not initialized')
|
|
error.code = 'ENOENT'
|
|
throw error
|
|
}
|
|
return this.readRevision(current)
|
|
}
|
|
}
|