Orginize
This commit is contained in:
@@ -0,0 +1,982 @@
|
||||
/**
|
||||
* Hyperdrive Management System: registry on personal drive, extra drives + swarm,
|
||||
* Autopass for invite/pair. Use a static ESM import (not createRequire) so Pear can
|
||||
* trace and stage `autopass`; bare-module require() from pear:// URLs does not resolve node_modules.
|
||||
*/
|
||||
|
||||
import Autopass from 'autopass'
|
||||
import b4a from 'b4a'
|
||||
import { randomBytes } from 'bare-crypto'
|
||||
import Hyperbee from 'hyperbee'
|
||||
import hcCrypto from 'hypercore-crypto'
|
||||
import idEnc from 'hypercore-id-encoding'
|
||||
import { parseHdmsPairArgv } from './hdms-cli-argv.js'
|
||||
import { bareOsHostBooterWarn } from './bare-os-host-booter-log.js'
|
||||
import {
|
||||
HDMS_AUTOPASS_SHARE_KEY,
|
||||
HDMS_AUTOPASS_SHARE_RW_KEY,
|
||||
decodeRwShareOffer,
|
||||
decodeShareOffer,
|
||||
removeBothHdmsShareKeys,
|
||||
writerSecretBytesFromHex
|
||||
} from './hdms-share-offer.js'
|
||||
|
||||
export const HDMS_REGISTRY_PATH = '/.bare/hdms/registry.json'
|
||||
|
||||
/**
|
||||
* After BlindPairing, the HyperDB view may lag behind membership; autopass's own tests
|
||||
* wait for `base.system.members === 2` before reading records.
|
||||
* @param {{ base: { system?: { members?: number }, on: Function, off: Function, update?: () => Promise<void> } }} pass
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async function waitForAutopassMembersReady(pass, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
return new Promise((resolve) => {
|
||||
let iv = null
|
||||
const cleanup = () => {
|
||||
if (iv) clearInterval(iv)
|
||||
iv = null
|
||||
try {
|
||||
pass.base?.off?.('update', onUpdate)
|
||||
} catch (_) {}
|
||||
}
|
||||
const tryOk = () => {
|
||||
try {
|
||||
const n = pass.base?.system?.members
|
||||
if (typeof n === 'number' && n >= 2) {
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Date.now() >= deadline) {
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const onUpdate = () => {
|
||||
void pass.base?.update?.().catch(() => {})
|
||||
tryOk()
|
||||
}
|
||||
try {
|
||||
pass.base?.on?.('update', onUpdate)
|
||||
} catch (_) {}
|
||||
iv = setInterval(() => {
|
||||
void pass.base?.update?.().catch(() => {})
|
||||
tryOk()
|
||||
}, 100)
|
||||
void pass.base?.update?.().catch(() => {})
|
||||
tryOk()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer RW offer (separate Autopass key) so legacy clients never treat RW as RO-only.
|
||||
* @param {{ get: (k: string) => Promise<{ value?: unknown } | null>, base?: { update?: () => Promise<void> } }} pass
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<{ kind: 'rw', label: string, key: string, signerKey: string, writerSecretHex: string } | { kind: 'ro', label: string, key: string } | null>}
|
||||
*/
|
||||
async function waitForAutopassShare(pass, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await pass.base?.update?.()
|
||||
} catch (_) {}
|
||||
const rw = decodeRwShareOffer(await pass.get(HDMS_AUTOPASS_SHARE_RW_KEY))
|
||||
if (rw) return { kind: 'rw', ...rw }
|
||||
const ro = decodeShareOffer(await pass.get(HDMS_AUTOPASS_SHARE_KEY))
|
||||
if (ro) return { kind: 'ro', ...ro }
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Max ms to wait for Autobase membership after pair before polling share records. */
|
||||
function parseHdmsPairReadyMs() {
|
||||
const raw = globalThis.process?.env?.BARE_OS_HDMS_PAIR_READY_MS
|
||||
let ms = 45_000
|
||||
if (raw != null && String(raw).trim() !== '') {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n >= 0) ms = Math.min(120_000, n)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
/** Max ms to wait for `@autopass/invite` to disappear from the view after deleteInvite. */
|
||||
function parseHdmsInviteClearMs() {
|
||||
const raw = globalThis.process?.env?.BARE_OS_HDMS_INVITE_CLEAR_MS
|
||||
let ms = 30_000
|
||||
if (raw != null && String(raw).trim() !== '') {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n > 0) ms = Math.min(120_000, n)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Always mint a brand-new BlindPairing z32: clear any open invite row from the view first.
|
||||
* Autopass `createInvite()` returns the existing token while a row remains; after
|
||||
* `deleteInvite()` the HyperDB view can lag until `base.update()` applies the delete.
|
||||
*
|
||||
* @param {import('autopass')} ap
|
||||
* @param {boolean} readOnly
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function mintFreshHdmsAutopassInvite(ap, readOnly) {
|
||||
await ap.deleteInvite()
|
||||
if (ap.member) await ap.member.flushed()
|
||||
try {
|
||||
await ap.base.update()
|
||||
} catch (_) {}
|
||||
|
||||
const deadline = Date.now() + parseHdmsInviteClearMs()
|
||||
while (Date.now() < deadline) {
|
||||
const existing = await ap.base.view.findOne('@autopass/invite', {})
|
||||
if (existing === null) break
|
||||
await ap.deleteInvite()
|
||||
if (ap.member) await ap.member.flushed()
|
||||
try {
|
||||
await ap.base.update()
|
||||
} catch (_) {}
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
}
|
||||
|
||||
const left = await ap.base.view.findOne('@autopass/invite', {})
|
||||
if (left !== null) {
|
||||
throw new Error(
|
||||
'HDMS: prior Autopass invite did not clear from view (try hdms invite again or check swarm/replication)'
|
||||
)
|
||||
}
|
||||
|
||||
return await ap.createInvite({ readOnly })
|
||||
}
|
||||
|
||||
/** @param {string} label */
|
||||
export function assertValidHdmsLabel(label) {
|
||||
if (!label || typeof label !== 'string') throw new Error('Invalid label')
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/.test(label)) {
|
||||
throw new Error(
|
||||
'Label must start with alphanumeric; allowed: . _ - (max 63 chars)'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function randomNsSuffix() {
|
||||
return b4a.toString(randomBytes(8), 'hex')
|
||||
}
|
||||
|
||||
/** Max time to wait for BlindPairing to complete (inviter must be reachable). */
|
||||
function parseHdmsPairWaitMs() {
|
||||
const raw = globalThis.process?.env?.BARE_OS_HDMS_PAIR_WAIT_MS
|
||||
let ms = 120_000
|
||||
if (raw != null && String(raw).trim() !== '') {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n > 0) ms = Math.min(600_000, n)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer inviter's label; if already mounted locally, try label-2 … label-99 (63-char cap).
|
||||
* @param {Map<string, unknown>} byLabel
|
||||
* @param {string} base
|
||||
*/
|
||||
function pickUniqueHdmsMountLabel(byLabel, base) {
|
||||
assertValidHdmsLabel(base)
|
||||
if (!byLabel.has(base)) return base
|
||||
for (let n = 2; n <= 99; n++) {
|
||||
const suffix = '-' + n
|
||||
if (base.length + suffix.length > 63) break
|
||||
const candidate = base + suffix
|
||||
assertValidHdmsLabel(candidate)
|
||||
if (!byLabel.has(candidate)) return candidate
|
||||
}
|
||||
throw new Error(
|
||||
'HDMS mount label in use: ' + base + ' (remove it or ask inviter to use another name)'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writable replica: `key` (drive z32) + `signerKey` (writer pk z32) + `writerSecretHex`, no `ns`.
|
||||
* @typedef {{ id: string, label: string, mode: 'writable' | 'readonly', key?: string, ns?: string, signerKey?: string, writerSecretHex?: string }} HdmsRegistryEntry
|
||||
* @typedef {{ version: 1, drives: HdmsRegistryEntry[] }} HdmsRegistryFile
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @returns {Promise<HdmsRegistryFile>}
|
||||
*/
|
||||
export async function loadHdmsRegistry(personalDrive) {
|
||||
const buf = await personalDrive.get(HDMS_REGISTRY_PATH, { follow: true })
|
||||
if (!buf || buf.length === 0) {
|
||||
return { version: 1, drives: [] }
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(b4a.toString(buf))
|
||||
if (j && j.version === 1 && Array.isArray(j.drives)) return j
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
return { version: 1, drives: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @param {HdmsRegistryFile} data
|
||||
*/
|
||||
export async function saveHdmsRegistry(personalDrive, data) {
|
||||
const json = JSON.stringify(data, null, 0)
|
||||
await personalDrive.put(HDMS_REGISTRY_PATH, b4a.from(json))
|
||||
}
|
||||
|
||||
export class HdmsController {
|
||||
constructor() {
|
||||
/** @type {import('corestore').default | null} */
|
||||
this.store = null
|
||||
/** @type {import('hyperswarm').default | null} */
|
||||
this.swarm = null
|
||||
/** @type {typeof import('hyperdrive').default | null} */
|
||||
this.Hyperdrive = null
|
||||
/** @type {import('hyperdrive').default | null} */
|
||||
this.personalDrive = null
|
||||
/** @type {import('./swarm-disk.js').SwarmDisk | null} */
|
||||
this.disk = null
|
||||
/** @type {{ getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> } | null} */
|
||||
this.vfsMountRef = null
|
||||
/** @type {string[] | null} */
|
||||
this.bootstrap = null
|
||||
|
||||
/** @type {HdmsRegistryFile | null} */
|
||||
this.registry = null
|
||||
/** @type {Map<string, { drive: import('hyperdrive').default, entry: HdmsRegistryEntry, writable: boolean, ephemeral?: boolean }>} */
|
||||
this.byLabel = new Map()
|
||||
/** @type {import('autopass') | null} */
|
||||
this.autopass = null
|
||||
this.active = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* store: import('corestore').default,
|
||||
* swarm: import('hyperswarm').default,
|
||||
* Hyperdrive: typeof import('hyperdrive').default,
|
||||
* personalDrive: import('hyperdrive').default,
|
||||
* disk: import('./swarm-disk.js').SwarmDisk,
|
||||
* vfsMountRef: { getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> },
|
||||
* bootstrap?: string[] | null,
|
||||
* onAfterActivate?: (info: { labels: string[] }) => void | Promise<void>
|
||||
* }} opts
|
||||
*/
|
||||
async activate(opts) {
|
||||
if (this.active) await this.deactivate()
|
||||
this.store = opts.store
|
||||
this.swarm = opts.swarm
|
||||
this.Hyperdrive = opts.Hyperdrive
|
||||
this.personalDrive = opts.personalDrive
|
||||
this.disk = opts.disk
|
||||
this.vfsMountRef = opts.vfsMountRef
|
||||
const b = opts.bootstrap
|
||||
this.bootstrap =
|
||||
Array.isArray(b) && b.length ? b : (parseBootstrapEnv() ?? null)
|
||||
|
||||
this.registry = await loadHdmsRegistry(this.personalDrive)
|
||||
this.byLabel.clear()
|
||||
this.disk.auxiliaryDrives = []
|
||||
|
||||
for (const entry of this.registry.drives) {
|
||||
try {
|
||||
await this._openEntry(entry)
|
||||
} catch (err) {
|
||||
bareOsHostBooterWarn(
|
||||
'hdms_skip_drive',
|
||||
'skip drive ' + entry.label,
|
||||
err?.message || String(err)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.vfsMountRef.getMounts = () => this.getMountMap()
|
||||
this.active = true
|
||||
if (typeof opts.onAfterActivate === 'function') {
|
||||
try {
|
||||
await opts.onAfterActivate({ labels: [...this.byLabel.keys()] })
|
||||
} catch (e) {
|
||||
bareOsHostBooterWarn(
|
||||
'hdms_on_after_activate',
|
||||
'onAfterActivate failed',
|
||||
e?.message || String(e)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deactivate() {
|
||||
if (this.autopass) {
|
||||
try {
|
||||
await this.autopass.close()
|
||||
} catch (_) {}
|
||||
this.autopass = null
|
||||
}
|
||||
|
||||
for (const { drive, entry } of this.byLabel.values()) {
|
||||
try {
|
||||
if (this.swarm && drive.discoveryKey) {
|
||||
this.swarm.leave(drive.discoveryKey)
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await drive.close()
|
||||
} catch (_) {}
|
||||
}
|
||||
this.byLabel.clear()
|
||||
if (this.disk) this.disk.auxiliaryDrives = []
|
||||
if (this.vfsMountRef) {
|
||||
this.vfsMountRef.getMounts = () => new Map()
|
||||
}
|
||||
this.registry = null
|
||||
this.active = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HdmsRegistryEntry} entry
|
||||
*/
|
||||
async _openEntry(entry) {
|
||||
const Hyperdrive = this.Hyperdrive
|
||||
const store = this.store
|
||||
const swarm = this.swarm
|
||||
if (!Hyperdrive || !store || !swarm) throw new Error('HDMS not configured')
|
||||
|
||||
let drive
|
||||
if (entry.mode === 'writable' && entry.ns) {
|
||||
const ns = store.namespace(entry.ns, { writable: true })
|
||||
drive = new Hyperdrive(ns)
|
||||
} else if (
|
||||
entry.mode === 'writable' &&
|
||||
entry.key &&
|
||||
entry.signerKey &&
|
||||
entry.writerSecretHex &&
|
||||
!entry.ns
|
||||
) {
|
||||
const driveKey = idEnc.decode(entry.key)
|
||||
const signerPk = idEnc.decode(entry.signerKey)
|
||||
const secretKey = writerSecretBytesFromHex(entry.writerSecretHex)
|
||||
if (!hcCrypto.validateKeyPair({ publicKey: signerPk, secretKey })) {
|
||||
throw new Error('writer secret does not match signer key (re-pair with a fresh invite)')
|
||||
}
|
||||
const core = store.get({
|
||||
key: driveKey,
|
||||
keyPair: { publicKey: signerPk, secretKey },
|
||||
exclusive: true
|
||||
})
|
||||
const bee = new Hyperbee(core, {
|
||||
keyEncoding: 'utf-8',
|
||||
valueEncoding: 'json',
|
||||
metadata: { contentFeed: null }
|
||||
})
|
||||
drive = new Hyperdrive(store, null, { _db: bee })
|
||||
} else if (entry.mode === 'readonly' && entry.key) {
|
||||
const key = idEnc.decode(entry.key)
|
||||
drive = new Hyperdrive(store, key)
|
||||
} else {
|
||||
throw new Error('Bad registry entry')
|
||||
}
|
||||
|
||||
await drive.ready()
|
||||
swarm.join(drive.discoveryKey)
|
||||
const done = drive.findingPeers()
|
||||
// Hyperdrive blocks ops until findingPeers `done()` runs; `swarm.flush()` can
|
||||
// hang when DHT/announce never settles — always finish after a bounded wait.
|
||||
const flushMsRaw = globalThis.process?.env?.BARE_OS_HDMS_SWARM_FLUSH_MS
|
||||
let flushMs = 8000
|
||||
if (flushMsRaw != null && String(flushMsRaw).trim() !== '') {
|
||||
const n = Number(flushMsRaw)
|
||||
if (Number.isFinite(n) && n >= 0) flushMs = Math.min(120_000, n)
|
||||
}
|
||||
const finishFinding = () => {
|
||||
try {
|
||||
done()
|
||||
} catch (_) {}
|
||||
}
|
||||
const t = flushMs > 0 ? setTimeout(finishFinding, flushMs) : null
|
||||
swarm.flush().then(
|
||||
() => {
|
||||
if (t) clearTimeout(t)
|
||||
finishFinding()
|
||||
},
|
||||
() => {
|
||||
if (t) clearTimeout(t)
|
||||
finishFinding()
|
||||
}
|
||||
)
|
||||
|
||||
if (this.disk) {
|
||||
this.disk.auxiliaryDrives.push(drive)
|
||||
}
|
||||
|
||||
this.byLabel.set(entry.label, {
|
||||
drive,
|
||||
entry,
|
||||
writable: entry.mode === 'writable'
|
||||
})
|
||||
|
||||
for (const peer of this.disk?.peers || []) {
|
||||
try {
|
||||
drive.replicate(peer.mux.stream, { live: true, download: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
getMountMap() {
|
||||
/** @type {Map<string, { drive: import('hyperdrive').default, writable: boolean }>} */
|
||||
const m = new Map()
|
||||
for (const [label, x] of this.byLabel) {
|
||||
m.set(label, { drive: x.drive, writable: x.writable })
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
assertLoggedIn(ctx) {
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
throw new Error('Log in to use Hyperdrive management (hdms)')
|
||||
}
|
||||
if (!this.active) throw new Error('HDMS inactive')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async list(ctx) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const reg = this.registry
|
||||
const inReg = new Set(
|
||||
reg && Array.isArray(reg.drives) ? reg.drives.map((d) => d.label) : []
|
||||
)
|
||||
const lines = []
|
||||
if (reg && reg.drives.length) {
|
||||
for (const d of reg.drives) {
|
||||
const k = d.key || '(local)'
|
||||
lines.push(`${d.label}\t${d.mode}\t${k}`)
|
||||
}
|
||||
}
|
||||
for (const [label, x] of this.byLabel) {
|
||||
if (inReg.has(label)) continue
|
||||
const d = x.entry
|
||||
const k = d.key || '(local)'
|
||||
lines.push(`${d.label}\t${d.mode}\t${k}\tephemeral`)
|
||||
}
|
||||
if (!lines.length) {
|
||||
ctx.console.log('(no extra drives)')
|
||||
return
|
||||
}
|
||||
for (const line of lines) ctx.console.log(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async create(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
assertValidHdmsLabel(label)
|
||||
if (this.byLabel.has(label)) throw new Error('Label already exists')
|
||||
|
||||
const id = randomNsSuffix()
|
||||
const ns = `bare-os-hdms-w-${id}`
|
||||
/** @type {HdmsRegistryEntry} */
|
||||
const entry = {
|
||||
id,
|
||||
label,
|
||||
mode: 'writable',
|
||||
ns,
|
||||
key: ''
|
||||
}
|
||||
|
||||
// Single Hyperdrive open: corestore uses exclusive db cores per namespace; a
|
||||
// second `new Hyperdrive(ns)` while the first is still open deadlocks on ready().
|
||||
await this._openEntry(entry)
|
||||
const opened = this.byLabel.get(label)
|
||||
if (!opened?.drive?.key) {
|
||||
throw new Error('HDMS create failed (no drive key)')
|
||||
}
|
||||
entry.key = idEnc.encode(opened.drive.key)
|
||||
|
||||
this.registry.drives.push(entry)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
const mounted = this.getMountMap().has(label)
|
||||
if (!mounted) {
|
||||
throw new Error(
|
||||
'HDMS create succeeded but mount is not visible at /mnt/' + label
|
||||
)
|
||||
}
|
||||
ctx.console.log(`Created ${label} key=${entry.key} mounted=/mnt/${label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
* @param {string} keyZ32
|
||||
* @param {{ persist?: boolean }} [opts]
|
||||
*/
|
||||
async addReadonly(ctx, label, keyZ32, opts = {}) {
|
||||
const persist = opts.persist !== false
|
||||
this.assertLoggedIn(ctx)
|
||||
assertValidHdmsLabel(label)
|
||||
if (this.byLabel.has(label)) throw new Error('Label already exists')
|
||||
idEnc.decode(keyZ32)
|
||||
|
||||
const id = randomNsSuffix()
|
||||
/** @type {HdmsRegistryEntry} */
|
||||
const entry = {
|
||||
id,
|
||||
label,
|
||||
mode: 'readonly',
|
||||
key: keyZ32
|
||||
}
|
||||
if (persist) {
|
||||
this.registry.drives.push(entry)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
}
|
||||
await this._openEntry(entry)
|
||||
if (!persist) {
|
||||
const slot = this.byLabel.get(label)
|
||||
if (slot) slot.ephemeral = true
|
||||
}
|
||||
ctx.console.log(`Added readonly ${label}${persist ? '' : ' (ephemeral)'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writable Hyperdrive replica: `keyZ32` is drive id (manifest), `signerKeyZ32` is metadata signer pk.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
* @param {string} keyZ32 drive public key (z32)
|
||||
* @param {string} signerKeyZ32 metadata writer public key (z32)
|
||||
* @param {string} writerSecretHex
|
||||
* @param {{ persist?: boolean }} [opts]
|
||||
*/
|
||||
async addWritableReplica(
|
||||
ctx,
|
||||
label,
|
||||
keyZ32,
|
||||
signerKeyZ32,
|
||||
writerSecretHex,
|
||||
opts = {}
|
||||
) {
|
||||
const persist = opts.persist !== false
|
||||
this.assertLoggedIn(ctx)
|
||||
assertValidHdmsLabel(label)
|
||||
if (this.byLabel.has(label)) throw new Error('Label already exists')
|
||||
idEnc.decode(keyZ32)
|
||||
const signerPk = idEnc.decode(signerKeyZ32)
|
||||
const secretKey = writerSecretBytesFromHex(writerSecretHex)
|
||||
if (!hcCrypto.validateKeyPair({ publicKey: signerPk, secretKey })) {
|
||||
throw new Error('writer secret does not match signer key')
|
||||
}
|
||||
|
||||
const id = randomNsSuffix()
|
||||
/** @type {HdmsRegistryEntry} */
|
||||
const entry = {
|
||||
id,
|
||||
label,
|
||||
mode: 'writable',
|
||||
key: keyZ32,
|
||||
signerKey: signerKeyZ32,
|
||||
writerSecretHex: b4a.toString(secretKey, 'hex')
|
||||
}
|
||||
if (persist) {
|
||||
this.registry.drives.push(entry)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
}
|
||||
await this._openEntry(entry)
|
||||
if (!persist) {
|
||||
const slot = this.byLabel.get(label)
|
||||
if (slot) slot.ephemeral = true
|
||||
}
|
||||
ctx.console.log(`Added writable ${label}${persist ? '' : ' (ephemeral)'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async remove(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const open = this.byLabel.get(label)
|
||||
if (open?.ephemeral) {
|
||||
try {
|
||||
if (this.swarm && open.drive.discoveryKey) {
|
||||
this.swarm.leave(open.drive.discoveryKey)
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await open.drive.close()
|
||||
} catch (_) {}
|
||||
if (this.disk?.auxiliaryDrives) {
|
||||
this.disk.auxiliaryDrives = this.disk.auxiliaryDrives.filter(
|
||||
(d) => d !== open.drive
|
||||
)
|
||||
}
|
||||
this.byLabel.delete(label)
|
||||
ctx.console.log('Removed ' + label)
|
||||
return
|
||||
}
|
||||
|
||||
const idx = this.registry.drives.findIndex((d) => d.label === label)
|
||||
if (idx < 0) throw new Error('Unknown label: ' + label)
|
||||
|
||||
const openReg = this.byLabel.get(label)
|
||||
if (openReg) {
|
||||
try {
|
||||
if (this.swarm && openReg.drive.discoveryKey) {
|
||||
this.swarm.leave(openReg.drive.discoveryKey)
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await openReg.drive.close()
|
||||
} catch (_) {}
|
||||
if (this.disk?.auxiliaryDrives) {
|
||||
this.disk.auxiliaryDrives = this.disk.auxiliaryDrives.filter(
|
||||
(d) => d !== openReg.drive
|
||||
)
|
||||
}
|
||||
this.byLabel.delete(label)
|
||||
}
|
||||
|
||||
this.registry.drives.splice(idx, 1)
|
||||
await saveHdmsRegistry(this.personalDrive, this.registry)
|
||||
ctx.console.log('Removed ' + label)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} label
|
||||
*/
|
||||
async show(ctx, label) {
|
||||
this.assertLoggedIn(ctx)
|
||||
let e = this.registry.drives.find((d) => d.label === label)
|
||||
if (!e) {
|
||||
const open = this.byLabel.get(label)
|
||||
if (open?.ephemeral) {
|
||||
e = { ...open.entry, ephemeral: true }
|
||||
}
|
||||
}
|
||||
if (!e) throw new Error('Unknown label: ' + label)
|
||||
ctx.console.log(JSON.stringify(e, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {boolean} readOnly Autopass writer role for the peer (not Hyperdrive R/W).
|
||||
* @param {string} [driveLabel] Writable HDMS label to attach to the invite.
|
||||
* @param {boolean} [shareReadWrite] When true with `driveLabel`, publish writer secret on `bare-os-hdms/pending-share-rw`.
|
||||
*/
|
||||
async invite(ctx, readOnly, driveLabel, shareReadWrite = false) {
|
||||
this.assertLoggedIn(ctx)
|
||||
await this._ensureAutopass()
|
||||
const ap = this.autopass
|
||||
if (!ap) throw new Error('HDMS autopass unavailable')
|
||||
|
||||
await removeBothHdmsShareKeys(ap)
|
||||
|
||||
if (driveLabel != null && String(driveLabel).trim() !== '') {
|
||||
assertValidHdmsLabel(driveLabel)
|
||||
const open = this.byLabel.get(driveLabel)
|
||||
if (!open) throw new Error('Unknown label: ' + driveLabel)
|
||||
if (open.entry.mode !== 'writable') {
|
||||
throw new Error('hdms invite <label> requires a writable HDMS drive')
|
||||
}
|
||||
const keyZ32 = idEnc.encode(open.drive.key)
|
||||
if (shareReadWrite) {
|
||||
const kp = open.drive.core.keyPair
|
||||
if (!kp?.secretKey || kp.secretKey.length !== 64) {
|
||||
throw new Error('HDMS: drive has no writer secret')
|
||||
}
|
||||
const writerSecretHex = b4a.toString(kp.secretKey, 'hex')
|
||||
const signerKeyZ32 = idEnc.encode(kp.publicKey)
|
||||
await ap.add(
|
||||
HDMS_AUTOPASS_SHARE_RW_KEY,
|
||||
JSON.stringify({
|
||||
label: driveLabel,
|
||||
key: keyZ32,
|
||||
signerKey: signerKeyZ32,
|
||||
writerSecretHex
|
||||
})
|
||||
)
|
||||
} else {
|
||||
await ap.add(
|
||||
HDMS_AUTOPASS_SHARE_KEY,
|
||||
JSON.stringify({ label: driveLabel, key: keyZ32 })
|
||||
)
|
||||
}
|
||||
if (ap.member) await ap.member.flushed()
|
||||
} else if (ap.member) {
|
||||
await ap.member.flushed()
|
||||
}
|
||||
|
||||
const inv = await mintFreshHdmsAutopassInvite(ap, readOnly)
|
||||
ctx.console.log(inv)
|
||||
if (driveLabel != null && String(driveLabel).trim() !== '') {
|
||||
if (shareReadWrite) {
|
||||
ctx.console.log(
|
||||
'Invite includes HDMS drive "' +
|
||||
driveLabel +
|
||||
'" with read/write (writer secret on Autopass ledger bare-os-hdms/pending-share-rw). Peer: hdms pair <invite>'
|
||||
)
|
||||
} else {
|
||||
ctx.console.log(
|
||||
'Invite includes HDMS drive "' +
|
||||
driveLabel +
|
||||
'". Peer: hdms pair <invite> (mounts read-only at /mnt/' +
|
||||
driveLabel +
|
||||
' or label-2… if that name exists locally; same swarm/bootstrap).'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} inviteZ32
|
||||
* @param {{ persist?: boolean }} [pairOpts] Default persist true; false skips `registry.json` (ephemeral until removed).
|
||||
*/
|
||||
async pair(ctx, inviteZ32, pairOpts = {}) {
|
||||
this.assertLoggedIn(ctx)
|
||||
const persist = pairOpts.persist !== false
|
||||
if (!inviteZ32 || typeof inviteZ32 !== 'string') {
|
||||
throw new Error('Usage: hdms pair [--persist|--no-persist] <invite>')
|
||||
}
|
||||
const pairNs = this.store.namespace(
|
||||
'bare-os-hdms-pair-' + randomNsSuffix(),
|
||||
{
|
||||
writable: true
|
||||
}
|
||||
)
|
||||
const pairer = Autopass.pair(pairNs, inviteZ32.trim(), {
|
||||
bootstrap: this.bootstrap
|
||||
})
|
||||
const pairWaitMs = parseHdmsPairWaitMs()
|
||||
let pairTimeout = 0
|
||||
const pairTimeoutP = new Promise((_, reject) => {
|
||||
pairTimeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
'hdms pair: timed out after ' +
|
||||
pairWaitMs +
|
||||
'ms waiting for inviter (inviter must be online with matching HYPERSWARM_BOOTSTRAP; set BARE_OS_HDMS_PAIR_WAIT_MS to adjust)'
|
||||
)
|
||||
)
|
||||
}, pairWaitMs)
|
||||
})
|
||||
const finishedP = pairer.finished()
|
||||
let pass
|
||||
try {
|
||||
pass = await Promise.race([finishedP, pairTimeoutP])
|
||||
} catch (e) {
|
||||
clearTimeout(pairTimeout)
|
||||
try {
|
||||
await pairer.close()
|
||||
} catch (_) {}
|
||||
void finishedP.catch(() => {})
|
||||
throw e
|
||||
}
|
||||
clearTimeout(pairTimeout)
|
||||
await pass.ready()
|
||||
try {
|
||||
const wk = pass.writerKey
|
||||
ctx.console.log(
|
||||
'Paired Autopass. writerKey=' +
|
||||
(wk ? b4a.toString(wk, 'hex').slice(0, 16) + '…' : '?')
|
||||
)
|
||||
|
||||
const pairReadyMs = parseHdmsPairReadyMs()
|
||||
if (pairReadyMs > 0) {
|
||||
await waitForAutopassMembersReady(pass, pairReadyMs)
|
||||
}
|
||||
|
||||
const offer = await waitForAutopassShare(
|
||||
pass,
|
||||
Number(
|
||||
globalThis.process?.env?.BARE_OS_HDMS_PAIR_SHARE_WAIT_MS ?? 25000
|
||||
) || 25000
|
||||
)
|
||||
if (offer) {
|
||||
const lbl = pickUniqueHdmsMountLabel(this.byLabel, offer.label)
|
||||
const renamed = lbl !== offer.label
|
||||
if (offer.kind === 'rw') {
|
||||
await this.addWritableReplica(
|
||||
ctx,
|
||||
lbl,
|
||||
offer.key,
|
||||
offer.signerKey,
|
||||
offer.writerSecretHex,
|
||||
{
|
||||
persist
|
||||
}
|
||||
)
|
||||
ctx.console.log(
|
||||
'HDMS read/write mount ready at /mnt/' +
|
||||
lbl +
|
||||
(renamed
|
||||
? ' (inviter label "' + offer.label + '" was in use locally)'
|
||||
: '') +
|
||||
(persist ? '' : ' (ephemeral; not saved to registry)') +
|
||||
' — same swarm/bootstrap as inviter.'
|
||||
)
|
||||
} else {
|
||||
await this.addReadonly(ctx, lbl, offer.key, { persist })
|
||||
ctx.console.log(
|
||||
'HDMS read-only mount ready at /mnt/' +
|
||||
lbl +
|
||||
(renamed
|
||||
? ' (inviter label "' + offer.label + '" was in use locally)'
|
||||
: '') +
|
||||
(persist ? '' : ' (ephemeral; not saved to registry)') +
|
||||
' — replicate from swarm; not the same as Autopass R/W.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ctx.console.log(
|
||||
'No HDMS drive on this invite. Inviter can run: hdms invite [--read-only] [--rw] <label>'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await pass.close()
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureAutopass() {
|
||||
if (this.autopass) return
|
||||
const ns = this.store.namespace('bare-os-hdms-autopass', { writable: true })
|
||||
this.autopass = new Autopass(ns, {
|
||||
swarm: this.swarm,
|
||||
replicate: true,
|
||||
bootstrap: this.bootstrap
|
||||
})
|
||||
await this.autopass.ready()
|
||||
attachAutopassReplicateOnce(this.swarm, () => this.autopass?.base)
|
||||
try {
|
||||
this.autopass.on('error', (err) => {
|
||||
bareOsHostBooterWarn(
|
||||
'hdms_autopass',
|
||||
'Autopass error',
|
||||
err?.message || String(err)
|
||||
)
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {() => unknown} getBase
|
||||
*/
|
||||
function attachAutopassReplicateOnce(swarm, getBase) {
|
||||
const s = /** @type {{ _bareOsHdmsAutopassRepl?: boolean }} */ (swarm)
|
||||
if (s._bareOsHdmsAutopassRepl) return
|
||||
s._bareOsHdmsAutopassRepl = true
|
||||
swarm.on('connection', (socket) => {
|
||||
const base = getBase()
|
||||
if (base && typeof base.replicate === 'function') {
|
||||
try {
|
||||
base.replicate(socket, { live: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseBootstrapEnv() {
|
||||
const raw = globalThis.process?.env?.HYPERSWARM_BOOTSTRAP
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
return parts.length ? parts : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HdmsController} hdms
|
||||
* @param {string[]} argv
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function runHdmsCli(hdms, argv, ctx) {
|
||||
try {
|
||||
const sub = argv[1]
|
||||
const rest = argv.slice(2)
|
||||
|
||||
if (!sub || sub === 'help' || sub === '--help') {
|
||||
ctx.console.log(
|
||||
'hdms list | create <label> | add <label> <key> | remove <label> | show <label> | invite [--read-only] [--rw] [<label>] | pair [--persist|--no-persist] <invite>'
|
||||
)
|
||||
ctx.console.log(
|
||||
'invite [--read-only] [--rw] <label>: each run clears the prior BlindPairing invite from Autopass, waits for the view to catch up, then mints an all-new z32. Omit --read-only for pairing (see man hdms).'
|
||||
)
|
||||
ctx.console.log(
|
||||
'pair: default saves mount to registry (survives reboot). --no-persist keeps mount ephemeral. --persist is explicit default (no-op).'
|
||||
)
|
||||
ctx.console.log(
|
||||
'pair waits for inviter (BARE_OS_HDMS_PAIR_WAIT_MS default 120s); then for drive offer (BARE_OS_HDMS_PAIR_SHARE_WAIT_MS default 25s).'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list' || sub === 'ls') {
|
||||
await hdms.list(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'create') {
|
||||
await hdms.create(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
await hdms.addReadonly(ctx, rest[0], rest[1])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
await hdms.remove(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'show') {
|
||||
await hdms.show(ctx, rest[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'invite') {
|
||||
const ro = rest.includes('--read-only')
|
||||
const rwDrive = rest.includes('--rw')
|
||||
const pos = rest.filter((x) => x !== '--read-only' && x !== '--rw')
|
||||
const driveLabel = pos[0]
|
||||
if (rwDrive && (!driveLabel || String(driveLabel).trim() === '')) {
|
||||
ctx.console.error('hdms: invite --rw requires a writable drive <label>')
|
||||
return
|
||||
}
|
||||
await hdms.invite(ctx, ro, driveLabel, rwDrive)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'pair') {
|
||||
const parsed = parseHdmsPairArgv(rest)
|
||||
if (!parsed.ok) {
|
||||
ctx.console.error(parsed.error)
|
||||
return
|
||||
}
|
||||
await hdms.pair(ctx, parsed.inviteTok, { persist: parsed.persist })
|
||||
return
|
||||
}
|
||||
|
||||
ctx.console.error('hdms: unknown subcommand (try hdms help)')
|
||||
} catch (e) {
|
||||
ctx.console.error('hdms: ' + (e?.message || e))
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user