Orginize
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared env parsing for bare-holesail / managed tunnels.
|
||||
* @param {unknown} v
|
||||
*/
|
||||
export function bareHolesailEnvTruthy(v) {
|
||||
const s = String(v ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return s === '1' || s === 'true' || s === 'yes'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Resolve the upstream `holesail` class for bare-holesail / managed tunnels.
|
||||
*
|
||||
* This follows the same pattern as `bare-openssh` / `bare-ssh2`: keep `holesail`
|
||||
* as a booter-owned dependency and import it directly so Pear stages the package.
|
||||
* Upstream is AGPL-3.0.
|
||||
*/
|
||||
|
||||
import Holesail from 'holesail'
|
||||
|
||||
/** ESM import so Pear stages `holesail` and its traced package deps. */
|
||||
let holesailCache = null
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @returns {Promise<new (opts?: object) => { ready: () => Promise<void>, close: () => Promise<void>, pause?: () => Promise<void>, resume?: () => Promise<void>, info?: unknown }>}
|
||||
*/
|
||||
export async function loadHolesailConstructor(ctx) {
|
||||
void ctx
|
||||
if (!holesailCache) {
|
||||
const Ho = Holesail && Holesail.default ? Holesail.default : Holesail
|
||||
if (typeof Ho !== 'function') {
|
||||
throw new Error('holesail: expected default export to be a constructor')
|
||||
}
|
||||
holesailCache = Ho
|
||||
}
|
||||
|
||||
return holesailCache
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
import { dirname } from '#host-path'
|
||||
|
||||
/**
|
||||
* Multi-tunnel Holesail manager: persisted state on the VFS, one Holesail instance per entry.
|
||||
* Used when managed mode is on (stock default with BARE_OS_HOLESAIL_INITD/MANAGED).
|
||||
*
|
||||
* State path: **`BARE_OS_HOLESAIL_STATE`** override, else **`~/.holesail/state.json`** (resolves under **`$HOME`**).
|
||||
* Managed Holesail starts only after unlock; legacy **`/.bare/holesail/**`** files may be merged on first read.
|
||||
*
|
||||
* **Server identity** — **`seed`** is the **64-hex** ctor secret (`bare-crypto`); it is passed to **`Holesail({ key })`**
|
||||
* and **must not** be replaced with the z32 URL suffix (different input ⇒ different tunnel). **`key`** holds the full
|
||||
* **`hs://…`** from **`hs.info.url`** after **`ready()`** for sharing. Legacy rows may have only **`key`** (no **`seed`**).
|
||||
* Upstream holesail is AGPL-3.0.
|
||||
*/
|
||||
import { randomBytes } from 'bare-crypto'
|
||||
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
|
||||
import { loadHolesailConstructor } from './bare-holesail-loader.js'
|
||||
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
|
||||
import { bindFallbackEnabled, isAddrInUse } from './bare-os-bind-fallback.js'
|
||||
|
||||
/** Per-user managed state (unlocked shells; resolves under `$HOME`). */
|
||||
export const BARE_HOLESAIL_STATE_USER = '~/.holesail/state.json'
|
||||
|
||||
/** Legacy guest-only managed state (migration source only). */
|
||||
export const BARE_HOLESAIL_STATE_GUEST = '/.bare/holesail/guest/state.json'
|
||||
|
||||
/** Pre-split single file at personal root (migration source only). */
|
||||
export const BARE_HOLESAIL_STATE_LEGACY_ROOT = '/.bare/holesail/state.json'
|
||||
|
||||
/** @deprecated Use {@link BARE_HOLESAIL_STATE_LEGACY_ROOT}. */
|
||||
export const BARE_HOLESAIL_STATE_STABLE = BARE_HOLESAIL_STATE_LEGACY_ROOT
|
||||
|
||||
/** @deprecated Alias for {@link BARE_HOLESAIL_STATE_USER}. */
|
||||
export const BARE_HOLESAIL_STATE_LEGACY = BARE_HOLESAIL_STATE_USER
|
||||
|
||||
const HOLESAIL_LOG = `${BARE_OS_VAR_LOG_DIR}/holesail.log`
|
||||
|
||||
/** @type {Map<string, { hs: unknown, entry: Record<string, unknown> }>} */
|
||||
const managedInstances = new Map()
|
||||
|
||||
/** @type {(() => void)[]} */
|
||||
let managedHookUnsubs = []
|
||||
|
||||
let managedServiceRunning = false
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedStatePath(env) {
|
||||
const raw = String(env.BARE_OS_HOLESAIL_STATE || '').trim()
|
||||
if (raw) return raw
|
||||
return BARE_HOLESAIL_STATE_USER
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} [_id]
|
||||
*/
|
||||
export function bareHolesailManagedEnvForConnectionId(env, _id) {
|
||||
return env
|
||||
}
|
||||
|
||||
function logLine(ctx, line) {
|
||||
void appendVarLog(ctx, HOLESAIL_LOG, 'holesail', line)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} dir
|
||||
*/
|
||||
async function ensureDir(ctx, dir) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.mkdir !== 'function') return
|
||||
try {
|
||||
await vfs.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} hs
|
||||
*/
|
||||
async function safeCloseHolesail(hs) {
|
||||
if (!hs || typeof hs.close !== 'function') return
|
||||
try {
|
||||
await hs.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedValidateId(id) {
|
||||
const s = String(id || '').trim()
|
||||
if (!s || s.length > 64) return { ok: false, err: 'invalid id (1–64 chars)' }
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(s))
|
||||
return { ok: false, err: 'invalid id (use [a-zA-Z0-9._-])' }
|
||||
return { ok: true, id: s }
|
||||
}
|
||||
|
||||
/** 32 bytes hex — upstream holesail expects `key` length ≥ 32 chars for servers. */
|
||||
const HOLESAIL_SEED_HEX_LEN = 64
|
||||
|
||||
/**
|
||||
* New server tunnel secret (persist in **`state.connections[id].seed`**; passed to **`Holesail({ key })`**).
|
||||
*/
|
||||
export function bareHolesailManagedGenerateSeedHex() {
|
||||
return randomBytes(HOLESAIL_SEED_HEX_LEN / 2).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
export function bareHolesailManagedSeedLooksValid(s) {
|
||||
return /^[0-9a-f]{64}$/i.test(String(s || '').trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract holesail **`key`** material from a full **`hs://…`** URL or bare connection string (matches upstream **`urlParser`**).
|
||||
* @param {string} urlOrHs
|
||||
*/
|
||||
export function bareHolesailManagedHsUrlKeySuffix(urlOrHs) {
|
||||
const u = String(urlOrHs || '').trim()
|
||||
if (u.length < 10) return ''
|
||||
if (u.substring(0, 5).toLowerCase() !== 'hs://') return ''
|
||||
return u.substring(9)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid persisted **`seed`**: 64-char hex (pre-mint) or holesail **z32-style** suffix (≥32 chars, alphanumeric).
|
||||
* @param {string} s
|
||||
*/
|
||||
export function bareHolesailManagedPersistentSeedLooksValid(s) {
|
||||
const t = String(s || '').trim()
|
||||
if (!t) return false
|
||||
if (/^[0-9a-f]{64}$/i.test(t)) return true
|
||||
if (t.length >= 32 && t.length <= 128 && /^[a-z0-9]+$/i.test(t)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} raw
|
||||
* @returns {{ ok: true, entry: Record<string, unknown> } | { ok: false, err: string }}
|
||||
*/
|
||||
export function bareHolesailManagedNormalizeEntry(raw) {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
return { ok: false, err: 'connection entry must be an object' }
|
||||
const server = bareHolesailEnvTruthy(
|
||||
/** @type {Record<string, unknown>} */ (raw).server
|
||||
)
|
||||
const client = bareHolesailEnvTruthy(
|
||||
/** @type {Record<string, unknown>} */ (raw).client
|
||||
)
|
||||
if (server === client) {
|
||||
return { ok: false, err: 'set exactly one of server or client' }
|
||||
}
|
||||
/** @type {Record<string, unknown>} */
|
||||
const entry = { server, client }
|
||||
const key = String(raw.key ?? '').trim()
|
||||
const seed = String(raw.seed ?? '').trim()
|
||||
if (client && !key) return { ok: false, err: 'client requires key' }
|
||||
|
||||
if (seed) {
|
||||
if (!bareHolesailManagedPersistentSeedLooksValid(seed))
|
||||
return {
|
||||
ok: false,
|
||||
err: 'invalid seed (64 hex chars or hs:// key suffix / z32)'
|
||||
}
|
||||
entry.seed = seed.toLowerCase()
|
||||
}
|
||||
|
||||
if (key) entry.key = key
|
||||
|
||||
if (raw.secure !== undefined && raw.secure !== null && String(raw.secure) !== '')
|
||||
entry.secure = bareHolesailEnvTruthy(raw.secure)
|
||||
|
||||
if (raw.port != null && raw.port !== '') {
|
||||
const n = Number.parseInt(String(raw.port), 10)
|
||||
if (!Number.isFinite(n)) return { ok: false, err: 'invalid port' }
|
||||
entry.port = n
|
||||
}
|
||||
|
||||
const host = String(raw.host ?? '').trim()
|
||||
if (host) entry.host = host
|
||||
|
||||
if (raw.udp !== undefined && raw.udp !== null && String(raw.udp) !== '')
|
||||
entry.udp = bareHolesailEnvTruthy(raw.udp)
|
||||
|
||||
if (raw.log !== undefined && raw.log !== null && raw.log !== '') {
|
||||
if (typeof raw.log === 'number') entry.log = raw.log
|
||||
else if (/^\d+$/.test(String(raw.log)))
|
||||
entry.log = Number.parseInt(String(raw.log), 10)
|
||||
else entry.log = bareHolesailEnvTruthy(raw.log)
|
||||
}
|
||||
|
||||
const enabled =
|
||||
raw.enabled === undefined || raw.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(raw.enabled)
|
||||
entry.enabled = enabled
|
||||
|
||||
return { ok: true, entry }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare persisted shareable URL / key string to live `hs.info.url` (case-insensitive trim).
|
||||
* @param {string} persisted
|
||||
* @param {string} live
|
||||
*/
|
||||
export function bareHolesailManagedPersistedUrlMatchesLive(persisted, live) {
|
||||
const p = String(persisted ?? '').trim().toLowerCase()
|
||||
const l = String(live ?? '').trim().toLowerCase()
|
||||
if (!p || !l) return false
|
||||
return p === l
|
||||
}
|
||||
|
||||
/**
|
||||
* `log` for managed tunnels: row → `BARE_OS_HOLESAIL_MANAGED_LOG` / `BARE_OS_HOLESAIL_LOG` → INFO (1),
|
||||
* or DEBUG (0) when `BARE_OS_HOLESAIL_DEBUG` is set (reference scripts often use `log: 0`).
|
||||
* @param {Record<string, unknown>} conn
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedLogLevel(conn, env) {
|
||||
if (conn.log !== undefined && conn.log !== null && conn.log !== '') {
|
||||
if (typeof conn.log === 'number') return conn.log
|
||||
if (/^\d+$/.test(String(conn.log)))
|
||||
return Number.parseInt(String(conn.log), 10)
|
||||
return bareHolesailEnvTruthy(conn.log) ? 1 : 0
|
||||
}
|
||||
const raw =
|
||||
env.BARE_OS_HOLESAIL_MANAGED_LOG ?? env.BARE_OS_HOLESAIL_LOG ?? ''
|
||||
const s = String(raw).trim()
|
||||
if (s !== '' && /^\d+$/.test(s)) {
|
||||
const n = Number.parseInt(s, 10)
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 1
|
||||
}
|
||||
if (bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_DEBUG)) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for `new Holesail(opts)`.
|
||||
*
|
||||
* **Server** — Ctor `key` field: **64-hex `seed`** first, then a valid **persisted non-hex seed** (z32 suffix migrated
|
||||
* from **`hs://…`**), then the full **`hs://…`** **`key`** (legacy). Never replace a hex **`seed`** with only the URL
|
||||
* suffix written as ctor material — upstream hashes the ctor string; hex ≠ z32 ⇒ different URLs.
|
||||
*
|
||||
* **Client** — Passthrough **`key`** (typically **`hs://…`**).
|
||||
*
|
||||
* @param {Record<string, unknown>} conn one `state.connections[id]` object
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function bareHolesailManagedNewHolesailOpts(conn, env) {
|
||||
const c = /** @type {Record<string, unknown>} */ (conn)
|
||||
const seedStr = c.seed != null ? String(c.seed).trim() : ''
|
||||
const keyFull = c.key != null ? String(c.key).trim() : ''
|
||||
|
||||
const server = bareHolesailEnvTruthy(c.server)
|
||||
const client = bareHolesailEnvTruthy(c.client)
|
||||
|
||||
let secure = false
|
||||
if (c.secure !== undefined && c.secure !== null && String(c.secure) !== '') {
|
||||
secure = bareHolesailEnvTruthy(c.secure)
|
||||
} else if (seedStr && bareHolesailManagedSeedLooksValid(seedStr)) {
|
||||
secure = false
|
||||
} else if (keyFull) {
|
||||
secure = /^hs:\/\/s/i.test(keyFull)
|
||||
}
|
||||
|
||||
let udp = false
|
||||
if (c.udp !== undefined && c.udp !== null && String(c.udp) !== '')
|
||||
udp = bareHolesailEnvTruthy(c.udp)
|
||||
|
||||
const log = bareHolesailManagedLogLevel(conn, env)
|
||||
|
||||
/** @type {string} */
|
||||
let keyOpt = ''
|
||||
if (server) {
|
||||
if (bareHolesailManagedSeedLooksValid(seedStr)) keyOpt = seedStr.toLowerCase()
|
||||
else if (
|
||||
seedStr &&
|
||||
bareHolesailManagedPersistentSeedLooksValid(seedStr)
|
||||
)
|
||||
keyOpt = seedStr.toLowerCase()
|
||||
else if (/^hs:\/\//i.test(keyFull)) keyOpt = keyFull
|
||||
else if (seedStr) keyOpt = seedStr.toLowerCase()
|
||||
else keyOpt = keyFull
|
||||
} else {
|
||||
keyOpt = keyFull
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const opts = {
|
||||
server,
|
||||
client,
|
||||
port: c.port,
|
||||
host: c.host != null ? String(c.host) : undefined,
|
||||
key: keyOpt,
|
||||
secure,
|
||||
udp
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
function coerceState(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { version: 1, connections: {} }
|
||||
}
|
||||
const o = /** @type {Record<string, unknown>} */ (parsed)
|
||||
const conns = o.connections
|
||||
const connections =
|
||||
conns && typeof conns === 'object' && !Array.isArray(conns)
|
||||
? /** @type {Record<string, unknown>} */ (conns)
|
||||
: {}
|
||||
return { version: 1, connections }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} a
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} b
|
||||
*/
|
||||
function mergeCoercedHolesailStates(a, b) {
|
||||
const conns = { ...a.connections }
|
||||
for (const [id, raw] of Object.entries(b.connections || {})) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const cur = conns[id]
|
||||
if (!cur || typeof cur !== 'object') {
|
||||
conns[id] = raw
|
||||
continue
|
||||
}
|
||||
const c = /** @type {Record<string, unknown>} */ (cur)
|
||||
const n = /** @type {Record<string, unknown>} */ (raw)
|
||||
const ck = String(c.key ?? '').trim()
|
||||
const nk = String(n.key ?? '').trim()
|
||||
const cseed = String(c.seed ?? '').trim()
|
||||
const nseed = String(n.seed ?? '').trim()
|
||||
if (nk && !ck) conns[id] = { ...c, ...n, key: nk }
|
||||
else if (ck && !nk) conns[id] = { ...n, ...c, key: ck }
|
||||
else if (nseed && !cseed) conns[id] = { ...c, ...n, seed: nseed }
|
||||
else if (cseed && !nseed) conns[id] = { ...n, ...c, seed: cseed }
|
||||
else conns[id] = { ...c, ...n }
|
||||
}
|
||||
return { version: 1, connections: conns }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ readFile: (p: string) => Promise<unknown> }} vfs
|
||||
* @param {string} logicalPath
|
||||
*/
|
||||
async function tryReadCoercedStateFile(ctx, vfs, logicalPath) {
|
||||
try {
|
||||
const buf = await vfs.readFile(logicalPath)
|
||||
const text = ctx.b4a
|
||||
? ctx.b4a.toString(buf, 'utf8')
|
||||
: Buffer.from(buf).toString('utf8')
|
||||
return coerceState(JSON.parse(text))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time upgrade when the primary state file is missing or empty: merge legacy sources
|
||||
* (`/.bare/holesail/state.json`, old guest file, `/home/guest/.holesail/state.json`).
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} primaryPath
|
||||
* @returns {Promise<{ version: number, connections: Record<string, unknown> } | null>}
|
||||
*/
|
||||
async function bareHolesailManagedTryMigrateLegacyState(ctx, env, primaryPath) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.resolveLogical !== 'function')
|
||||
return null
|
||||
|
||||
void primaryPath
|
||||
/** @type {string[]} */
|
||||
const paths = [
|
||||
BARE_HOLESAIL_STATE_LEGACY_ROOT,
|
||||
BARE_HOLESAIL_STATE_GUEST,
|
||||
'/home/guest/.holesail/state.json'
|
||||
]
|
||||
|
||||
/** @type {{ version: number, connections: Record<string, unknown> }} */
|
||||
let merged = { version: 1, connections: {} }
|
||||
const seen = new Set()
|
||||
for (const p of paths) {
|
||||
const norm = String(p || '').trim()
|
||||
if (!norm || seen.has(norm)) continue
|
||||
seen.add(norm)
|
||||
const chunk = await tryReadCoercedStateFile(ctx, vfs, norm)
|
||||
if (chunk && Object.keys(chunk.connections).length > 0) {
|
||||
merged = mergeCoercedHolesailStates(merged, chunk)
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged.connections).length === 0) return null
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export async function bareHolesailManagedReadState(ctx, env) {
|
||||
const path = bareHolesailManagedStatePath(env)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function') {
|
||||
return { path, state: { version: 1, connections: {} } }
|
||||
}
|
||||
try {
|
||||
const buf = await vfs.readFile(path)
|
||||
const text = ctx.b4a ? ctx.b4a.toString(buf, 'utf8') : Buffer.from(buf).toString('utf8')
|
||||
const parsed = JSON.parse(text)
|
||||
const state = coerceState(parsed)
|
||||
if (Object.keys(state.connections).length > 0) return { path, state }
|
||||
} catch {
|
||||
/* missing or corrupt */
|
||||
}
|
||||
const migrated = await bareHolesailManagedTryMigrateLegacyState(ctx, env, path)
|
||||
if (migrated && Object.keys(migrated.connections).length > 0) {
|
||||
try {
|
||||
await bareHolesailManagedWriteState(ctx, env, migrated)
|
||||
} catch {
|
||||
/* return merged in-memory even if persist fails */
|
||||
}
|
||||
return { path, state: migrated }
|
||||
}
|
||||
return { path, state: { version: 1, connections: {} } }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} state
|
||||
*/
|
||||
export async function bareHolesailManagedWriteState(ctx, env, state) {
|
||||
const path = bareHolesailManagedStatePath(env)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.writeFile !== 'function') {
|
||||
throw new Error('holesail: vfs.writeFile unavailable')
|
||||
}
|
||||
await ensureDir(ctx, dirname(path))
|
||||
const body = JSON.stringify(state, null, 2) + '\n'
|
||||
await vfs.writeFile(path, ctx.b4a.from(body))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a managed **server** row has a **stable ctor secret** before `new Holesail`.
|
||||
*
|
||||
* - **64-hex `seed`** — authoritative; never replaced by sync.
|
||||
* - **Persisted z32 `seed`** (from migration or upstream) — same as ctor material; **do not clear** (was a bug).
|
||||
* - **`hs://…` `key` only** (legacy) — migrate suffix into **`seed`** or mint **64-hex** **`seed`** (`bare-crypto`).
|
||||
*
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedEnsureServerSeedPersisted(ctx, env, id) {
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[id]
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
const row = /** @type {Record<string, unknown>} */ (raw)
|
||||
if (!bareHolesailEnvTruthy(row.server)) return
|
||||
|
||||
const seedRaw = String(row.seed ?? '').trim()
|
||||
const key = String(row.key ?? '').trim()
|
||||
|
||||
if (seedRaw && bareHolesailManagedSeedLooksValid(seedRaw)) {
|
||||
row.seed = seedRaw.toLowerCase()
|
||||
state.connections[id] = row
|
||||
return
|
||||
}
|
||||
|
||||
if (seedRaw && bareHolesailManagedPersistentSeedLooksValid(seedRaw)) {
|
||||
row.seed = seedRaw.toLowerCase()
|
||||
state.connections[id] = row
|
||||
return
|
||||
}
|
||||
|
||||
if (/^hs:\/\//i.test(key)) {
|
||||
const sfx = bareHolesailManagedHsUrlKeySuffix(key)
|
||||
if (sfx && bareHolesailManagedPersistentSeedLooksValid(sfx)) {
|
||||
row.seed = sfx.toLowerCase()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: persisted hs url suffix as seed for ${id}`)
|
||||
return
|
||||
}
|
||||
row.seed = bareHolesailManagedGenerateSeedHex()
|
||||
delete row.key
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: minted new hex seed (hs key had no stable suffix) for ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (bareHolesailManagedSeedLooksValid(key)) {
|
||||
row.seed = key.toLowerCase()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: migrated hex key → seed for ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
row.seed = bareHolesailManagedGenerateSeedHex()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: persisted new server seed for ${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedDebugEnabled(ctx, env) {
|
||||
return bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_DEBUG)
|
||||
}
|
||||
|
||||
/**
|
||||
* When a managed **server** is live, persist **`hs.info.url`** into **`key`** only.
|
||||
* **Never** overwrite **`seed`** (64-hex ctor secret): replacing it with the z32 URL suffix changes the ctor input
|
||||
* and mints a **different** tunnel on the next login.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>} true if `state.json` was written
|
||||
*/
|
||||
/**
|
||||
* @param {unknown} hs
|
||||
* @param {number} cfgPort
|
||||
*/
|
||||
function bareHolesailManagedResolvedListenPort(hs, cfgPort) {
|
||||
try {
|
||||
const info =
|
||||
hs && typeof hs === 'object' && 'info' in hs
|
||||
? /** @type {{ info?: unknown }} */ (hs).info
|
||||
: null
|
||||
if (info && typeof info === 'object') {
|
||||
const p = Number(/** @type {{ port?: unknown }} */ (info).port)
|
||||
if (Number.isFinite(p) && p > 0) return p
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const n = Number(cfgPort)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
* @param {number} newPort
|
||||
* @param {number} previousPort
|
||||
*/
|
||||
async function persistManagedListenPortAfterFallback(
|
||||
ctx,
|
||||
env,
|
||||
id,
|
||||
newPort,
|
||||
previousPort
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(newPort) ||
|
||||
newPort <= 0 ||
|
||||
!Number.isFinite(previousPort) ||
|
||||
previousPort <= 0 ||
|
||||
newPort === previousPort
|
||||
)
|
||||
return
|
||||
try {
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const cur = state.connections[id]
|
||||
if (!cur || typeof cur !== 'object') return
|
||||
const row = /** @type {Record<string, unknown>} */ (cur)
|
||||
row.port = newPort
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
const rec = managedInstances.get(id)
|
||||
if (rec?.entry && typeof rec.entry === 'object') {
|
||||
/** @type {Record<string, unknown>} */ (rec.entry).port = newPort
|
||||
}
|
||||
logLine(
|
||||
ctx,
|
||||
`managed: persisted listen port ${newPort} (fallback from ${previousPort}) for ${id}`
|
||||
)
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: persist listen port ${id}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function bareHolesailManagedSyncPersistedServerKey(ctx, env, id) {
|
||||
const url = bareHolesailManagedRuntimeUrl(id).trim()
|
||||
if (!url) return false
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const cur = state.connections[id]
|
||||
if (!cur || typeof cur !== 'object') return false
|
||||
const row = /** @type {Record<string, unknown>} */ (cur)
|
||||
if (!bareHolesailEnvTruthy(row.server)) return false
|
||||
|
||||
const prev = String(row.key ?? '').trim()
|
||||
if (prev && bareHolesailManagedPersistedUrlMatchesLive(prev, url)) return false
|
||||
|
||||
row.key = url
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
const rec = managedInstances.get(id)
|
||||
if (rec && rec.entry && typeof rec.entry === 'object') {
|
||||
/** @type {Record<string, unknown>} */ (rec.entry).key = url
|
||||
}
|
||||
logLine(ctx, `managed: synced server hs url for ${id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one managed tunnel from the **current** `state.json` row for `id` (no normalize — uses disk as-is).
|
||||
* Re-reads state immediately before `new Holesail` so `key` matches `~/.holesail/state.json`, including full `hs://…`.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
async function startManagedInstance(ctx, env, id) {
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(ctx, env, id)
|
||||
await yieldForStateCoalescence()
|
||||
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[id]
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
|
||||
const conn = /** @type {Record<string, unknown>} */ (raw)
|
||||
|
||||
const enabled =
|
||||
conn.enabled === undefined || conn.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(conn.enabled)
|
||||
if (!enabled) return
|
||||
|
||||
const server = bareHolesailEnvTruthy(conn.server)
|
||||
const client = bareHolesailEnvTruthy(conn.client)
|
||||
if (server === client) {
|
||||
logLine(ctx, `managed: skip ${id}: set exactly one of server or client`)
|
||||
return
|
||||
}
|
||||
|
||||
const keyStr = String(conn.key ?? '').trim()
|
||||
const seedStr = String(conn.seed ?? '').trim()
|
||||
if (client && !keyStr) {
|
||||
logLine(ctx, `managed: skip ${id}: client requires key`)
|
||||
return
|
||||
}
|
||||
|
||||
if (managedInstances.has(id)) {
|
||||
if (!server) return
|
||||
|
||||
const live = bareHolesailManagedRuntimeUrl(id).trim()
|
||||
const rec = managedInstances.get(id)
|
||||
const prevRow =
|
||||
rec?.entry && typeof rec.entry === 'object'
|
||||
? /** @type {Record<string, unknown>} */ (rec.entry)
|
||||
: null
|
||||
const prevSeed = prevRow ? String(prevRow.seed ?? '').trim() : ''
|
||||
|
||||
if (seedStr && prevSeed && seedStr.toLowerCase() === prevSeed.toLowerCase() && live)
|
||||
return
|
||||
|
||||
if (
|
||||
keyStr &&
|
||||
live &&
|
||||
bareHolesailManagedPersistedUrlMatchesLive(keyStr, live) &&
|
||||
(!seedStr || seedStr.toLowerCase() === prevSeed.toLowerCase())
|
||||
)
|
||||
return
|
||||
|
||||
await stopManagedInstance(ctx, id)
|
||||
}
|
||||
|
||||
const Holesail = await loadHolesailConstructor(ctx)
|
||||
const cfg = bareHolesailManagedNewHolesailOpts(conn, env)
|
||||
if (server && !String(cfg.key ?? '').trim()) {
|
||||
logLine(ctx, `managed: skip ${id}: server ctor key material empty after ensure`)
|
||||
return
|
||||
}
|
||||
const requestedPort =
|
||||
cfg.port !== undefined && cfg.port !== null ? Number(cfg.port) : NaN
|
||||
|
||||
let hs = new Holesail(cfg)
|
||||
try {
|
||||
await hs.ready()
|
||||
} catch (e) {
|
||||
if (!bindFallbackEnabled(env) || !isAddrInUse(e)) throw e
|
||||
await safeCloseHolesail(hs)
|
||||
cfg.port = 0
|
||||
hs = new Holesail(cfg)
|
||||
await hs.ready()
|
||||
logLine(
|
||||
ctx,
|
||||
`managed: ${id} ready after EADDRINUSE fallback (ephemeral port)`
|
||||
)
|
||||
}
|
||||
managedInstances.set(id, { hs, entry: { ...conn } })
|
||||
|
||||
const cfgPortAfter =
|
||||
typeof cfg.port === 'number'
|
||||
? cfg.port
|
||||
: cfg.port != null
|
||||
? Number(cfg.port)
|
||||
: NaN
|
||||
const resolved = bareHolesailManagedResolvedListenPort(hs, cfgPortAfter)
|
||||
if (
|
||||
bindFallbackEnabled(env) &&
|
||||
Number.isFinite(requestedPort) &&
|
||||
requestedPort > 0 &&
|
||||
resolved > 0 &&
|
||||
resolved !== requestedPort
|
||||
) {
|
||||
await persistManagedListenPortAfterFallback(
|
||||
ctx,
|
||||
env,
|
||||
id,
|
||||
resolved,
|
||||
requestedPort
|
||||
)
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await bareHolesailManagedSyncPersistedServerKey(ctx, env, id)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: persist key ${id} skipped: ${msg}`)
|
||||
}
|
||||
}
|
||||
logLine(ctx, `managed: started ${id}`)
|
||||
if (bareHolesailManagedDebugEnabled(ctx, env)) {
|
||||
try {
|
||||
const info = hs.info
|
||||
const url = info && typeof info === 'object' ? info.url : ''
|
||||
ctx.console?.log?.(`[bare-holesail] managed ${id} url=${String(url).slice(0, 120)}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} id
|
||||
*/
|
||||
async function stopManagedInstance(ctx, id) {
|
||||
const rec = managedInstances.get(id)
|
||||
if (!rec) return
|
||||
managedInstances.delete(id)
|
||||
await safeCloseHolesail(rec.hs)
|
||||
logLine(ctx, `managed: stopped ${id}`)
|
||||
}
|
||||
|
||||
/** Yield one tick so `bare-os-www` / `bare-os-ssh-holesail` (and other writers) can finish persisting `~/.holesail/state.json`. */
|
||||
function yieldForStateCoalescence() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof globalThis.setImmediate === 'function') globalThis.setImmediate(resolve)
|
||||
else queueMicrotask(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start every enabled connection from a fresh read of disk (used for initial start + reconcile pass).
|
||||
* **`onlyNew`** — when `true`, skip ids already live in **`managedInstances`** so a second pass can pick up
|
||||
* connections written after the first pass (e.g. **bare-os-www**, **bare-openssh**) without destroying running tunnels.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {{ onlyNew?: boolean }} [opts]
|
||||
*/
|
||||
async function startManagedConnectionsFromDisk(ctx, env, opts = {}) {
|
||||
const onlyNew = opts.onlyNew === true
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const idsSorted = Object.keys(state.connections).sort()
|
||||
for (const id of idsSorted) {
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) {
|
||||
logLine(ctx, `managed: skip invalid id ${id}: ${idOk.err}`)
|
||||
continue
|
||||
}
|
||||
if (onlyNew && managedInstances.has(idOk.id)) continue
|
||||
try {
|
||||
await startManagedInstance(ctx, env, idOk.id)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: start ${id} failed: ${msg}`)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-holesail] managed ${id}: ${msg}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export async function bareHolesailManagedStartService(ctx, env) {
|
||||
if (managedServiceRunning) return
|
||||
|
||||
managedServiceRunning = true
|
||||
|
||||
await startManagedConnectionsFromDisk(ctx, env)
|
||||
await yieldForStateCoalescence()
|
||||
await startManagedConnectionsFromDisk(ctx, env, { onlyNew: true })
|
||||
|
||||
managedHookUnsubs.push(
|
||||
ctx.bareOsRegisterSuspendHook(async () => {
|
||||
for (const { hs } of managedInstances.values()) {
|
||||
if (hs && typeof hs.pause === 'function') {
|
||||
try {
|
||||
await hs.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
managedHookUnsubs.push(
|
||||
ctx.bareOsRegisterResumeHook(async () => {
|
||||
for (const { hs } of managedInstances.values()) {
|
||||
if (hs && typeof hs.resume === 'function') {
|
||||
try {
|
||||
await hs.resume()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logLine(ctx, 'managed: service ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function bareHolesailManagedStopService(ctx) {
|
||||
for (const u of managedHookUnsubs.splice(0)) {
|
||||
try {
|
||||
u()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const ids = [...managedInstances.keys()]
|
||||
for (const id of ids) {
|
||||
await stopManagedInstance(ctx, id)
|
||||
}
|
||||
managedServiceRunning = false
|
||||
logLine(ctx, 'managed: service stopped')
|
||||
}
|
||||
|
||||
export function bareHolesailManagedServiceIsRunning() {
|
||||
return managedServiceRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedRuntimeRunning(id) {
|
||||
return managedInstances.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedRuntimeUrl(id) {
|
||||
const rec = managedInstances.get(id)
|
||||
if (!rec || !rec.hs) return ''
|
||||
try {
|
||||
const info = rec.hs.info
|
||||
if (info && typeof info === 'object' && 'url' in info)
|
||||
return String(/** @type {{ url?: string }} */ (info).url || '')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedStartOne(ctx, env, id) {
|
||||
if (!bareHolesailManagedServiceIsRunning()) {
|
||||
throw new Error('holesail: managed daemon is not running (enable bare-holesail + MANAGED)')
|
||||
}
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) throw new Error(idOk.err)
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[idOk.id]
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error(`holesail: unknown connection ${idOk.id}`)
|
||||
const row = /** @type {Record<string, unknown>} */ (raw)
|
||||
const en =
|
||||
row.enabled === undefined || row.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(row.enabled)
|
||||
if (!en) throw new Error(`holesail: ${idOk.id} is disabled`)
|
||||
await startManagedInstance(ctx, env, idOk.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedStopOne(ctx, id) {
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) throw new Error(idOk.err)
|
||||
await stopManagedInstance(ctx, idOk.id)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* bare-holesail — Holesail TCP/UDP P2P proxy via the official holesail package API only.
|
||||
* Initd unit `bare-holesail` (stock-on managed mode; disable via env/initd) and optional early booter instance (kernel-path).
|
||||
*
|
||||
* Upstream is AGPL-3.0; the booter imports `holesail` directly (same Pear-staged pattern as
|
||||
* bare-openssh / bare-ssh2) instead of depending on ctx.bare / drive-bundle resolution.
|
||||
*/
|
||||
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
|
||||
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
|
||||
import {
|
||||
bareHolesailManagedServiceIsRunning,
|
||||
bareHolesailManagedStartService,
|
||||
bareHolesailManagedStopService
|
||||
} from './bare-holesail-managed.js'
|
||||
import { bindFallbackEnabled, isAddrInUse } from './bare-os-bind-fallback.js'
|
||||
import { loadHolesailConstructor } from './bare-holesail-loader.js'
|
||||
import {
|
||||
registerBareInitdDisposer,
|
||||
registerBareService,
|
||||
registerKernelShutdownHook
|
||||
} from './bare-initd.js'
|
||||
|
||||
export { bareHolesailEnvTruthy }
|
||||
|
||||
export const BARE_HOLESAIL_LOG = `${BARE_OS_VAR_LOG_DIR}/holesail.log`
|
||||
|
||||
/** @type {unknown} */
|
||||
let kernelHolesail = null
|
||||
/** @type {unknown} */
|
||||
let initdHolesail = null
|
||||
/** @type {(() => void)[]} */
|
||||
let initdHookUnsubs = []
|
||||
/** @type {(() => void)[]} */
|
||||
let kernelHookUnsubs = []
|
||||
|
||||
/** @type {boolean} */
|
||||
let bareHolesailInitdRegistered = false
|
||||
|
||||
/**
|
||||
* Register the **bare-holesail** initd unit (call from **`bare-user-session-stack`** after unlock).
|
||||
* Stock **`BARE_OS_HOLESAIL_INITD`** default is on; set **`0`** to skip registration.
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function maybeRegisterBareHolesailInitd(env) {
|
||||
if (bareHolesailInitdRegistered) return
|
||||
if (!bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_INITD)) return
|
||||
bareHolesailInitdRegistered = true
|
||||
registerBareService({
|
||||
name: 'bare-holesail',
|
||||
description:
|
||||
'Holesail P2P TCP/UDP proxy (official holesail API); on by default (managed multi-tunnel for /bin/holesail); disable with BARE_OS_HOLESAIL_INITD=0 or systemctl disable',
|
||||
logPath: BARE_HOLESAIL_LOG,
|
||||
start: initdStartBareHolesail,
|
||||
stop: initdStopBareHolesail
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} key
|
||||
*/
|
||||
function envStr(env, key) {
|
||||
const v = env[key]
|
||||
if (v === undefined || v === null) return ''
|
||||
return String(v).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {'initd' | 'kernel'} profile
|
||||
* @returns {{ enabled: boolean, err?: string, opts?: Record<string, unknown> }}
|
||||
*/
|
||||
export function bareHolesailParseConfig(env, profile) {
|
||||
const isKernel = profile === 'kernel'
|
||||
const enabled = isKernel
|
||||
? bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_KERNEL)
|
||||
: bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_INITD)
|
||||
if (!enabled) return { enabled: false }
|
||||
|
||||
const P = isKernel ? 'BARE_OS_HOLESAIL_KERNEL_' : 'BARE_OS_HOLESAIL_'
|
||||
const server = bareHolesailEnvTruthy(env[`${P}SERVER`])
|
||||
const client = bareHolesailEnvTruthy(env[`${P}CLIENT`])
|
||||
if (server === client) {
|
||||
return {
|
||||
enabled: true,
|
||||
err: 'holesail: set exactly one of SERVER or CLIENT for this profile'
|
||||
}
|
||||
}
|
||||
const key = envStr(env, `${P}KEY`)
|
||||
if (client && !key) {
|
||||
return { enabled: true, err: 'holesail: CLIENT requires KEY' }
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const opts = {
|
||||
server,
|
||||
client,
|
||||
key: key || undefined
|
||||
}
|
||||
|
||||
const sec = envStr(env, `${P}SECURE`)
|
||||
if (sec !== '') opts.secure = bareHolesailEnvTruthy(sec)
|
||||
|
||||
const portRaw = envStr(env, `${P}PORT`)
|
||||
if (portRaw !== '') {
|
||||
const n = Number.parseInt(portRaw, 10)
|
||||
if (!Number.isFinite(n)) {
|
||||
return { enabled: true, err: 'holesail: invalid PORT' }
|
||||
}
|
||||
opts.port = n
|
||||
}
|
||||
|
||||
const host = envStr(env, `${P}HOST`)
|
||||
if (host !== '') opts.host = host
|
||||
|
||||
const udp = envStr(env, `${P}UDP`)
|
||||
if (udp !== '') opts.udp = bareHolesailEnvTruthy(udp)
|
||||
|
||||
const logRaw = envStr(env, `${P}LOG`)
|
||||
if (logRaw !== '') {
|
||||
if (/^\d+$/.test(logRaw)) opts.log = Number.parseInt(logRaw, 10)
|
||||
else opts.log = bareHolesailEnvTruthy(logRaw)
|
||||
}
|
||||
|
||||
return { enabled: true, opts }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} line
|
||||
*/
|
||||
function logLine(ctx, line) {
|
||||
void appendVarLog(ctx, BARE_HOLESAIL_LOG, 'holesail', line)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function debugEnabled(ctx, env) {
|
||||
return bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_DEBUG)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} hs
|
||||
*/
|
||||
async function safeCloseHolesail(hs) {
|
||||
if (!hs || typeof hs.close !== 'function') return
|
||||
try {
|
||||
await hs.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional early booter instance: runs after ctx is wired (repl phase), before bare-initd.
|
||||
* Env: BARE_OS_HOLESAIL_KERNEL=1 and BARE_OS_HOLESAIL_KERNEL_SERVER|CLIENT, etc.
|
||||
*
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function maybeStartBareHolesailKernelFromBooter(ctx) {
|
||||
const env =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: /** @type {Record<string, string | undefined>} */ ({})
|
||||
|
||||
const parsed = bareHolesailParseConfig(env, 'kernel')
|
||||
if (!parsed.enabled) return
|
||||
if (parsed.err) {
|
||||
logLine(ctx, `kernel: ${parsed.err}`)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-holesail] ${parsed.err}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!parsed.opts) return
|
||||
|
||||
try {
|
||||
const Holesail = await loadHolesailConstructor(ctx)
|
||||
let hs = new Holesail(parsed.opts)
|
||||
try {
|
||||
await hs.ready()
|
||||
} catch (e) {
|
||||
if (!bindFallbackEnabled(env) || !isAddrInUse(e)) throw e
|
||||
await safeCloseHolesail(hs)
|
||||
hs = new Holesail({ ...parsed.opts, port: 0 })
|
||||
await hs.ready()
|
||||
logLine(ctx, 'kernel: ready after EADDRINUSE fallback (ephemeral port)')
|
||||
}
|
||||
kernelHolesail = hs
|
||||
logLine(ctx, 'kernel: ready (early booter instance)')
|
||||
if (debugEnabled(ctx, env)) {
|
||||
try {
|
||||
const info = hs.info
|
||||
const url = info && typeof info === 'object' ? info.url : ''
|
||||
ctx.console?.log?.(`[bare-holesail] kernel url=${String(url).slice(0, 120)}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
kernelHookUnsubs.push(
|
||||
ctx.bareOsRegisterSuspendHook(async () => {
|
||||
if (kernelHolesail && typeof kernelHolesail.pause === 'function') {
|
||||
try {
|
||||
await kernelHolesail.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
kernelHookUnsubs.push(
|
||||
ctx.bareOsRegisterResumeHook(async () => {
|
||||
if (kernelHolesail && typeof kernelHolesail.resume === 'function') {
|
||||
try {
|
||||
await kernelHolesail.resume()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
registerKernelShutdownHook(async () => {
|
||||
for (const u of kernelHookUnsubs.splice(0)) {
|
||||
try {
|
||||
u()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const h = kernelHolesail
|
||||
kernelHolesail = null
|
||||
await safeCloseHolesail(h)
|
||||
logLine(ctx, 'kernel: closed (session shutdown)')
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `kernel: start error: ${msg}`)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-holesail] kernel: ${msg}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initdStartBareHolesail(ctx) {
|
||||
const env =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: /** @type {Record<string, string | undefined>} */ ({})
|
||||
|
||||
const initdOn = bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_INITD)
|
||||
const managedOn = bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_MANAGED)
|
||||
if (initdOn && managedOn) {
|
||||
try {
|
||||
await bareHolesailManagedStartService(ctx, env)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `initd managed: start error: ${msg}`)
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = bareHolesailParseConfig(env, 'initd')
|
||||
if (!parsed.enabled) return
|
||||
if (parsed.err) {
|
||||
logLine(ctx, `initd: ${parsed.err}`)
|
||||
throw new Error(parsed.err)
|
||||
}
|
||||
if (!parsed.opts) return
|
||||
|
||||
try {
|
||||
const Holesail = await loadHolesailConstructor(ctx)
|
||||
let hs = new Holesail(parsed.opts)
|
||||
try {
|
||||
await hs.ready()
|
||||
} catch (e) {
|
||||
if (!bindFallbackEnabled(env) || !isAddrInUse(e)) throw e
|
||||
await safeCloseHolesail(hs)
|
||||
hs = new Holesail({ ...parsed.opts, port: 0 })
|
||||
await hs.ready()
|
||||
logLine(ctx, 'initd: ready after EADDRINUSE fallback (ephemeral port)')
|
||||
}
|
||||
initdHolesail = hs
|
||||
logLine(ctx, 'initd: ready')
|
||||
if (debugEnabled(ctx, env)) {
|
||||
try {
|
||||
const info = hs.info
|
||||
const url = info && typeof info === 'object' ? info.url : ''
|
||||
ctx.console?.log?.(`[bare-holesail] initd url=${String(url).slice(0, 120)}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
initdHookUnsubs.push(
|
||||
ctx.bareOsRegisterSuspendHook(async () => {
|
||||
if (initdHolesail && typeof initdHolesail.pause === 'function') {
|
||||
try {
|
||||
await initdHolesail.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
initdHookUnsubs.push(
|
||||
ctx.bareOsRegisterResumeHook(async () => {
|
||||
if (initdHolesail && typeof initdHolesail.resume === 'function') {
|
||||
try {
|
||||
await initdHolesail.resume()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `initd: start error: ${msg}`)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function initdStopBareHolesail(ctx) {
|
||||
if (bareHolesailManagedServiceIsRunning()) {
|
||||
await bareHolesailManagedStopService(ctx)
|
||||
logLine(ctx, 'initd: stopped (managed)')
|
||||
return
|
||||
}
|
||||
for (const u of initdHookUnsubs.splice(0)) {
|
||||
try {
|
||||
u()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const h = initdHolesail
|
||||
initdHolesail = null
|
||||
await safeCloseHolesail(h)
|
||||
logLine(ctx, 'initd: stopped')
|
||||
}
|
||||
|
||||
registerBareInitdDisposer(() => {
|
||||
if (bareHolesailManagedServiceIsRunning()) {
|
||||
void bareHolesailManagedStopService({})
|
||||
return
|
||||
}
|
||||
for (const u of initdHookUnsubs.splice(0)) {
|
||||
try {
|
||||
u()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const h = initdHolesail
|
||||
initdHolesail = null
|
||||
void safeCloseHolesail(h)
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
setupBareOsMeshdropChannel,
|
||||
PROTOCOL_MESHDROP_CHANNEL_NAME,
|
||||
BARE_OS_MESHDROP_WIRE_SCHEMA_VERSION,
|
||||
bareOsProtMuxMeshdropChannelEnabled
|
||||
} from 'bare-os-protocol'
|
||||
|
||||
/**
|
||||
* @typedef {{ chan: import('protomux').Channel, mux: import('protomux').Protomux, socket: any, id: string | null, meshdropChan?: import('protomux').Channel | null }} SwarmPeer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function bareOsMeshdropMuxEnabled(env = globalThis.process?.env) {
|
||||
return bareOsProtMuxMeshdropChannelEnabled(env || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function ensureDiskBareOsMeshdropTransport(disk, env = {}) {
|
||||
if (!disk || !bareOsMeshdropMuxEnabled(globalThis.process?.env)) return
|
||||
const merged = {
|
||||
.../** @type {Record<string, string | undefined>} */ (
|
||||
globalThis.process?.env || {}
|
||||
),
|
||||
...env
|
||||
}
|
||||
disk.bareOsMeshdropService = createBareOsMeshdropService({ env: merged })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function meshdropHistoryMax(env) {
|
||||
const raw = String(env.BARE_OS_MESHDROP_HISTORY_MAX ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (Number.isFinite(n) && n >= 32 && n <= 10000) return n
|
||||
return 2048
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function meshdropPayloadMaxBytes(env) {
|
||||
const raw = String(env.BARE_OS_MESHDROP_MAX_PAYLOAD_BYTES ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (Number.isFinite(n) && n >= 512 && n <= 512 * 1024) return n
|
||||
return 64 * 1024
|
||||
}
|
||||
|
||||
export function createBareOsMeshdropService(opts = {}) {
|
||||
const env = opts.env || globalThis.process?.env || {}
|
||||
const historyMax = meshdropHistoryMax(
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
const payloadMaxBytes = meshdropPayloadMaxBytes(
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
/** @type {Set<(ev: Record<string, unknown>) => void>} */
|
||||
const subscribers = new Set()
|
||||
/** @type {Array<Record<string, unknown>>} */
|
||||
const history = []
|
||||
/** @type {Map<string, number>} */
|
||||
const seenFrame = new Map()
|
||||
const metrics = {
|
||||
rxEnvelope: 0,
|
||||
txEnvelope: 0,
|
||||
droppedPayload: 0,
|
||||
deduped: 0
|
||||
}
|
||||
|
||||
function trimSeen() {
|
||||
const now = Date.now()
|
||||
for (const [k, exp] of seenFrame) {
|
||||
if (exp < now) seenFrame.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
function pushHistory(rec) {
|
||||
history.push(rec)
|
||||
while (history.length > historyMax) history.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} frame
|
||||
*/
|
||||
function frameId(frame) {
|
||||
const p = frame && typeof frame.payload === 'object' ? frame.payload : {}
|
||||
const transferId = typeof p.transferId === 'string' ? p.transferId : ''
|
||||
const offerId = typeof p.offerId === 'string' ? p.offerId : ''
|
||||
const idx = typeof p.index === 'number' ? p.index : -1
|
||||
const kind = typeof frame.kind === 'string' ? frame.kind : 'unknown'
|
||||
const frameUid = typeof p.frameId === 'string' ? p.frameId : ''
|
||||
return frameUid || `${transferId}:${offerId}:${kind}:${idx}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {SwarmPeer} fromPeer
|
||||
* @param {Record<string, unknown>} frame
|
||||
*/
|
||||
function relayEnvelope(disk, fromPeer, frame) {
|
||||
for (const p of disk.peers) {
|
||||
if (p === fromPeer) continue
|
||||
const ch = p.meshdropChan
|
||||
if (!ch || !ch.messages || !ch.messages[0]) continue
|
||||
try {
|
||||
ch.messages[0].send(frame)
|
||||
metrics.txEnvelope++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {SwarmPeer} fromPeer
|
||||
* @param {Record<string, unknown>} frame
|
||||
*/
|
||||
function ingestEnvelope(disk, fromPeer, frame) {
|
||||
if (!frame || typeof frame !== 'object') return
|
||||
const encodedLen = JSON.stringify(frame).length
|
||||
if (encodedLen > payloadMaxBytes) {
|
||||
metrics.droppedPayload++
|
||||
return
|
||||
}
|
||||
trimSeen()
|
||||
const fid = frameId(frame)
|
||||
if (fid) {
|
||||
if (seenFrame.has(fid)) {
|
||||
metrics.deduped++
|
||||
return
|
||||
}
|
||||
seenFrame.set(fid, Date.now() + 120_000)
|
||||
}
|
||||
metrics.rxEnvelope++
|
||||
const rec = {
|
||||
...frame,
|
||||
fromPeerKey: fromPeer.id || '',
|
||||
receivedAtMs: Date.now()
|
||||
}
|
||||
pushHistory(rec)
|
||||
for (const fn of subscribers) {
|
||||
try {
|
||||
fn(rec)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
relayEnvelope(disk, fromPeer, frame)
|
||||
}
|
||||
|
||||
return {
|
||||
PROTOCOL_MESHDROP_CHANNEL_NAME,
|
||||
metrics,
|
||||
history() {
|
||||
return [...history]
|
||||
},
|
||||
subscribe(fn) {
|
||||
subscribers.add(fn)
|
||||
return () => subscribers.delete(fn)
|
||||
},
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {import('protomux').Protomux} mux
|
||||
* @param {any} _socket
|
||||
* @param {SwarmPeer} peer
|
||||
*/
|
||||
pairOnMux(disk, mux, _socket, peer) {
|
||||
setupBareOsMeshdropChannel(mux, {
|
||||
onEnvelope(m, _ch) {
|
||||
try {
|
||||
disk.protomuxMeshdropChannelRxTotal =
|
||||
(disk.protomuxMeshdropChannelRxTotal || 0) + 1
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ingestEnvelope(disk, peer, m)
|
||||
},
|
||||
onChannelOpened(chan) {
|
||||
peer.meshdropChan = chan
|
||||
mux.stream?.once?.('close', () => {
|
||||
peer.meshdropChan = null
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {Record<string, unknown>} frame
|
||||
* @param {{ sender?: string }} [meta]
|
||||
*/
|
||||
broadcastLocal(disk, frame, meta = {}) {
|
||||
const safeFrame = {
|
||||
schemaVersion: BARE_OS_MESHDROP_WIRE_SCHEMA_VERSION,
|
||||
sender: String(meta.sender || env.USER || 'local'),
|
||||
tsMs: Date.now(),
|
||||
...frame
|
||||
}
|
||||
const encodedLen = JSON.stringify(safeFrame).length
|
||||
if (encodedLen > payloadMaxBytes) {
|
||||
metrics.droppedPayload++
|
||||
return { ok: false, reason: 'payload_too_large' }
|
||||
}
|
||||
pushHistory({ ...safeFrame, local: true, receivedAtMs: Date.now() })
|
||||
for (const fn of subscribers) {
|
||||
try {
|
||||
fn({ ...safeFrame, local: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const p of disk.peers) {
|
||||
const ch = p.meshdropChan
|
||||
if (!ch || !ch.messages || !ch.messages[0]) continue
|
||||
try {
|
||||
ch.messages[0].send(safeFrame)
|
||||
metrics.txEnvelope++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
snapshotMetrics() {
|
||||
return {
|
||||
...metrics,
|
||||
historyMax,
|
||||
payloadMaxBytes,
|
||||
protocol: PROTOCOL_MESHDROP_CHANNEL_NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Optional peer admission gate from **`BARE_OS_PEER_ALLOWLIST_HEX`**
|
||||
* (comma-separated hex public keys; empty = deny unless explicit allow-all).
|
||||
*
|
||||
* **`BARE_OS_PEER_DENYLIST_HEX`** — comma-separated hex keys; if the peer matches,
|
||||
* verdict is **`deny`** before allowlist evaluation (denylist wins).
|
||||
*
|
||||
* **`BARE_OS_PEER_REQUIRE_CAPS_JSON`** — JSON array of capability strings; when set,
|
||||
* **`meta.caps`** must include every required token (hosts pass peer caps when known).
|
||||
*
|
||||
* When **`BARE_OS_DHT_ADDRESS_CLASS_ALLOWLIST`** is set and callers pass
|
||||
* **`dhtAddressClass`** (hyperdht-address–style hint), the class must appear in
|
||||
* the allow list after the key gate passes.
|
||||
*
|
||||
* Admission results use **`schema: 2`** (v1 was **`schema: 1`** only).
|
||||
*
|
||||
* @param {Record<string, unknown> | null | undefined} env
|
||||
* @param {string} peerKeyHex
|
||||
* @param {{ dhtAddressClass?: string, caps?: string[] } | null | undefined} [meta]
|
||||
*/
|
||||
const ADMISSION_SCHEMA = 2
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown> | null | undefined} env
|
||||
*/
|
||||
function zeroTrustStrictProfile(env) {
|
||||
const p = String(env?.BARE_OS_ZERO_TRUST_PROFILE || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return p === 'strict' || p === 'security'
|
||||
}
|
||||
|
||||
/** @param {string} s */
|
||||
function normKeyHex(s) {
|
||||
return String(s || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^0x/, '')
|
||||
}
|
||||
|
||||
/** @param {string} raw */
|
||||
function keySetFromCommaHex(raw) {
|
||||
return new Set(
|
||||
raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => normKeyHex(s))
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown> | null | undefined} env
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
function parseRequireCaps(env) {
|
||||
const raw = String(env?.BARE_OS_PEER_REQUIRE_CAPS_JSON || '').trim()
|
||||
if (!raw) return null
|
||||
try {
|
||||
const j = JSON.parse(raw)
|
||||
if (!Array.isArray(j)) return null
|
||||
const caps = j
|
||||
.map((x) => String(x).trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 32)
|
||||
return caps.length ? caps : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateBareOsPeerAdmission(env, peerKeyHex, meta = undefined) {
|
||||
const want = normKeyHex(peerKeyHex)
|
||||
const atMs = () => Date.now()
|
||||
|
||||
const denyRaw = String(env?.BARE_OS_PEER_DENYLIST_HEX || '').trim()
|
||||
if (denyRaw) {
|
||||
const denySet = keySetFromCommaHex(denyRaw)
|
||||
if (want && denySet.has(want)) {
|
||||
return {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: 'deny',
|
||||
reason: 'peer_denylist',
|
||||
denylistSize: denySet.size,
|
||||
atMs: atMs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requireCaps = parseRequireCaps(env)
|
||||
if (requireCaps && requireCaps.length > 0) {
|
||||
const peerCapsRaw = meta && Array.isArray(meta.caps) ? meta.caps : []
|
||||
const peerSet = new Set(
|
||||
peerCapsRaw.map((c) => String(c).trim()).filter(Boolean)
|
||||
)
|
||||
for (const c of requireCaps) {
|
||||
if (!peerSet.has(c)) {
|
||||
return {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: 'deny',
|
||||
reason: 'peer_missing_cap',
|
||||
requiredCaps: [...requireCaps],
|
||||
atMs: atMs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const raw = String(env?.BARE_OS_PEER_ALLOWLIST_HEX || '').trim()
|
||||
const strict =
|
||||
env?.BARE_OS_PEER_ALLOWLIST_STRICT === '1' ||
|
||||
env?.BARE_OS_PEER_ALLOWLIST_STRICT === 'true' ||
|
||||
zeroTrustStrictProfile(env)
|
||||
const allowAll =
|
||||
env?.BARE_OS_PEER_ALLOW_ALL === '1' || env?.BARE_OS_PEER_ALLOW_ALL === 'true'
|
||||
/** @type {{ schema: number, verdict: string, note?: string, allowlistSize?: number, strictProfile?: boolean, atMs?: number, dhtAddressClassGate?: Record<string, unknown> }} */
|
||||
let base
|
||||
if (!raw) {
|
||||
if (strict || !allowAll) {
|
||||
base = {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: 'deny',
|
||||
reason: 'peer_allowlist_empty',
|
||||
note:
|
||||
'Peer allowlist is empty; admission denied unless BARE_OS_PEER_ALLOW_ALL=1.'
|
||||
}
|
||||
} else {
|
||||
base = {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: 'allow',
|
||||
note: 'No allowlist and BARE_OS_PEER_ALLOW_ALL=1; peers admitted.'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const set = keySetFromCommaHex(raw)
|
||||
const ok = want && set.has(want)
|
||||
base = {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: ok ? 'allow' : 'deny',
|
||||
allowlistSize: set.size,
|
||||
strictProfile: strict || undefined,
|
||||
atMs: atMs()
|
||||
}
|
||||
}
|
||||
|
||||
const classRaw = String(env?.BARE_OS_DHT_ADDRESS_CLASS_ALLOWLIST || '').trim()
|
||||
if (!classRaw) {
|
||||
if (base.atMs == null) base.atMs = atMs()
|
||||
return base
|
||||
}
|
||||
|
||||
const allowClasses = new Set(
|
||||
classRaw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
||||
)
|
||||
if (allowClasses.size === 0) {
|
||||
if (base.atMs == null) base.atMs = atMs()
|
||||
return base
|
||||
}
|
||||
|
||||
const cls = String(meta?.dhtAddressClass || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!cls) {
|
||||
return {
|
||||
...base,
|
||||
atMs: base.atMs ?? atMs(),
|
||||
dhtAddressClassGate: {
|
||||
schema: 1,
|
||||
mode: 'no_peer_class_hint',
|
||||
note: 'BARE_OS_DHT_ADDRESS_CLASS_ALLOWLIST is set but caller did not supply dhtAddressClass — stock Hyperswarm path does not classify peers; allow key verdict only.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowClasses.has(cls)) {
|
||||
return {
|
||||
schema: ADMISSION_SCHEMA,
|
||||
verdict: 'deny',
|
||||
reason: 'dht_address_class',
|
||||
dhtAddressClass: cls,
|
||||
allowedClasses: [...allowClasses],
|
||||
note: 'Peer DHT address class not listed in BARE_OS_DHT_ADDRESS_CLASS_ALLOWLIST.',
|
||||
atMs: atMs()
|
||||
}
|
||||
}
|
||||
|
||||
if (base.verdict === 'deny') return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
atMs: base.atMs ?? atMs(),
|
||||
dhtAddressClassGate: { schema: 1, mode: 'allow', class: cls }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an event-bus row for **`peer_admission`** audit (**never** includes full peer keys).
|
||||
*
|
||||
* @param {string} peerKeyHex
|
||||
* @param {{ verdict: string, reason?: string, schema: number }} result
|
||||
* @param {{ dhtAddressClass?: string } | null | undefined} [meta]
|
||||
*/
|
||||
export function bareOsFormatPeerAdmissionAuditEvent(peerKeyHex, result, meta) {
|
||||
const norm = String(peerKeyHex || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^0x/, '')
|
||||
const keyShort = norm ? norm.slice(0, 16) : ''
|
||||
return {
|
||||
type: 'peer_admission',
|
||||
verdict: result.verdict,
|
||||
reason: result.reason,
|
||||
peerKeyHexPrefix: keyShort || undefined,
|
||||
admissionSchema: result.schema,
|
||||
dhtAddressClass:
|
||||
meta && typeof meta === 'object'
|
||||
? String(meta.dhtAddressClass || '').trim() || undefined
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket rate gate for admission audit (**mutates** **`lastMap`** on allow).
|
||||
*
|
||||
* @param {string} bucket
|
||||
* @param {number} now
|
||||
* @param {number} rateMs
|
||||
* @param {Map<string, number>} lastMap
|
||||
*/
|
||||
export function bareOsPeerAdmissionAuditRateAllow(bucket, now, rateMs, lastMap) {
|
||||
if (rateMs <= 0) return true
|
||||
const last = lastMap.get(bucket) ?? 0
|
||||
if (now - last < rateMs) return false
|
||||
lastMap.set(bucket, now)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Peer-assisted seeding: booted booters may mirror MBR + seed RPC snapshots for
|
||||
* cold joiners when {@link peerSystemSeedEnvEnabled} (default **on**; opt out with
|
||||
* **`BARE_OS_PEER_SYSTEM_SEED=0`**, **`false`**, **`no`**, or **`off`**).
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
import {
|
||||
BARE_OS_KERNEL_CAPABILITY_WIRE_VERSION,
|
||||
BARE_OS_KERNEL_CAPABILITY_WORDS_JSON_KEY,
|
||||
BARE_OS_KERNEL_FEATURES_STOCK_WORD_PRIMARY,
|
||||
BARE_OS_KERNEL_FEATURE_BITS_DOC,
|
||||
BARE_OS_PROTOCOL_PACKAGE_VERSION,
|
||||
PROTOCOL_NAME,
|
||||
getKernelCapabilityWords
|
||||
} from 'bare-os-protocol'
|
||||
import { buildStockKernelCapabilityWords } from './bare-os-capability-registry.js'
|
||||
|
||||
/**
|
||||
* Whether peer system seeding may run (default **enabled** when unset).
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function peerSystemSeedEnvEnabled(env) {
|
||||
const v = String(env?.BARE_OS_PEER_SYSTEM_SEED ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false
|
||||
if (v === '1' || v === 'true' || v === 'yes' || v === 'on') return true
|
||||
const p = String(env?.BARE_OS_ZERO_TRUST_PROFILE || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (p === 'strict' || p === 'security') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator explicitly set **`BARE_OS_PEER_SYSTEM_SEED`** to an affirmative token
|
||||
* (**`1`**, **`true`**, **`yes`**). Used for host diagnostics when eligibility fails.
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
*/
|
||||
export function peerSystemSeedExplicitAffirmative(env) {
|
||||
const v = String(env?.BARE_OS_PEER_SYSTEM_SEED ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return v === '1' || v === 'true' || v === 'yes'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @returns {Record<string, unknown | null>}
|
||||
*/
|
||||
/**
|
||||
* After a successful swarm boot, fill {@link import('./swarm-disk.js').SwarmDisk#seedCapabilityInfo}
|
||||
* with stock kernel capability words when the publisher handshake was skipped, failed, or returned
|
||||
* no words — so {@link computePeerSystemSeedEligibility} can pass and more nodes mirror **block 0**.
|
||||
*
|
||||
* Opt out with **`BARE_OS_PEER_SEED_SYNTHETIC_CAPABILITIES`** **`0`** / **`false`** / **`no`** / **`off`**.
|
||||
*
|
||||
* **`imageTipId`** on the synthetic object (for tip gates and cold joiners) is taken from, in order:
|
||||
* **`BARE_OS_SEED_IMAGE_TIP_ID`**, **`BARE_OS_PEER_SEED_ADVERTISE_IMAGE_TIP_ID`**, **`BARE_OS_PEER_SEED_IMAGE_TIP_ID`**,
|
||||
* then any **`imageTipId`** on a partial non-error capability object.
|
||||
*
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
*/
|
||||
export function maybeSynthesizePeerSeedCapabilityInfo(disk, env) {
|
||||
const p = String(env?.BARE_OS_ZERO_TRUST_PROFILE || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (p === 'strict' || p === 'security') return
|
||||
const off = String(env?.BARE_OS_PEER_SEED_SYNTHETIC_CAPABILITIES ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (off === '0' || off === 'false' || off === 'no' || off === 'off') return
|
||||
|
||||
const mbr = disk?.bootMbr512
|
||||
if (!mbr || !(mbr instanceof Uint8Array) || mbr.length !== 512) return
|
||||
|
||||
const cap = disk.seedCapabilityInfo
|
||||
const hasUsableWords =
|
||||
cap &&
|
||||
typeof cap === 'object' &&
|
||||
!('error' in cap && cap.error) &&
|
||||
getKernelCapabilityWords(cap)
|
||||
if (hasUsableWords) return
|
||||
|
||||
const words = buildStockKernelCapabilityWords(
|
||||
BARE_OS_KERNEL_FEATURES_STOCK_WORD_PRIMARY
|
||||
)
|
||||
|
||||
const partial =
|
||||
cap && typeof cap === 'object' && !('error' in cap && cap.error)
|
||||
? /** @type {Record<string, unknown>} */ (cap)
|
||||
: null
|
||||
const fromPartial =
|
||||
partial && typeof partial.imageTipId === 'string'
|
||||
? String(partial.imageTipId).trim()
|
||||
: ''
|
||||
|
||||
const tip =
|
||||
String(env?.BARE_OS_SEED_IMAGE_TIP_ID ?? '').trim() ||
|
||||
String(env?.BARE_OS_PEER_SEED_ADVERTISE_IMAGE_TIP_ID ?? '').trim() ||
|
||||
String(env?.BARE_OS_PEER_SEED_IMAGE_TIP_ID ?? '').trim() ||
|
||||
fromPartial
|
||||
|
||||
const base = partial
|
||||
? {
|
||||
...partial,
|
||||
[BARE_OS_KERNEL_CAPABILITY_WORDS_JSON_KEY]: words
|
||||
}
|
||||
: {
|
||||
doc: BARE_OS_KERNEL_FEATURE_BITS_DOC,
|
||||
featureBitsDoc: BARE_OS_KERNEL_FEATURE_BITS_DOC,
|
||||
[BARE_OS_KERNEL_CAPABILITY_WORDS_JSON_KEY]: words,
|
||||
kernelCapabilityWireVersion: BARE_OS_KERNEL_CAPABILITY_WIRE_VERSION,
|
||||
protocolPackageVersion: BARE_OS_PROTOCOL_PACKAGE_VERSION,
|
||||
protocol: PROTOCOL_NAME,
|
||||
role: 'seeder',
|
||||
note:
|
||||
'Synthetic capabilities: stock kernelCapabilityWords after successful swarm boot (publisher snapshot missing or incomplete).'
|
||||
}
|
||||
|
||||
if (typeof base.doc !== 'string')
|
||||
base.doc = BARE_OS_KERNEL_FEATURE_BITS_DOC
|
||||
if (typeof base.featureBitsDoc !== 'string') {
|
||||
base.featureBitsDoc =
|
||||
typeof base.doc === 'string' ? base.doc : BARE_OS_KERNEL_FEATURE_BITS_DOC
|
||||
}
|
||||
if (typeof base.kernelCapabilityWireVersion !== 'number') {
|
||||
base.kernelCapabilityWireVersion = BARE_OS_KERNEL_CAPABILITY_WIRE_VERSION
|
||||
}
|
||||
if (typeof base.protocolPackageVersion !== 'string') {
|
||||
base.protocolPackageVersion = BARE_OS_PROTOCOL_PACKAGE_VERSION
|
||||
}
|
||||
if (typeof base.protocol !== 'string') base.protocol = PROTOCOL_NAME
|
||||
if (typeof base.role !== 'string') base.role = 'seeder'
|
||||
|
||||
delete base.error
|
||||
|
||||
if (tip) base.imageTipId = tip
|
||||
else delete base.imageTipId
|
||||
|
||||
disk.seedCapabilityInfo = /** @type {Record<string, unknown>} */ (base)
|
||||
}
|
||||
|
||||
export function buildPeerSeedSnapshots(disk) {
|
||||
return {
|
||||
replication_status: disk.seedReplicationStatus ?? null,
|
||||
manifest_hints: disk.seedManifestHints ?? null,
|
||||
peer_health: disk.seedPeerHealth ?? null,
|
||||
staging_slot: disk.seedStagingSlot ?? null,
|
||||
replication_queue: disk.seedReplicationQueue ?? null,
|
||||
capability_attestation: disk.seedCapabilityAttestation ?? null,
|
||||
mbr_layout: disk.seedMbrLayout ?? null,
|
||||
snapshot_hints: disk.seedSnapshotHints ?? null,
|
||||
peer_firewall_stats: disk.seedPeerFirewallStats ?? null,
|
||||
replication_plan: disk.seedReplicationPlan ?? null,
|
||||
dht_bootstrap_hint: disk.seedDhtBootstrapHint ?? null,
|
||||
snapshot_chain: disk.seedSnapshotChain ?? null,
|
||||
mirror_compaction_hint: disk.seedMirrorCompactionHint ?? null,
|
||||
updater_state: disk.seedUpdaterState ?? null,
|
||||
blind_peer_topology_v2: disk.seedBlindPeerTopologyV2 ?? null,
|
||||
compact_ping: disk.seedCompactPing ?? null,
|
||||
corestore_stats: disk.seedCorestoreStats ?? null,
|
||||
snapshot_manifest_slice: disk.seedSnapshotManifestSlice ?? null,
|
||||
mirror_drive_hint_v2: disk.seedMirrorDriveHintV2 ?? null,
|
||||
hrpc_registry_summary: disk.seedHrpcRegistrySummary ?? null,
|
||||
protomux_capability_ad: disk.seedProtomuxCapabilityAd ?? null,
|
||||
dht_address_book: disk.seedDhtAddressBook ?? null,
|
||||
replication_throttle_hint: disk.seedReplicationThrottleHint ?? null,
|
||||
bundlebee_stage: disk.seedBundlebeeStage ?? null,
|
||||
http_dht_proxy_hint: disk.seedHttpDhtProxyHint ?? null,
|
||||
protomux_rpc_pool_hint: disk.seedProtomuxRpcPoolHint ?? null,
|
||||
hyperblob_store_hint: disk.seedHyperblobStoreHint ?? null,
|
||||
signing_request_queue_hint: disk.seedSigningRequestQueueHint ?? null,
|
||||
core_storage_layout_hint: disk.seedCoreStorageLayoutHint ?? null,
|
||||
mirror_drive_compaction_v3: disk.seedMirrorDriveCompactionV3 ?? null,
|
||||
bundlebee_cli_stage: disk.seedBundlebeeCliStage ?? null,
|
||||
ready_guard_v2: disk.seedReadyGuardV2 ?? null,
|
||||
blind_relay_circuit_hint: disk.seedBlindRelayCircuitHint ?? null,
|
||||
http_dht_proxy_routes: disk.seedHttpDhtProxyRoutes ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* disk: import('./swarm-disk.js').SwarmDisk
|
||||
* systemRevision: Readonly<{ currentId?: string; pendingId?: string; slot?: string }> | null | undefined
|
||||
* env: Record<string, string | undefined> | null | undefined
|
||||
* }} args
|
||||
* @returns {{ ok: true } | { ok: false, reason: string }}
|
||||
*/
|
||||
export function computePeerSystemSeedEligibility(args) {
|
||||
const { disk, systemRevision, env } = args
|
||||
if (!peerSystemSeedEnvEnabled(env)) {
|
||||
return { ok: false, reason: 'env_disabled' }
|
||||
}
|
||||
const cap = disk.seedCapabilityInfo
|
||||
if (!cap || typeof cap !== 'object') {
|
||||
return { ok: false, reason: 'no_seed_capability_info' }
|
||||
}
|
||||
if ('error' in cap && cap.error) {
|
||||
return { ok: false, reason: 'seed_capability_error' }
|
||||
}
|
||||
if (cap.role === 'offline-lkg') {
|
||||
return { ok: false, reason: 'offline_lkg' }
|
||||
}
|
||||
const words = getKernelCapabilityWords(cap)
|
||||
if (!words) {
|
||||
return { ok: false, reason: 'no_kernel_capability_words' }
|
||||
}
|
||||
const mbr = disk.bootMbr512
|
||||
if (!mbr || !(mbr instanceof Uint8Array) || mbr.length !== 512) {
|
||||
return { ok: false, reason: 'no_boot_mbr' }
|
||||
}
|
||||
const drive = disk.drive
|
||||
if (!drive || typeof drive.id === 'undefined') {
|
||||
return { ok: false, reason: 'no_system_drive' }
|
||||
}
|
||||
const primaryHex = Array.isArray(disk.mbrKeysHex) ? disk.mbrKeysHex[0] : ''
|
||||
const idHex = b4a.toString(drive.id, 'hex').toLowerCase()
|
||||
if (!primaryHex || idHex !== String(primaryHex).toLowerCase()) {
|
||||
return { ok: false, reason: 'drive_key_not_mbr_primary' }
|
||||
}
|
||||
|
||||
const wantTip = String(env?.BARE_OS_PEER_SEED_IMAGE_TIP_ID ?? '').trim()
|
||||
if (wantTip) {
|
||||
const got = String(
|
||||
/** @type {Record<string, unknown>} */ (cap).imageTipId ?? ''
|
||||
).trim()
|
||||
if (got !== wantTip) {
|
||||
return { ok: false, reason: 'image_tip_mismatch' }
|
||||
}
|
||||
}
|
||||
|
||||
const wantRev = String(env?.BARE_OS_PEER_SEED_REQUIRE_REVISION_ID ?? '').trim()
|
||||
if (wantRev) {
|
||||
const cur = String(systemRevision?.currentId ?? '').trim()
|
||||
if (cur !== wantRev) {
|
||||
return { ok: false, reason: 'system_revision_mismatch' }
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Validated Protomux channel alias registry: logical name → actual channel name.
|
||||
* Each actual channel name may be claimed by at most one logical alias at a time.
|
||||
* Re-registering the same logical with a different actual overwrites and records history.
|
||||
*/
|
||||
|
||||
const NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,127}$/
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
export function assertValidProtomuxAliasName(s) {
|
||||
const t = String(s || '').trim()
|
||||
if (!t || t.length > 128) {
|
||||
throw new Error('protomux alias: name length must be 1..128')
|
||||
}
|
||||
if (!NAME_RE.test(t)) {
|
||||
throw new Error(
|
||||
'protomux alias: name must match ' + NAME_RE.source + ' (logical/actual)'
|
||||
)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
export function createBareOsProtomuxAliasRegistry() {
|
||||
/** @type {Map<string, string>} */
|
||||
const logicalToActual = new Map()
|
||||
/** @type {Record<string, string>} */
|
||||
const legacyMap = Object.create(null)
|
||||
/** @type {{ atMs: number, logical: string, previousActual: string | null, actual: string }[]} */
|
||||
const changeLog = []
|
||||
/** @type {Map<string, string[]>} */
|
||||
const actualToLogicals = new Map()
|
||||
|
||||
function reindexActual(logical, oldAct, newAct) {
|
||||
if (oldAct) {
|
||||
const arr = actualToLogicals.get(oldAct)
|
||||
if (arr) {
|
||||
const i = arr.indexOf(logical)
|
||||
if (i >= 0) arr.splice(i, 1)
|
||||
if (!arr.length) actualToLogicals.delete(oldAct)
|
||||
}
|
||||
}
|
||||
if (newAct) {
|
||||
const arr = actualToLogicals.get(newAct) || []
|
||||
if (!arr.includes(logical)) arr.push(logical)
|
||||
actualToLogicals.set(newAct, arr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* @param {string} logicalName
|
||||
* @param {string} actualName
|
||||
* @returns {{ ok: boolean, conflict?: string, replaced?: boolean }}
|
||||
*/
|
||||
register(logicalName, actualName) {
|
||||
const a = assertValidProtomuxAliasName(logicalName)
|
||||
const b = assertValidProtomuxAliasName(actualName)
|
||||
const prev = logicalToActual.get(a) || null
|
||||
if (prev === b) {
|
||||
return { ok: true, replaced: false }
|
||||
}
|
||||
const others = actualToLogicals.get(b) || []
|
||||
if (others.some((o) => o !== a)) {
|
||||
return {
|
||||
ok: false,
|
||||
conflict: `actual ${b} already mapped from logical: ${others.filter((o) => o !== a).join(', ')}`
|
||||
}
|
||||
}
|
||||
reindexActual(a, prev, b)
|
||||
logicalToActual.set(a, b)
|
||||
legacyMap[a] = b
|
||||
changeLog.push({
|
||||
atMs: Date.now(),
|
||||
logical: a,
|
||||
previousActual: prev,
|
||||
actual: b
|
||||
})
|
||||
if (changeLog.length > 256) changeLog.splice(0, changeLog.length - 256)
|
||||
return { ok: true, replaced: Boolean(prev) }
|
||||
},
|
||||
|
||||
get(logicalName) {
|
||||
const a = String(logicalName || '').trim()
|
||||
return logicalToActual.get(a) || null
|
||||
},
|
||||
|
||||
snapshot() {
|
||||
const aliases = {}
|
||||
for (const [k, v] of logicalToActual.entries()) aliases[k] = v
|
||||
return {
|
||||
schema: 2,
|
||||
aliases,
|
||||
changeLogTail: changeLog.slice(-32),
|
||||
reverseIndex: Object.fromEntries(
|
||||
[...actualToLogicals.entries()].map(([act, logs]) => [act, [...logs]])
|
||||
),
|
||||
atMs: Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
/** @returns {Record<string, string>} same object reference; updated on each register */
|
||||
legacyMapView() {
|
||||
return legacyMap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* `/proc/bare_os/protomux_extensions.json` — policy-gated operator view of Protomux
|
||||
* channel hints for kernel extensions (no wire secrets).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function protomuxExtensionsRegistryExposed(env) {
|
||||
const v = String(env?.BARE_OS_PROC_PROTOMUX_EXTENSIONS_REGISTRY ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return v === '1' || v === 'true' || v === 'yes'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* env?: Record<string, string | undefined> | null,
|
||||
* registrySnapshot?: Record<string, unknown> | null,
|
||||
* ctxApiVersion?: string
|
||||
* }} opts
|
||||
*/
|
||||
export function buildBareOsProtomuxExtensionsProcJson(opts = {}) {
|
||||
const env = opts.env && typeof opts.env === 'object' ? opts.env : {}
|
||||
const now = Date.now()
|
||||
if (!protomuxExtensionsRegistryExposed(env)) {
|
||||
return {
|
||||
schema: 1,
|
||||
exposed: false,
|
||||
note:
|
||||
'Set BARE_OS_PROC_PROTOMUX_EXTENSIONS_REGISTRY=1 to expose extension mux hints (alias registry snapshot + stable logical channel names).',
|
||||
atMs: now
|
||||
}
|
||||
}
|
||||
const snap =
|
||||
opts.registrySnapshot && typeof opts.registrySnapshot === 'object'
|
||||
? opts.registrySnapshot
|
||||
: {}
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
guestContractVersion: 1,
|
||||
exposed: true,
|
||||
ctxApiVersion: String(opts.ctxApiVersion || ''),
|
||||
registry: snap,
|
||||
muxWirePackage: 'protomux',
|
||||
muxWireMajor: 3,
|
||||
channelLifecycle: {
|
||||
schema: 1,
|
||||
states: ['paired', 'opening', 'open', 'closing', 'closed'],
|
||||
backpressureEvent: 'bare-os:protomux-backpressure',
|
||||
note:
|
||||
'Guest-facing contract: logical channels follow Protomux createChannel/open/close; framed streams must preserve message boundaries (length-prefixed / secret-stream).'
|
||||
},
|
||||
holepunchCompanionPackages: {
|
||||
rpcPool: 'protomux-rpc-client-pool',
|
||||
note:
|
||||
'Logical npm names in the Holepunch org; stock guest does not bundle them unless merged via ctx.bare.'
|
||||
},
|
||||
extensionLogicalChannels: [
|
||||
{
|
||||
name: 'bare-os-kernel-ext-handshake',
|
||||
note: 'Reserved logical label for extension pairing over Protomux-framed streams (P2P).'
|
||||
},
|
||||
{
|
||||
name: 'bare-os-extension-telemetry',
|
||||
note: 'Optional NDJSON-shaped diagnostic channel; host must open explicitly.'
|
||||
},
|
||||
{
|
||||
name: 'bare-os-cap-v1',
|
||||
protocol: 'bare-os-cap-v1',
|
||||
envGate: 'BARE_OS_PROTOMUX_CAP_CHANNEL',
|
||||
note: 'Opaque buffer side channel; application-layer auth required.'
|
||||
}
|
||||
],
|
||||
wireReference: '/proc/bare_os/protomux.json',
|
||||
atMs: now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Lexicographic replication priority tiers (boot-critical paths first).
|
||||
* @param {string[]} paths
|
||||
*/
|
||||
export function prioritizeReplicationPaths(paths) {
|
||||
const boot = ['/boot/', '/etc/bare-os/', '/lib/bare-os/', '/bin/']
|
||||
/** @type {{ path: string, tier: number }[]} */
|
||||
const scored = []
|
||||
for (const p of paths) {
|
||||
const path = String(p || '').replace(/\\/g, '/')
|
||||
let tier = 99
|
||||
for (let i = 0; i < boot.length; i++) {
|
||||
if (path.startsWith(boot[i])) {
|
||||
tier = i
|
||||
break
|
||||
}
|
||||
}
|
||||
scored.push({ path, tier })
|
||||
}
|
||||
scored.sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path))
|
||||
return scored.map((s) => s.path)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Live Hyperdrive / core length hints for `/proc/bare_os/replication` and
|
||||
* `metrics_live.replicationLive` (non-secret progress; not full per-peer throughput).
|
||||
*
|
||||
* @param {{ drive?: unknown, personalDrive?: unknown } | null | undefined} disk
|
||||
* @param {number} peerCount
|
||||
*/
|
||||
export function buildBareOsReplicationLiveSketch(disk, peerCount) {
|
||||
let systemCoreLength = null
|
||||
let personalCoreLength = null
|
||||
try {
|
||||
const c =
|
||||
disk &&
|
||||
disk.drive &&
|
||||
/** @type {{ core?: { length?: number } }} */ (disk.drive).core
|
||||
if (c && typeof c.length === 'number') systemCoreLength = c.length
|
||||
} catch {
|
||||
systemCoreLength = null
|
||||
}
|
||||
try {
|
||||
const c =
|
||||
disk &&
|
||||
disk.personalDrive &&
|
||||
/** @type {{ core?: { length?: number } }} */ (disk.personalDrive).core
|
||||
if (c && typeof c.length === 'number') personalCoreLength = c.length
|
||||
} catch {
|
||||
personalCoreLength = null
|
||||
}
|
||||
let auxiliaryDriveCount = 0
|
||||
try {
|
||||
if (disk && Array.isArray(disk.auxiliaryDrives))
|
||||
auxiliaryDriveCount = disk.auxiliaryDrives.length
|
||||
} catch {
|
||||
auxiliaryDriveCount = 0
|
||||
}
|
||||
const stallHint =
|
||||
peerCount === 0
|
||||
? 'no_peers'
|
||||
: systemCoreLength == null
|
||||
? 'length_unavailable'
|
||||
: 'ok'
|
||||
/** @type {{ schema: number, download?: number, upload?: number, peers?: number, source?: string } | null} */
|
||||
let monitorProgress = null
|
||||
try {
|
||||
const m = disk && disk.drive && typeof disk.drive.monitor === 'function'
|
||||
? disk.drive.monitor()
|
||||
: null
|
||||
if (m && typeof m === 'object') {
|
||||
const dl =
|
||||
typeof m.downloadedBlocks === 'number'
|
||||
? m.downloadedBlocks
|
||||
: typeof m.download === 'number'
|
||||
? m.download
|
||||
: null
|
||||
const ul =
|
||||
typeof m.uploadedBlocks === 'number'
|
||||
? m.uploadedBlocks
|
||||
: typeof m.upload === 'number'
|
||||
? m.upload
|
||||
: null
|
||||
const peers = typeof m.peers === 'number' ? m.peers : null
|
||||
if (dl != null || ul != null || peers != null) {
|
||||
monitorProgress = {
|
||||
schema: 1,
|
||||
...(dl != null ? { download: dl } : {}),
|
||||
...(ul != null ? { upload: ul } : {}),
|
||||
...(peers != null ? { peers } : {}),
|
||||
source: 'hyperdrive.monitor'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
monitorProgress = null
|
||||
}
|
||||
return {
|
||||
schema: 4,
|
||||
systemCoreLength,
|
||||
personalCoreLength,
|
||||
auxiliaryDriveCount,
|
||||
stallHint,
|
||||
...(monitorProgress ? { monitorProgress } : {}),
|
||||
corestoreSnapshotSurface: {
|
||||
schema: 1,
|
||||
operatorWorkflow:
|
||||
'Pause or quiesce writers → replicate cores → export snapshot (corestore-snapshot style). Guest **`guestSuspendResume`** remains **ENOTSUP** unless the host registers Corestore suspend/resume hooks.',
|
||||
readOnly: true
|
||||
},
|
||||
note: 'Schema 4 keeps schema 3 fields and optionally exposes monitorProgress when Hyperdrive monitor() returns counters (download/upload/peers).'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Swarm connection manager: delegates to {@link BareOsSwarmPeerPolicyEngine} for
|
||||
* peer health, scoring, backoff/ban windows, and replication scheduling hints.
|
||||
*/
|
||||
|
||||
import { BareOsSwarmPeerPolicyEngine } from './bare-os-swarm-peer-policy.js'
|
||||
|
||||
export class BareOsSwarmConnectionManager {
|
||||
/**
|
||||
* @param {import('hyperswarm').default | null} swarm
|
||||
* @param {{ env?: Record<string, string | undefined> | null }} [opts]
|
||||
*/
|
||||
constructor(swarm, opts = {}) {
|
||||
this._engine = new BareOsSwarmPeerPolicyEngine(swarm, opts)
|
||||
/** Protomux RPC pool–style reuse counter (call {@link recordRpcPoolReuse} from hot paths). */
|
||||
this._rpcPoolReuse = 0
|
||||
/** @type {Map<string, { openedAtMs: number, lastActivityAtMs: number, initiator: boolean }>} */
|
||||
this._activeConnections = new Map()
|
||||
this._duplicateDrops = 0
|
||||
/** @type {Map<'short'|'medium'|'long'|'xlong', Set<string>>} */
|
||||
this._retryBuckets = new Map([
|
||||
['short', new Set()],
|
||||
['medium', new Set()],
|
||||
['long', new Set()],
|
||||
['xlong', new Set()]
|
||||
])
|
||||
this._bucketTimer = null
|
||||
this._retryDequeued = 0
|
||||
}
|
||||
|
||||
/** Increment when a logical RPC channel is reused instead of opened fresh. */
|
||||
recordRpcPoolReuse() {
|
||||
this._rpcPoolReuse++
|
||||
}
|
||||
|
||||
get swarm() {
|
||||
return this._engine.swarm
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {boolean} ok
|
||||
* @param {{ latencyMs?: number }} [probeMeta]
|
||||
*/
|
||||
noteProbe(peerKey, ok, probeMeta) {
|
||||
this._engine.noteProbe(peerKey, ok, probeMeta)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {{ dhtAddressClass?: string } | null | undefined} [meta]
|
||||
*/
|
||||
shouldAttemptPeer(peerKey, meta) {
|
||||
return this._engine.shouldAttemptPeer(peerKey, meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
consumeReconnectBudget(peerKey) {
|
||||
this._engine.consumeReconnectBudget(peerKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {number} [amount]
|
||||
*/
|
||||
replenishReconnectBudget(peerKey, amount) {
|
||||
this._engine.replenishReconnectBudget(peerKey, amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
score(peerKey) {
|
||||
return this._engine.score(peerKey)
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const base = this._engine.snapshot()
|
||||
return {
|
||||
...base,
|
||||
protomuxPoolMetrics: {
|
||||
schema: 1,
|
||||
rpcPoolReuseCount: this._rpcPoolReuse,
|
||||
note: 'Aligns with protomux-rpc-client-pool-style accounting; stock booter increments only when callers invoke recordRpcPoolReuse.'
|
||||
},
|
||||
duplicateArbitration: {
|
||||
schema: 1,
|
||||
activeConnectionCount: this._activeConnections.size,
|
||||
duplicateDrops: this._duplicateDrops,
|
||||
note: 'Deterministic tie-break: keep older open; when equal age keep initiator.'
|
||||
},
|
||||
groupedRetry: {
|
||||
schema: 1,
|
||||
queued: {
|
||||
short: this._retryBuckets.get('short')?.size || 0,
|
||||
medium: this._retryBuckets.get('medium')?.size || 0,
|
||||
long: this._retryBuckets.get('long')?.size || 0,
|
||||
xlong: this._retryBuckets.get('xlong')?.size || 0
|
||||
},
|
||||
dequeuedTotal: this._retryDequeued
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} peerKeys
|
||||
*/
|
||||
rankPeers(peerKeys) {
|
||||
return this._engine.rankPeers(peerKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic duplicate arbitration. Returns true when caller should keep the incoming edge.
|
||||
* @param {string} peerKey
|
||||
* @param {{ initiator?: boolean }} [incoming]
|
||||
*/
|
||||
shouldAcceptConnection(peerKey, incoming = {}) {
|
||||
const key = String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
const now = Date.now()
|
||||
const cur = this._activeConnections.get(key)
|
||||
if (!cur) return true
|
||||
const incomingInitiator = incoming.initiator === true
|
||||
if (now - cur.lastActivityAtMs <= 15000) {
|
||||
this._duplicateDrops++
|
||||
return false
|
||||
}
|
||||
if (cur.initiator && !incomingInitiator) {
|
||||
this._duplicateDrops++
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {{ initiator?: boolean }} [meta]
|
||||
*/
|
||||
markConnectionOpen(peerKey, meta = {}) {
|
||||
const key = String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
const now = Date.now()
|
||||
this._activeConnections.set(key, {
|
||||
openedAtMs: now,
|
||||
lastActivityAtMs: now,
|
||||
initiator: meta.initiator === true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
markConnectionClosed(peerKey) {
|
||||
const key = String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
this._activeConnections.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
markConnectionActivity(peerKey) {
|
||||
const key = String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
const row = this._activeConnections.get(key)
|
||||
if (!row) return
|
||||
row.lastActivityAtMs = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue peer for grouped retry scheduling.
|
||||
* @param {string} peerKey
|
||||
* @param {'short'|'medium'|'long'|'xlong'} [tier]
|
||||
*/
|
||||
queueRetry(peerKey, tier = 'short') {
|
||||
const k = String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
const t = this._retryBuckets.get(tier)
|
||||
if (!t) return
|
||||
t.add(k)
|
||||
if (this._bucketTimer) return
|
||||
this._bucketTimer = setInterval(() => {
|
||||
for (const bucket of this._retryBuckets.values()) {
|
||||
const first = bucket.values().next()
|
||||
if (!first.done) {
|
||||
bucket.delete(first.value)
|
||||
this._retryDequeued++
|
||||
break
|
||||
}
|
||||
}
|
||||
const allEmpty = [...this._retryBuckets.values()].every((b) => b.size === 0)
|
||||
if (allEmpty && this._bucketTimer) {
|
||||
clearInterval(this._bucketTimer)
|
||||
this._bucketTimer = null
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger synthetic keepalive probe accounting for all active peers.
|
||||
* @param {boolean} ok
|
||||
* @param {{ latencyMs?: number }} [meta]
|
||||
*/
|
||||
emitKeepaliveProbeSweep(ok, meta = {}) {
|
||||
for (const key of this._activeConnections.keys()) {
|
||||
this.noteProbe(key, ok, {
|
||||
latencyMs: meta.latencyMs,
|
||||
failClass: ok ? undefined : 'timeout',
|
||||
transportClass: 'tcp'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Swarm session lifecycle labels for operators (non-authoritative; driven by env + peer count).
|
||||
* @param {Record<string, unknown> | null | undefined} env
|
||||
* @param {number} peerCount
|
||||
*/
|
||||
export function bareOsSwarmLifecycleSnapshot(env, peerCount) {
|
||||
const peers = Math.max(0, Math.floor(peerCount))
|
||||
const syncing =
|
||||
env &&
|
||||
(env.BARE_OS_SWARM_SYNCING === '1' || env.BARE_OS_SWARM_SYNCING === 'true')
|
||||
const degraded =
|
||||
env &&
|
||||
(env.BARE_OS_SWARM_DEGRADED === '1' || env.BARE_OS_SWARM_DEGRADED === 'true')
|
||||
/** @type {'discovering' | 'syncing' | 'steady' | 'degraded'} */
|
||||
let state = 'steady'
|
||||
if (degraded) state = 'degraded'
|
||||
else if (syncing) state = 'syncing'
|
||||
else if (peers === 0) state = 'discovering'
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let dhtActiveQueries
|
||||
/** @type {number | undefined} */
|
||||
let udxEphemeralPort
|
||||
const raw = env && env.BARE_OS_HYPERDHT_STATS_JSON
|
||||
if (raw != null && String(raw).trim()) {
|
||||
try {
|
||||
const j = JSON.parse(String(raw))
|
||||
if (typeof j.activeQueries === 'number') dhtActiveQueries = j.activeQueries
|
||||
if (typeof j.udpPort === 'number') udxEphemeralPort = j.udpPort
|
||||
} catch {
|
||||
/* ignore invalid operator JSON */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 1,
|
||||
state,
|
||||
peerCount: peers,
|
||||
note:
|
||||
'Lifecycle is derived from BARE_OS_SWARM_* env hints and live peer count; host may override via env.',
|
||||
hyperdhtSketch: {
|
||||
schema: 1,
|
||||
note: 'Capped non-secret hints from BARE_OS_HYPERDHT_STATS_JSON when the host sets it.',
|
||||
dhtActiveQueries,
|
||||
udxEphemeralPort
|
||||
},
|
||||
atMs: Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Production swarm peer policy: EWMA latency, success/fail scoring, exponential backoff,
|
||||
* and temporary ban windows for abusive or failing peers.
|
||||
*
|
||||
* Tunable via optional env (read by caller): BARE_OS_SWARM_BAN_FAIL_THRESHOLD,
|
||||
* BARE_OS_SWARM_BAN_MS_INITIAL, BARE_OS_SWARM_BAN_MS_MAX, BARE_OS_SWARM_EWMA_ALPHA.
|
||||
* Optional **`BARE_OS_PEER_ALLOWLIST_HEX`** is enforced here so swarm scheduling aligns with
|
||||
* **`ctx.bareOsEvaluatePeerAdmission`** (deny before reconnect budget is spent).
|
||||
*/
|
||||
|
||||
import { evaluateBareOsPeerAdmission } from './bare-os-peer-admission.js'
|
||||
|
||||
const DEFAULT_FAILS_BEFORE_BAN = 4
|
||||
const DEFAULT_BAN_MS_INITIAL = 5000
|
||||
const DEFAULT_BAN_MS_MAX = 300000
|
||||
const DEFAULT_EWMA_ALPHA = 0.25
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
*/
|
||||
function readSwarmPolicyEnv(env) {
|
||||
const fails = Number.parseInt(
|
||||
String(env?.BARE_OS_SWARM_BAN_FAIL_THRESHOLD || ''),
|
||||
10
|
||||
)
|
||||
const ban0 = Number.parseInt(
|
||||
String(env?.BARE_OS_SWARM_BAN_MS_INITIAL || ''),
|
||||
10
|
||||
)
|
||||
const banMax = Number.parseInt(
|
||||
String(env?.BARE_OS_SWARM_BAN_MS_MAX || ''),
|
||||
10
|
||||
)
|
||||
const alpha = Number.parseFloat(
|
||||
String(env?.BARE_OS_SWARM_EWMA_ALPHA || '')
|
||||
)
|
||||
return {
|
||||
failsBeforeBan:
|
||||
Number.isFinite(fails) && fails >= 1 ? Math.min(32, fails) : DEFAULT_FAILS_BEFORE_BAN,
|
||||
banMsInitial:
|
||||
Number.isFinite(ban0) && ban0 >= 500
|
||||
? Math.min(DEFAULT_BAN_MS_MAX, ban0)
|
||||
: DEFAULT_BAN_MS_INITIAL,
|
||||
banMsMax:
|
||||
Number.isFinite(banMax) && banMax >= 1000
|
||||
? Math.min(3600000, banMax)
|
||||
: DEFAULT_BAN_MS_MAX,
|
||||
ewmaAlpha:
|
||||
Number.isFinite(alpha) && alpha > 0 && alpha <= 1
|
||||
? alpha
|
||||
: DEFAULT_EWMA_ALPHA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* ok: number,
|
||||
* fail: number,
|
||||
* lastMs: number,
|
||||
* consecutiveFail: number,
|
||||
* banCount: number,
|
||||
* banUntilMs: number,
|
||||
* ewmaLatencyMs: number,
|
||||
* lastLatencyMs: number | null,
|
||||
* reconnectBudget: number,
|
||||
* earliestRetryAtMs: number,
|
||||
* retryTier: 'none' | 'short' | 'medium' | 'long' | 'xlong',
|
||||
* failByClass: Record<string, number>,
|
||||
* transportByClass: Record<string, number>
|
||||
* }} PeerState
|
||||
*/
|
||||
|
||||
export class BareOsSwarmPeerPolicyEngine {
|
||||
/**
|
||||
* @param {import('hyperswarm').default | null} swarm
|
||||
* @param {{ env?: Record<string, string | undefined> | null }} [opts]
|
||||
*/
|
||||
constructor(swarm, opts = {}) {
|
||||
this.swarm = swarm
|
||||
this._env = opts.env && typeof opts.env === 'object' ? opts.env : null
|
||||
this._cfg = readSwarmPolicyEnv(opts.env)
|
||||
/** @type {Map<string, PeerState>} */
|
||||
this._peers = new Map()
|
||||
/** @type {number} */
|
||||
this._globalReconnectBudget = 256
|
||||
/** @type {{ start: number, n: number } | null} */
|
||||
this._attemptBurstWindow = null
|
||||
}
|
||||
|
||||
_key(peerKey) {
|
||||
return String(peerKey || '').slice(0, 128) || 'unknown'
|
||||
}
|
||||
|
||||
_getOrCreate(k) {
|
||||
let st = this._peers.get(k)
|
||||
if (!st) {
|
||||
st = {
|
||||
ok: 0,
|
||||
fail: 0,
|
||||
lastMs: 0,
|
||||
consecutiveFail: 0,
|
||||
banCount: 0,
|
||||
banUntilMs: 0,
|
||||
ewmaLatencyMs: 0,
|
||||
lastLatencyMs: null,
|
||||
reconnectBudget: 32,
|
||||
earliestRetryAtMs: 0,
|
||||
retryTier: 'none',
|
||||
failByClass: {},
|
||||
transportByClass: {}
|
||||
}
|
||||
this._peers.set(k, st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
_retryDelayFor(st) {
|
||||
const n = Math.max(0, Number(st.consecutiveFail) || 0)
|
||||
const tier =
|
||||
n >= 9 ? 'xlong' : n >= 6 ? 'long' : n >= 4 ? 'medium' : n >= 2 ? 'short' : 'none'
|
||||
const base =
|
||||
tier === 'xlong'
|
||||
? 15000
|
||||
: tier === 'long'
|
||||
? 8000
|
||||
: tier === 'medium'
|
||||
? 3000
|
||||
: tier === 'short'
|
||||
? 800
|
||||
: 0
|
||||
const jitter = base > 0 ? Math.floor(base * 0.3 * Math.random()) : 0
|
||||
return { tier, delayMs: base + jitter }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {boolean} ok
|
||||
* @param {{ latencyMs?: number, failClass?: string, transportClass?: string }} [probeMeta]
|
||||
*/
|
||||
noteProbe(peerKey, ok, probeMeta = {}) {
|
||||
const k = this._key(peerKey)
|
||||
const st = this._getOrCreate(k)
|
||||
const now = Date.now()
|
||||
st.lastMs = now
|
||||
if (ok) {
|
||||
const tc = String(probeMeta.transportClass || 'unknown')
|
||||
.trim()
|
||||
.slice(0, 24) || 'unknown'
|
||||
st.transportByClass[tc] = (st.transportByClass[tc] || 0) + 1
|
||||
st.ok++
|
||||
st.consecutiveFail = 0
|
||||
st.retryTier = 'none'
|
||||
st.earliestRetryAtMs = 0
|
||||
const lat =
|
||||
typeof probeMeta.latencyMs === 'number' && Number.isFinite(probeMeta.latencyMs)
|
||||
? Math.max(0, probeMeta.latencyMs)
|
||||
: null
|
||||
st.lastLatencyMs = lat
|
||||
if (lat != null) {
|
||||
const a = this._cfg.ewmaAlpha
|
||||
st.ewmaLatencyMs =
|
||||
st.ewmaLatencyMs === 0
|
||||
? lat
|
||||
: a * lat + (1 - a) * st.ewmaLatencyMs
|
||||
}
|
||||
if (now >= st.banUntilMs) {
|
||||
st.banUntilMs = 0
|
||||
}
|
||||
} else {
|
||||
st.fail++
|
||||
st.consecutiveFail++
|
||||
const failClass = String(probeMeta.failClass || 'unknown')
|
||||
.trim()
|
||||
.slice(0, 32) || 'unknown'
|
||||
st.failByClass[failClass] = (st.failByClass[failClass] || 0) + 1
|
||||
const retry = this._retryDelayFor(st)
|
||||
st.retryTier = retry.tier
|
||||
st.earliestRetryAtMs = now + retry.delayMs
|
||||
if (st.consecutiveFail >= this._cfg.failsBeforeBan) {
|
||||
st.banCount++
|
||||
const base = Math.min(
|
||||
this._cfg.banMsMax,
|
||||
this._cfg.banMsInitial * Math.pow(2, Math.min(8, st.banCount - 1))
|
||||
)
|
||||
const jitter = Math.floor(base * 0.2 * Math.random())
|
||||
st.banUntilMs = now + base + jitter
|
||||
st.consecutiveFail = 0
|
||||
st.retryTier = 'xlong'
|
||||
st.earliestRetryAtMs = st.banUntilMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
* @param {{ dhtAddressClass?: string } | null | undefined} [meta]
|
||||
* @returns {boolean} false when peer is in ban window or reconnect budget exhausted
|
||||
*/
|
||||
shouldAttemptPeer(peerKey, meta) {
|
||||
const k = this._key(peerKey)
|
||||
if (this._env) {
|
||||
const adm = evaluateBareOsPeerAdmission(this._env, k, meta)
|
||||
if (adm.verdict === 'deny') return false
|
||||
const burstMax = Number.parseInt(
|
||||
String(this._env.BARE_OS_SWARM_ATTEMPT_BURST_PER_SEC || ''),
|
||||
10
|
||||
)
|
||||
if (Number.isFinite(burstMax) && burstMax > 0) {
|
||||
const now = Date.now()
|
||||
const w = this._attemptBurstWindow
|
||||
if (w && now - w.start <= 1000 && w.n >= burstMax) return false
|
||||
}
|
||||
}
|
||||
const st = this._peers.get(k)
|
||||
const now = Date.now()
|
||||
if (!st) return true
|
||||
if (this._globalReconnectBudget <= 0) return false
|
||||
if (st.banUntilMs > now) return false
|
||||
if (st.earliestRetryAtMs > now) return false
|
||||
if (st.reconnectBudget <= 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when initiating a connection attempt (consumes budget).
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
consumeReconnectBudget(peerKey) {
|
||||
const k = this._key(peerKey)
|
||||
const st = this._getOrCreate(k)
|
||||
if (st.reconnectBudget > 0) st.reconnectBudget--
|
||||
if (this._globalReconnectBudget > 0) this._globalReconnectBudget--
|
||||
if (this._env) {
|
||||
const burstMax = Number.parseInt(
|
||||
String(this._env.BARE_OS_SWARM_ATTEMPT_BURST_PER_SEC || ''),
|
||||
10
|
||||
)
|
||||
if (Number.isFinite(burstMax) && burstMax > 0) {
|
||||
const now = Date.now()
|
||||
if (
|
||||
!this._attemptBurstWindow ||
|
||||
now - this._attemptBurstWindow.start > 1000
|
||||
) {
|
||||
this._attemptBurstWindow = { start: now, n: 0 }
|
||||
}
|
||||
this._attemptBurstWindow.n++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replenish reconnect budget periodically (e.g. on successful session).
|
||||
* @param {string} peerKey
|
||||
* @param {number} [amount]
|
||||
*/
|
||||
replenishReconnectBudget(peerKey, amount = 8) {
|
||||
const k = this._key(peerKey)
|
||||
const st = this._getOrCreate(k)
|
||||
st.reconnectBudget = Math.min(32, st.reconnectBudget + amount)
|
||||
this._globalReconnectBudget = Math.min(256, this._globalReconnectBudget + amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher is better for replication throttle hints (0..1 scale, may go slightly negative).
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
score(peerKey) {
|
||||
const k = this._key(peerKey)
|
||||
const st = this._peers.get(k)
|
||||
if (!st) return 0
|
||||
const total = st.ok + st.fail
|
||||
if (!total) return 0
|
||||
const successRate = st.ok / total
|
||||
const failPenalty = Math.min(0.5, st.fail * 0.05)
|
||||
const protocolPenalty = Math.min(0.25, (st.failByClass.protocol || 0) * 0.03)
|
||||
const transportReward = Math.min(
|
||||
0.15,
|
||||
((st.transportByClass.ipc || 0) +
|
||||
(st.transportByClass.tcp || 0) * 0.7 +
|
||||
(st.transportByClass.udx || 0) * 0.5) *
|
||||
0.01
|
||||
)
|
||||
const policyPenalty = Math.min(0.2, (st.failByClass.policy || 0) * 0.02)
|
||||
const timeoutPenalty = Math.min(0.15, (st.failByClass.timeout || 0) * 0.01)
|
||||
const now = Date.now()
|
||||
const banned = st.banUntilMs > now ? 0.35 : 0
|
||||
const lat = st.ewmaLatencyMs
|
||||
const latPenalty =
|
||||
lat > 0 ? Math.min(0.25, Math.log10(1 + lat / 50) * 0.08) : 0
|
||||
return (
|
||||
successRate +
|
||||
transportReward -
|
||||
failPenalty -
|
||||
protocolPenalty -
|
||||
policyPenalty -
|
||||
timeoutPenalty -
|
||||
banned -
|
||||
latPenalty
|
||||
)
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const now = Date.now()
|
||||
return {
|
||||
schema: 2,
|
||||
cfg: { ...this._cfg },
|
||||
attemptBurstEnv: 'BARE_OS_SWARM_ATTEMPT_BURST_PER_SEC',
|
||||
attemptBurstWindow: this._attemptBurstWindow
|
||||
? { ...this._attemptBurstWindow }
|
||||
: null,
|
||||
globalReconnectBudget: this._globalReconnectBudget,
|
||||
peers: [...this._peers.entries()].map(([id, st]) => ({
|
||||
id,
|
||||
...st,
|
||||
banned: st.banUntilMs > now,
|
||||
banRemainingMs: st.banUntilMs > now ? st.banUntilMs - now : 0,
|
||||
retryWaitMs:
|
||||
st.earliestRetryAtMs > now ? st.earliestRetryAtMs - now : 0
|
||||
})),
|
||||
atMs: now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank peer keys by score descending (for replication scheduling hints).
|
||||
* @param {string[]} peerKeys
|
||||
*/
|
||||
rankPeers(peerKeys) {
|
||||
return [...new Set(peerKeys.map((p) => this._key(p)))].sort(
|
||||
(a, b) => this.score(b) - this.score(a)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Per-file JSON for /proc/bare_os/swarm_*_status.json (and internal bare_os_swarm_*_status).
|
||||
* Surfaces always; “absent file” is not a mode — use `status` + `note` when a layer is not wired.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} fileKey e.g. `bare_os_swarm_replication_status`
|
||||
* @param {{
|
||||
* disk: { peers?: { size: number } | null, [k: string]: unknown } | null,
|
||||
* shellEnv: Record<string, string | undefined>,
|
||||
* swarmConnectionManager: { snapshot?: () => unknown } | null,
|
||||
* buildBareOsReplicationLiveSketch: (d: unknown, n: number) => unknown
|
||||
* }} d
|
||||
* @returns {string} JSON + newline
|
||||
*/
|
||||
export function bareOsFormatSwarmSubsystemProcJson(fileKey, d) {
|
||||
const disk = d.disk
|
||||
const shellEnv = d.shellEnv || {}
|
||||
const peers = disk && disk.peers && typeof disk.peers.size === 'number'
|
||||
? disk.peers.size
|
||||
: 0
|
||||
const now = Date.now()
|
||||
/** @returns {unknown} */
|
||||
const snap = () => {
|
||||
try {
|
||||
const scm = d.swarmConnectionManager
|
||||
if (!scm || typeof scm.snapshot !== 'function') return null
|
||||
return scm.snapshot()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const repLive =
|
||||
typeof d.buildBareOsReplicationLiveSketch === 'function' && disk
|
||||
? d.buildBareOsReplicationLiveSketch(disk, peers)
|
||||
: null
|
||||
|
||||
switch (fileKey) {
|
||||
case 'bare_os_swarm_replication_status':
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'replication',
|
||||
peerCount: peers,
|
||||
replicationLiveSketch: repLive ?? null,
|
||||
seedReplicationStatus:
|
||||
disk &&
|
||||
disk.seedReplicationStatus &&
|
||||
typeof disk.seedReplicationStatus === 'object'
|
||||
? disk.seedReplicationStatus
|
||||
: null,
|
||||
note:
|
||||
'Focused replication slice. Full blob: /proc/bare_os/replication and metrics_live.replicationLive.'
|
||||
}) + '\n'
|
||||
)
|
||||
case 'bare_os_swarm_relay_status': {
|
||||
const raw = String(shellEnv.BARE_OS_BLIND_RELAY_TOPOLOGY_JSON || '').trim()
|
||||
let topology = null
|
||||
if (raw) {
|
||||
try {
|
||||
topology = JSON.parse(raw)
|
||||
} catch {
|
||||
topology = { parseError: true }
|
||||
}
|
||||
}
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'relay',
|
||||
status: topology ? 'env_present' : 'env_unset',
|
||||
blindRelayTopology: topology,
|
||||
envGate: 'BARE_OS_BLIND_RELAY_TOPOLOGY_JSON',
|
||||
note:
|
||||
topology
|
||||
? 'Relay topology sketch from env (same idea as swarm.blindRelayTopology).'
|
||||
: 'Set BARE_OS_BLIND_RELAY_TOPOLOGY_JSON on the host for relay sketches.'
|
||||
}) + '\n'
|
||||
)
|
||||
}
|
||||
case 'bare_os_swarm_datagrams_status':
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'datagrams',
|
||||
status: 'logical',
|
||||
guestDatagramSockets: 'POSIX DGRAM bridges / USOCK when ctx wires them',
|
||||
metricsHint: 'See metrics_live.ipcTelemetry and env BARE_OS_POSIX_DGRAM_*',
|
||||
note:
|
||||
'No separate on-disk swarm datagram counters in stock booter; ipc + metrics_live carry hints.'
|
||||
}) + '\n'
|
||||
)
|
||||
case 'bare_os_swarm_connection_manager_status': {
|
||||
const s = snap()
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'connection_manager',
|
||||
snapshotPresent: !!s,
|
||||
snapshot: s,
|
||||
note:
|
||||
'BareOsSwarmConnectionManager snapshot (peer policy + protomux pool counters). No peer keys.'
|
||||
}) + '\n'
|
||||
)
|
||||
}
|
||||
case 'bare_os_swarm_key_broker_status':
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'key_broker',
|
||||
status: 'not_exposed',
|
||||
note:
|
||||
'Stock Bare OS does not expose a standalone Hyperswarm “key broker” daemon. Identity flows through Hypercore/Hyperdrive + Pear configuration.'
|
||||
}) + '\n'
|
||||
)
|
||||
case 'bare_os_swarm_holepunch_status':
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'holepunch',
|
||||
natTraversalMode: String(
|
||||
shellEnv.BARE_OS_NAT_TRAVERSAL_MODE || 'auto'
|
||||
).trim(),
|
||||
dhtAllowlistClasses: String(
|
||||
shellEnv.BARE_OS_DHT_ADDRESS_CLASS_ALLOWLIST || ''
|
||||
).trim(),
|
||||
deeperDetailPath: '/proc/bare_os/net_summary.json',
|
||||
note:
|
||||
'NAT/holepunch outcomes are summarized under net_summary.transport when the host wires stats.'
|
||||
}) + '\n'
|
||||
)
|
||||
case 'bare_os_swarm_datagram_replication_status':
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
subsystem: 'datagram_replication',
|
||||
replicationQueue:
|
||||
disk &&
|
||||
disk.seedReplicationQueue &&
|
||||
typeof disk.seedReplicationQueue === 'object'
|
||||
? disk.seedReplicationQueue
|
||||
: null,
|
||||
peerCount: peers,
|
||||
note:
|
||||
'Correlation of UDP/logical datagram paths with replication is deployment-specific; seed replication queue shown when present.'
|
||||
}) + '\n'
|
||||
)
|
||||
default:
|
||||
return (
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
atMs: now,
|
||||
fileKey,
|
||||
status: 'unknown_key',
|
||||
note:
|
||||
'Unrecognized swarm subsystem proc key; regenerate booter / report upstream.'
|
||||
}) + '\n'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Parse `BARE_OS_REPLICATION_SYNC_WINDOWS` (UTC, `HH:MM-HH:MM` ranges, comma-separated).
|
||||
* @param {Record<string, string>} env
|
||||
* @returns {{ active: boolean, windows: string[], nowUtc: string, note?: string }}
|
||||
*/
|
||||
export function computeReplicationSyncWindow(env) {
|
||||
const raw = String(env.BARE_OS_REPLICATION_SYNC_WINDOWS || '').trim()
|
||||
const now = new Date()
|
||||
const pad = (n) => (n < 10 ? '0' : '') + n
|
||||
const nowUtc = `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}`
|
||||
if (!raw) {
|
||||
return {
|
||||
active: true,
|
||||
windows: [],
|
||||
nowUtc,
|
||||
note: 'no windows; replication always allowed'
|
||||
}
|
||||
}
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
/** @type {string[]} */
|
||||
const windows = []
|
||||
let active = false
|
||||
const toMin = (h, m) => h * 60 + m
|
||||
const cur = toMin(now.getUTCHours(), now.getUTCMinutes())
|
||||
for (const p of parts) {
|
||||
const m = p.match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/)
|
||||
if (!m) continue
|
||||
const a = toMin(Number(m[1]), Number(m[2]))
|
||||
const b = toMin(Number(m[3]), Number(m[4]))
|
||||
windows.push(p)
|
||||
if (a <= b) {
|
||||
if (cur >= a && cur <= b) active = true
|
||||
} else {
|
||||
if (cur >= a || cur <= b) active = true
|
||||
}
|
||||
}
|
||||
if (windows.length === 0) {
|
||||
return {
|
||||
active: true,
|
||||
windows: [],
|
||||
nowUtc,
|
||||
note: 'no valid windows parsed; default allow'
|
||||
}
|
||||
}
|
||||
return { active, windows, nowUtc }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Pure argv helpers for hdms (no bare-crypto / hyperdrive) so brittle-node can test parsing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string[]} rest argv slice after `hdms pair`
|
||||
* @returns {{ ok: true, persist: boolean, inviteTok: string } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseHdmsPairArgv(rest) {
|
||||
let persist = true
|
||||
let inviteTok = null
|
||||
for (const x of rest) {
|
||||
if (x === '--persist') continue
|
||||
if (x === '--no-persist') {
|
||||
persist = false
|
||||
continue
|
||||
}
|
||||
if (String(x).startsWith('-')) {
|
||||
return { ok: false, error: 'hdms: unknown flag: ' + x }
|
||||
}
|
||||
if (inviteTok) {
|
||||
return { ok: false, error: 'hdms: pair expects a single invite string' }
|
||||
}
|
||||
inviteTok = x
|
||||
}
|
||||
if (rest.includes('--persist') && rest.includes('--no-persist')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'hdms: pair: use only one of --persist and --no-persist'
|
||||
}
|
||||
}
|
||||
if (!inviteTok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'hdms: Usage: hdms pair [--persist|--no-persist] <invite>'
|
||||
}
|
||||
}
|
||||
return { ok: true, persist, inviteTok }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Autopass payload for HDMS drive sharing (invite → pair → mount).
|
||||
* Kept separate from hdms-manager.js for small, Node-testable surface.
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
import idEnc from 'hypercore-id-encoding'
|
||||
|
||||
/** Autopass record key: read-only replica offer (public key only). */
|
||||
export const HDMS_AUTOPASS_SHARE_KEY = 'bare-os-hdms/pending-share'
|
||||
|
||||
/** Autopass record key: read/write replica (public key + writer secret). Legacy peers ignore this key. */
|
||||
export const HDMS_AUTOPASS_SHARE_RW_KEY = 'bare-os-hdms/pending-share-rw'
|
||||
|
||||
/** Expected hex length for a Hypercore Ed25519 secret key (64 bytes). */
|
||||
export const WRITER_SECRET_HEX_LEN = 128
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @returns {string} lowercase hex
|
||||
*/
|
||||
export function normalizeWriterSecretHex(hex) {
|
||||
if (typeof hex !== 'string') throw new Error('Invalid writer secret')
|
||||
const h = hex.trim().toLowerCase()
|
||||
if (!/^[0-9a-f]{128}$/.test(h)) {
|
||||
throw new Error('writerSecretHex must be 128 hex chars (64 bytes)')
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function writerSecretBytesFromHex(hex) {
|
||||
return b4a.from(normalizeWriterSecretHex(hex), 'hex')
|
||||
}
|
||||
|
||||
/** @param {{ remove: (k: string) => Promise<unknown>, member?: { flushed: () => Promise<void> }, base?: { update?: () => Promise<void> } }} ap */
|
||||
export async function removeBothHdmsShareKeys(ap) {
|
||||
for (const k of [HDMS_AUTOPASS_SHARE_KEY, HDMS_AUTOPASS_SHARE_RW_KEY]) {
|
||||
try {
|
||||
await ap.remove(k)
|
||||
if (ap.member) await ap.member.flushed()
|
||||
try {
|
||||
await ap.base?.update?.()
|
||||
} catch (_) {}
|
||||
} catch {
|
||||
/* no prior offer */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {{ value?: unknown } | null | undefined} offer */
|
||||
export function decodeShareOffer(offer) {
|
||||
if (!offer || offer.value == null) return null
|
||||
const v = offer.value
|
||||
const raw =
|
||||
typeof v === 'string' ? v : b4a.toString(/** @type {Uint8Array} */ (v))
|
||||
try {
|
||||
const j = JSON.parse(raw)
|
||||
if (
|
||||
j &&
|
||||
typeof j.key === 'string' &&
|
||||
typeof j.label === 'string' &&
|
||||
j.key.length > 0
|
||||
) {
|
||||
return { label: j.label, key: j.key }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* RW payload uses `key` = Hyperdrive public key (manifest id, z32) for discovery/open,
|
||||
* and `signerKey` = metadata writer Ed25519 public key (z32) matching `writerSecretHex`.
|
||||
* @param {{ value?: unknown } | null | undefined} offer
|
||||
* @returns {{ label: string, key: string, signerKey: string, writerSecretHex: string } | null}
|
||||
*/
|
||||
export function decodeRwShareOffer(offer) {
|
||||
if (!offer || offer.value == null) return null
|
||||
const v = offer.value
|
||||
const raw =
|
||||
typeof v === 'string' ? v : b4a.toString(/** @type {Uint8Array} */ (v))
|
||||
try {
|
||||
const j = JSON.parse(raw)
|
||||
if (
|
||||
j &&
|
||||
typeof j.key === 'string' &&
|
||||
typeof j.label === 'string' &&
|
||||
typeof j.signerKey === 'string' &&
|
||||
typeof j.writerSecretHex === 'string' &&
|
||||
j.key.length > 0 &&
|
||||
j.signerKey.length > 0
|
||||
) {
|
||||
idEnc.decode(j.signerKey)
|
||||
idEnc.decode(j.key)
|
||||
return {
|
||||
label: j.label,
|
||||
key: j.key,
|
||||
signerKey: j.signerKey,
|
||||
writerSecretHex: normalizeWriterSecretHex(j.writerSecretHex)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Drive-resident /bin/holesail → ctx.bareOsRunHolesailCli (booter).
|
||||
* Manages persisted tunnels under **`BARE_OS_HOLESAIL_STATE`** or **`~/.holesail/state.json`** (per **`$HOME`**).
|
||||
* Live start/stop uses the **bare-holesail** initd unit after login (managed mode is the stock default).
|
||||
*/
|
||||
import {
|
||||
bareHolesailManagedEnvForConnectionId,
|
||||
bareHolesailManagedNormalizeEntry,
|
||||
bareHolesailManagedReadState,
|
||||
bareHolesailManagedRuntimeRunning,
|
||||
bareHolesailManagedRuntimeUrl,
|
||||
bareHolesailManagedServiceIsRunning,
|
||||
bareHolesailManagedStartOne,
|
||||
bareHolesailManagedStatePath,
|
||||
bareHolesailManagedStopOne,
|
||||
bareHolesailManagedSyncPersistedServerKey,
|
||||
bareHolesailManagedValidateId,
|
||||
bareHolesailManagedWriteState
|
||||
} from './bare-holesail-managed.js'
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
function ctxEnv(ctx) {
|
||||
return ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: /** @type {Record<string, string | undefined>} */ ({})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
async function holesailCliReadStateMergedForList(ctx, env) {
|
||||
return bareHolesailManagedReadState(ctx, env)
|
||||
}
|
||||
|
||||
function printHelp(ctx, prog) {
|
||||
ctx.console.log(
|
||||
`Usage: ${prog} help|path|list|status
|
||||
${prog} show ID
|
||||
${prog} add ID --server|--client [--key KEY] [--port N] [--host H] [--udp] [--secure] [--no-secure] [--log N|true|false]
|
||||
${prog} edit ID [--port N] [--host H] [--clear-host] [--udp|--no-udp|--tcp] [--secure|--no-secure] [--key KEY] [--log N|true|false]
|
||||
${prog} remove ID
|
||||
${prog} start ID | stop ID | restart ID
|
||||
${prog} enable ID | disable ID
|
||||
|
||||
State file: BARE_OS_HOLESAIL_STATE or ${bareHolesailManagedStatePath({})}
|
||||
Stock boot enables bare-holesail in managed mode (BARE_OS_HOLESAIL_INITD/MANAGED default on).
|
||||
Disable with BARE_OS_HOLESAIL_INITD=0 or systemctl disable bare-holesail.
|
||||
|
||||
show never prints seed material (ctor secret). list/show print the shareable hs:// URL.
|
||||
edit updates fields in place (does not change server/client or seed). Live tunnels restart.
|
||||
|
||||
Upstream holesail is AGPL-3.0; see handbook/04-the-booter-runtime.md.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entry
|
||||
* @param {string} id
|
||||
* @param {boolean} live
|
||||
* @param {string} runtimeUrl
|
||||
*/
|
||||
export function holesailCliFormatListRow(id, entry, live, runtimeUrl) {
|
||||
const mode = entry.server ? 'server' : entry.client ? 'client' : '?'
|
||||
const persistedUrl = String(entry.key ?? '').trim()
|
||||
const url = (live ? String(runtimeUrl || '').trim() : '') || persistedUrl
|
||||
const port = entry.port != null && entry.port !== '' ? String(entry.port) : ''
|
||||
const host = String(entry.host ?? '').trim()
|
||||
const udp = entry.udp != null ? String(!!entry.udp) : ''
|
||||
const secure = entry.secure != null ? String(!!entry.secure) : ''
|
||||
const bits = [
|
||||
id,
|
||||
mode,
|
||||
`enabled=${String(!!entry.enabled)}`,
|
||||
`live=${String(!!live)}`
|
||||
]
|
||||
if (port) bits.push(`port=${port}`)
|
||||
if (host) bits.push(`host=${host}`)
|
||||
if (udp) bits.push(`udp=${udp}`)
|
||||
if (secure) bits.push(`secure=${secure}`)
|
||||
if (url) bits.push(`url=${url.slice(0, 120)}`)
|
||||
return bits.join('\t')
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept `hs://…` without `--key` (otherwise parsed as an unknown flag).
|
||||
* @param {string} a
|
||||
*/
|
||||
function holesailCliTokenLooksLikeHolesailConnectionString(a) {
|
||||
return typeof a === 'string' && /^hs:\/\//i.test(a)
|
||||
}
|
||||
|
||||
export function holesailCliParseAdd(flagArgs) {
|
||||
if (flagArgs.length < 1) {
|
||||
return { ok: false, err: 'holesail add: missing ID' }
|
||||
}
|
||||
const idOk = bareHolesailManagedValidateId(flagArgs[0])
|
||||
if (!idOk.ok) return { ok: false, err: `holesail add: ${idOk.err}` }
|
||||
/** @type {Record<string, unknown>} */
|
||||
const raw = {}
|
||||
for (let i = 1; i < flagArgs.length; i++) {
|
||||
const a = flagArgs[i]
|
||||
if (a === '--server') raw.server = true
|
||||
else if (a === '--client') raw.client = true
|
||||
else if (holesailCliTokenLooksLikeHolesailConnectionString(a)) {
|
||||
if (raw.key) {
|
||||
return { ok: false, err: 'holesail add: duplicate key' }
|
||||
}
|
||||
raw.key = a
|
||||
} else if (a === '--key' && flagArgs[i + 1]) raw.key = flagArgs[++i]
|
||||
else if (a === '--port' && flagArgs[i + 1]) raw.port = flagArgs[++i]
|
||||
else if (a === '--host' && flagArgs[i + 1]) raw.host = flagArgs[++i]
|
||||
else if (a === '--udp') raw.udp = true
|
||||
else if (a === '--secure') raw.secure = true
|
||||
else if (a === '--no-secure') raw.secure = false
|
||||
else if (a === '--log' && flagArgs[i + 1]) {
|
||||
const v = flagArgs[++i]
|
||||
if (v === 'true' || v === 'false') raw.log = v
|
||||
else raw.log = v
|
||||
} else {
|
||||
return { ok: false, err: `holesail add: unknown or incomplete flag: ${a}` }
|
||||
}
|
||||
}
|
||||
const norm = bareHolesailManagedNormalizeEntry(raw)
|
||||
if (!norm.ok) return { ok: false, err: `holesail add: ${norm.err}` }
|
||||
return { ok: true, id: idOk.id, entry: norm.entry }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} flagArgs tokens including ID for "edit"
|
||||
* @returns {{ ok: true, id: string, patch: Record<string, unknown> } | { ok: false, err: string }}
|
||||
*/
|
||||
export function holesailCliParseEdit(flagArgs) {
|
||||
if (flagArgs.length < 1) {
|
||||
return { ok: false, err: 'holesail edit: missing ID' }
|
||||
}
|
||||
const idOk = bareHolesailManagedValidateId(flagArgs[0])
|
||||
if (!idOk.ok) return { ok: false, err: `holesail edit: ${idOk.err}` }
|
||||
/** @type {Record<string, unknown>} */
|
||||
const patch = {}
|
||||
let touched = false
|
||||
for (let i = 1; i < flagArgs.length; i++) {
|
||||
const a = flagArgs[i]
|
||||
if (holesailCliTokenLooksLikeHolesailConnectionString(a)) {
|
||||
if (patch.key) return { ok: false, err: 'holesail edit: duplicate key' }
|
||||
patch.key = a
|
||||
touched = true
|
||||
} else if (a === '--key' && flagArgs[i + 1]) {
|
||||
patch.key = flagArgs[++i]
|
||||
touched = true
|
||||
} else if (a === '--port' && flagArgs[i + 1]) {
|
||||
patch.port = flagArgs[++i]
|
||||
touched = true
|
||||
} else if (a === '--host' && flagArgs[i + 1]) {
|
||||
patch.host = flagArgs[++i]
|
||||
touched = true
|
||||
} else if (a === '--clear-host') {
|
||||
patch.host = ''
|
||||
touched = true
|
||||
} else if (a === '--udp') {
|
||||
patch.udp = true
|
||||
touched = true
|
||||
} else if (a === '--no-udp' || a === '--tcp') {
|
||||
patch.udp = false
|
||||
touched = true
|
||||
} else if (a === '--secure') {
|
||||
patch.secure = true
|
||||
touched = true
|
||||
} else if (a === '--no-secure') {
|
||||
patch.secure = false
|
||||
touched = true
|
||||
} else if (a === '--log' && flagArgs[i + 1]) {
|
||||
patch.log = flagArgs[++i]
|
||||
touched = true
|
||||
} else if (a === '--server' || a === '--client') {
|
||||
return { ok: false, err: 'holesail edit: cannot change server/client (remove and add)' }
|
||||
} else {
|
||||
return { ok: false, err: `holesail edit: unknown or incomplete flag: ${a}` }
|
||||
}
|
||||
}
|
||||
if (!touched) return { ok: false, err: 'holesail edit: no fields to change' }
|
||||
return { ok: true, id: idOk.id, patch }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string[]} argv argv[0] is holesail
|
||||
*/
|
||||
export async function runHolesailCli(ctx, argv) {
|
||||
const env = ctxEnv(ctx)
|
||||
const args = argv.slice(1)
|
||||
const prog = 'holesail'
|
||||
|
||||
if (
|
||||
args.length === 0 ||
|
||||
args[0] === 'help' ||
|
||||
args[0] === '--help' ||
|
||||
args[0] === '-h'
|
||||
) {
|
||||
printHelp(ctx, prog)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
const sub = args[0]
|
||||
const rest = args.slice(1)
|
||||
|
||||
if (sub === 'path') {
|
||||
ctx.console.log(bareHolesailManagedStatePath(env))
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
||||
ctx.console.error('holesail: ctx.vfs read/write unavailable')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!ctx.b4a || typeof ctx.b4a.from !== 'function') {
|
||||
ctx.console.error('holesail: ctx.b4a.from unavailable')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (sub === 'list') {
|
||||
let { path, state } = await holesailCliReadStateMergedForList(ctx, env)
|
||||
const idsSorted = Object.keys(state.connections).sort()
|
||||
let resynced = false
|
||||
for (const id of idsSorted) {
|
||||
if (!bareHolesailManagedRuntimeRunning(id)) continue
|
||||
const raw0 = state.connections[id]
|
||||
const norm0 = bareHolesailManagedNormalizeEntry(raw0)
|
||||
if (norm0.ok && norm0.entry.server) {
|
||||
const w = await bareHolesailManagedSyncPersistedServerKey(
|
||||
ctx,
|
||||
bareHolesailManagedEnvForConnectionId(env, id),
|
||||
id
|
||||
)
|
||||
if (w) resynced = true
|
||||
}
|
||||
}
|
||||
if (resynced) {
|
||||
;({ path, state } = await holesailCliReadStateMergedForList(ctx, env))
|
||||
}
|
||||
const daemon = bareHolesailManagedServiceIsRunning()
|
||||
ctx.console.log(`state: ${path}`)
|
||||
ctx.console.log(`daemon: ${daemon ? 'managed bare-holesail running' : 'no managed daemon (live ops limited)'}`)
|
||||
const ids = Object.keys(state.connections).sort()
|
||||
if (ids.length === 0) {
|
||||
ctx.console.log('(no connections)')
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
const raw = state.connections[id]
|
||||
const norm = bareHolesailManagedNormalizeEntry(raw)
|
||||
const live = bareHolesailManagedRuntimeRunning(id)
|
||||
const runtimeUrl = live ? bareHolesailManagedRuntimeUrl(id).trim() : ''
|
||||
if (norm.ok) {
|
||||
ctx.console.log(holesailCliFormatListRow(id, norm.entry, live, runtimeUrl))
|
||||
} else {
|
||||
ctx.console.log(`${id}\t?\tenabled=?\tlive=${live}\tINVALID: ${norm.err}`)
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'status') {
|
||||
const { path, state } = await holesailCliReadStateMergedForList(ctx, env)
|
||||
const ids = Object.keys(state.connections).sort()
|
||||
let enabled = 0
|
||||
let live = 0
|
||||
let servers = 0
|
||||
let clients = 0
|
||||
for (const id of ids) {
|
||||
const norm = bareHolesailManagedNormalizeEntry(state.connections[id])
|
||||
if (!norm.ok) continue
|
||||
if (norm.entry.enabled) enabled++
|
||||
if (norm.entry.server) servers++
|
||||
if (norm.entry.client) clients++
|
||||
if (bareHolesailManagedRuntimeRunning(id)) live++
|
||||
}
|
||||
const daemon = bareHolesailManagedServiceIsRunning()
|
||||
ctx.console.log(`state: ${path}`)
|
||||
ctx.console.log(`daemon: ${daemon ? 'managed bare-holesail running' : 'no managed daemon (live ops limited)'}`)
|
||||
ctx.console.log(
|
||||
`connections: ${ids.length}\tenabled=${enabled}\tlive=${live}\tserver=${servers}\tclient=${clients}`
|
||||
)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'show') {
|
||||
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
||||
if (!idOk.ok) {
|
||||
ctx.console.error(`holesail show: ${idOk.err}`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
||||
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
||||
const raw = state.connections[idOk.id]
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
ctx.console.error(`holesail show: unknown ${idOk.id}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
const norm = bareHolesailManagedNormalizeEntry(raw)
|
||||
if (!norm.ok) {
|
||||
ctx.console.error(`holesail show: ${norm.err}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
const live = bareHolesailManagedRuntimeRunning(idOk.id)
|
||||
const persistedUrl = String(norm.entry.key ?? '').trim()
|
||||
const runtimeUrl = live ? bareHolesailManagedRuntimeUrl(idOk.id).trim() : ''
|
||||
const url = runtimeUrl || persistedUrl
|
||||
const seedSet = String(norm.entry.seed ?? '').trim() ? 'yes' : 'no'
|
||||
const daemon = bareHolesailManagedServiceIsRunning()
|
||||
ctx.console.log(`id\t${idOk.id}`)
|
||||
ctx.console.log(`mode\t${norm.entry.server ? 'server' : 'client'}`)
|
||||
ctx.console.log(`enabled\t${String(!!norm.entry.enabled)}`)
|
||||
ctx.console.log(`live\t${String(!!live)}`)
|
||||
if (norm.entry.port != null) ctx.console.log(`port\t${norm.entry.port}`)
|
||||
if (norm.entry.host) ctx.console.log(`host\t${norm.entry.host}`)
|
||||
if (norm.entry.udp != null) ctx.console.log(`udp\t${String(!!norm.entry.udp)}`)
|
||||
if (norm.entry.secure != null) ctx.console.log(`secure\t${String(!!norm.entry.secure)}`)
|
||||
if (norm.entry.log != null) ctx.console.log(`log\t${norm.entry.log}`)
|
||||
ctx.console.log(`seed\t${seedSet}`)
|
||||
if (url) ctx.console.log(`url\t${url}`)
|
||||
ctx.console.log(`state\t${path}`)
|
||||
ctx.console.log(`daemon\t${daemon ? 'running' : 'stopped'}`)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const parsed = holesailCliParseAdd(rest)
|
||||
if (!parsed.ok) {
|
||||
ctx.console.error(parsed.err)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const envW = bareHolesailManagedEnvForConnectionId(env, parsed.id)
|
||||
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
||||
if (state.connections[parsed.id]) {
|
||||
ctx.console.error(`holesail add: ${parsed.id} already exists (use remove first)`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
state.connections[parsed.id] = parsed.entry
|
||||
await bareHolesailManagedWriteState(ctx, envW, state)
|
||||
ctx.console.log(`wrote ${path}`)
|
||||
if (bareHolesailManagedServiceIsRunning()) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(ctx, envW, parsed.id)
|
||||
ctx.console.log(`started ${parsed.id}`)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
ctx.console.error(`holesail: persisted but start failed: ${msg}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
||||
if (!idOk.ok) {
|
||||
ctx.console.error(`holesail remove: ${idOk.err}`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
||||
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
||||
if (!state.connections[idOk.id]) {
|
||||
ctx.console.error(`holesail remove: unknown ${idOk.id}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (bareHolesailManagedRuntimeRunning(idOk.id)) {
|
||||
await bareHolesailManagedStopOne(ctx, idOk.id)
|
||||
}
|
||||
delete state.connections[idOk.id]
|
||||
await bareHolesailManagedWriteState(ctx, envW, state)
|
||||
ctx.console.log(`removed ${idOk.id} (${path})`)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'start' || sub === 'stop' || sub === 'restart') {
|
||||
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
||||
if (!idOk.ok) {
|
||||
ctx.console.error(`holesail ${sub}: ${idOk.err}`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
if (sub === 'restart' || sub === 'stop') {
|
||||
if (bareHolesailManagedRuntimeRunning(idOk.id)) {
|
||||
await bareHolesailManagedStopOne(ctx, idOk.id)
|
||||
}
|
||||
}
|
||||
if (sub === 'stop') {
|
||||
ctx.console.log(`stopped ${idOk.id} (still in state; will restart on boot if enabled)`)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
await bareHolesailManagedStartOne(
|
||||
ctx,
|
||||
bareHolesailManagedEnvForConnectionId(env, idOk.id),
|
||||
idOk.id
|
||||
)
|
||||
ctx.console.log(`started ${idOk.id}`)
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'edit') {
|
||||
const parsed = holesailCliParseEdit(rest)
|
||||
if (!parsed.ok) {
|
||||
ctx.console.error(parsed.err)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const envW = bareHolesailManagedEnvForConnectionId(env, parsed.id)
|
||||
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
||||
const raw = state.connections[parsed.id]
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
ctx.console.error(`holesail edit: unknown ${parsed.id}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
const merged = { .../** @type {Record<string, unknown>} */ (raw), ...parsed.patch }
|
||||
if (Object.prototype.hasOwnProperty.call(parsed.patch, 'host') && parsed.patch.host === '') {
|
||||
delete merged.host
|
||||
}
|
||||
const norm = bareHolesailManagedNormalizeEntry(merged)
|
||||
if (!norm.ok) {
|
||||
ctx.console.error(`holesail edit: ${norm.err}`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const prev = /** @type {Record<string, unknown>} */ (raw)
|
||||
if (prev.seed) norm.entry.seed = prev.seed
|
||||
if (prev.server) {
|
||||
norm.entry.server = true
|
||||
norm.entry.client = false
|
||||
} else if (prev.client) {
|
||||
norm.entry.client = true
|
||||
norm.entry.server = false
|
||||
}
|
||||
if (prev.enabled !== undefined && parsed.patch.enabled === undefined) {
|
||||
norm.entry.enabled = prev.enabled
|
||||
}
|
||||
state.connections[parsed.id] = norm.entry
|
||||
await bareHolesailManagedWriteState(ctx, envW, state)
|
||||
ctx.console.log(`wrote ${path}`)
|
||||
const wasLive = bareHolesailManagedRuntimeRunning(parsed.id)
|
||||
if (wasLive) {
|
||||
await bareHolesailManagedStopOne(ctx, parsed.id)
|
||||
if (norm.entry.enabled && bareHolesailManagedServiceIsRunning()) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(ctx, envW, parsed.id)
|
||||
ctx.console.log(`restarted ${parsed.id}`)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
ctx.console.error(`holesail: saved but restart failed: ${msg}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'enable' || sub === 'disable') {
|
||||
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
||||
if (!idOk.ok) {
|
||||
ctx.console.error(`holesail ${sub}: ${idOk.err}`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
||||
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
||||
const raw = state.connections[idOk.id]
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
ctx.console.error(`holesail ${sub}: unknown ${idOk.id}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
const o = /** @type {Record<string, unknown>} */ (raw)
|
||||
o.enabled = sub === 'enable'
|
||||
await bareHolesailManagedWriteState(ctx, envW, state)
|
||||
ctx.console.log(`${sub}d ${idOk.id} (${path})`)
|
||||
if (sub === 'disable' && bareHolesailManagedRuntimeRunning(idOk.id)) {
|
||||
await bareHolesailManagedStopOne(ctx, idOk.id)
|
||||
}
|
||||
if (sub === 'enable' && bareHolesailManagedServiceIsRunning()) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(ctx, envW, idOk.id)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
ctx.console.error(`holesail: enable saved but start failed: ${msg}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
ctx.console.error(`holesail: unknown command ${sub} (try ${prog} help)`)
|
||||
ctx.exitCode = 2
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
ctx.console.error(`holesail: ${msg}`)
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
import b4a from 'b4a'
|
||||
import c from 'compact-encoding'
|
||||
import {
|
||||
PROTOCOL_NAME,
|
||||
PROTOCOL_APP_CHANNEL_NAME,
|
||||
PROTOCOL_CAP_CHANNEL_NAME,
|
||||
PROTOCOL_CHAT_CHANNEL_NAME,
|
||||
PROTOCOL_MESHDROP_CHANNEL_NAME
|
||||
} from 'bare-os-protocol/constants.js'
|
||||
import { bareOsChatMuxEnabled } from './bare-os-chat-service.js'
|
||||
import { bareOsMeshdropMuxEnabled } from './bare-os-meshdrop-service.js'
|
||||
import {
|
||||
bareOsHostBooterInfo,
|
||||
bareOsHostBooterWarn
|
||||
} from './bare-os-host-booter-log.js'
|
||||
import { getKernelCapabilityWords } from 'bare-os-protocol'
|
||||
|
||||
/**
|
||||
* Host-side booter log for swarm disk (stderr JSON when BARE_OS_BOOT_TRACE=json|ndjson; else stderr or console.warn).
|
||||
* @param {'info'|'warn'} level
|
||||
* @param {string} message
|
||||
* @param {Record<string, unknown> | null} [detail]
|
||||
*/
|
||||
function emitSwarmDiskHostLog(level, message, detail = null) {
|
||||
const env = globalThis.process?.env
|
||||
const trace =
|
||||
env &&
|
||||
(env.BARE_OS_BOOT_TRACE === 'json' || env.BARE_OS_BOOT_TRACE === 'ndjson')
|
||||
const err = globalThis.process?.stderr
|
||||
if (trace && err && typeof err.write === 'function') {
|
||||
err.write(
|
||||
`${JSON.stringify({
|
||||
type: 'booterHost',
|
||||
bootTraceSchemaVersion: 2,
|
||||
component: 'swarm_disk',
|
||||
level,
|
||||
message,
|
||||
detail,
|
||||
ts: Date.now()
|
||||
})}\n`
|
||||
)
|
||||
}
|
||||
if (level === 'warn') {
|
||||
bareOsHostBooterWarn(
|
||||
'swarm_disk',
|
||||
message,
|
||||
detail ? JSON.stringify(detail) : ''
|
||||
)
|
||||
return
|
||||
}
|
||||
bareOsHostBooterInfo(
|
||||
'swarm_disk',
|
||||
message,
|
||||
detail ? JSON.stringify(detail) : ''
|
||||
)
|
||||
}
|
||||
|
||||
/** Default max wait for block 0 (MBR) before failing boot (override **`BARE_OS_MBR_READ_TIMEOUT_MS`**). */
|
||||
const MBR_READ_TIMEOUT_MS_DEFAULT = 60_000
|
||||
|
||||
/** Cap-channel (`bare-os-cap-v1`) message size bound when **`BARE_OS_PROTOMUX_CAP_CHANNEL`** is enabled. */
|
||||
export const BARE_OS_PROTOMUX_CAP_CHANNEL_MESSAGE_MAX_BYTES = 65536
|
||||
|
||||
/**
|
||||
* Max wait (ms) for block 0 / MBR replication before boot failure.
|
||||
* Exported for microbench / unit introspection (same rules as booter).
|
||||
* @param {SwarmDisk} disk
|
||||
*/
|
||||
export function mbrReadTimeoutMsForDisk(disk) {
|
||||
const env = globalThis.process?.env
|
||||
const raw = String(env?.BARE_OS_MBR_READ_TIMEOUT_MS ?? '').trim()
|
||||
if (raw) {
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n) && n >= 3000) return Math.min(n, 600_000)
|
||||
}
|
||||
const adapt =
|
||||
env?.BARE_OS_MBR_READ_TIMEOUT_ADAPTIVE === '1' ||
|
||||
env?.BARE_OS_MBR_READ_TIMEOUT_ADAPTIVE === 'true'
|
||||
if (adapt && disk && disk.peers && typeof disk.peers.size === 'number') {
|
||||
const n = disk.peers.size
|
||||
if (n <= 1) return Math.min(120_000, MBR_READ_TIMEOUT_MS_DEFAULT + 30_000)
|
||||
if (n >= 4) return Math.max(45_000, MBR_READ_TIMEOUT_MS_DEFAULT - 15_000)
|
||||
}
|
||||
return MBR_READ_TIMEOUT_MS_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval (ms) to re-send pending block read requests to all peers while waiting for MBR / block data.
|
||||
* **`0`** disables periodic rebroadcast (initial + per-connect sends only).
|
||||
* Override **`BARE_OS_MBR_READ_REBROADCAST_MS`** (defaults **5000**, clamp **2000**–**60000**).
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
*/
|
||||
export function mbrReadRebroadcastMsFromEnv(env) {
|
||||
const raw = String(env?.BARE_OS_MBR_READ_REBROADCAST_MS ?? '').trim()
|
||||
if (raw === '0' || raw.toLowerCase() === 'false') return 0
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n) && n >= 0) {
|
||||
if (n === 0) return 0
|
||||
return Math.min(60_000, Math.max(2_000, n))
|
||||
}
|
||||
return 5_000
|
||||
}
|
||||
|
||||
export class SwarmDisk {
|
||||
constructor() {
|
||||
this.localRAM = new Map()
|
||||
this.peers = new Set()
|
||||
this.pendingReads = new Map()
|
||||
this.pendingSearches = new Map()
|
||||
this.pendingRpc = new Map()
|
||||
this.searchIdCounter = 0
|
||||
this.rpcIdCounter = 0
|
||||
this.drive = null
|
||||
this.personalDrive = null
|
||||
this.os = null
|
||||
/** @type {import('hyperdrive').default[]} */
|
||||
this.auxiliaryDrives = []
|
||||
/** @type {Record<string, unknown> | null} Last `bare_os.capabilities` RPC result (or error object). */
|
||||
this.seedCapabilityInfo = null
|
||||
/** @type {Record<string, unknown> | null} Last `bare_os.replication_status` RPC result when available. */
|
||||
this.seedReplicationStatus = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedManifestHints = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedPeerHealth = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedStagingSlot = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedReplicationQueue = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedCapabilityAttestation = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedMbrLayout = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedSnapshotHints = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedPeerFirewallStats = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedReplicationPlan = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedDhtBootstrapHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedSnapshotChain = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedMirrorCompactionHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedUpdaterState = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedBlindPeerTopologyV2 = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedCompactPing = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedCorestoreStats = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedSnapshotManifestSlice = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedMirrorDriveHintV2 = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedHrpcRegistrySummary = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedProtomuxCapabilityAd = null
|
||||
/** Cumulative count of buffers received on optional Protomux app-channel pairs (operator observability). */
|
||||
this.protomuxAppChannelRxTotal = 0
|
||||
/** Cumulative count of buffers received on optional Protomux cap-channel pairs (`bare-os-cap-v1`). */
|
||||
this.protomuxCapChannelRxTotal = 0
|
||||
/** Cumulative chat `event` messages received on `bare-os-chat-v1` (when enabled). */
|
||||
this.protomuxChatChannelRxTotal = 0
|
||||
/** Cumulative meshdrop envelopes received on `bare-os-meshdrop-v1` (when enabled). */
|
||||
this.protomuxMeshdropChannelRxTotal = 0
|
||||
/** @type {ReturnType<import('./bare-os-chat-service.js').createBareOsChatService> | null} */
|
||||
this.bareOsChatService = null
|
||||
/** @type {ReturnType<import('./bare-os-meshdrop-service.js').createBareOsMeshdropService> | null} */
|
||||
this.bareOsMeshdropService = null
|
||||
/** @type {Record<string, unknown> | null} Host-requested Hyperswarm caps (from env); surfaced on disk.os RPC. */
|
||||
this.swarmConnectionBudget = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedDhtAddressBook = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedReplicationThrottleHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedBundlebeeStage = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedHttpDhtProxyHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedProtomuxRpcPoolHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedHyperblobStoreHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedSigningRequestQueueHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedCoreStorageLayoutHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedMirrorDriveCompactionV3 = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedBundlebeeCliStage = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedReadyGuardV2 = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedBlindRelayCircuitHint = null
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
this.seedHttpDhtProxyRoutes = null
|
||||
/** @type {string[]} MBR-derived drive key hex list (primary + failovers). */
|
||||
this.mbrKeysHex = []
|
||||
/**
|
||||
* 512-byte MBR block used at boot (copy); enables peer block-0 service when mirrored to {@link SwarmDisk#localRAM}.
|
||||
* @type {Uint8Array | null}
|
||||
*/
|
||||
this.bootMbr512 = null
|
||||
/** True after peer system seed eligibility passed and MBR was published to `localRAM`. */
|
||||
this.peerSystemSeedActive = false
|
||||
/**
|
||||
* Local Noise wire static key (32 bytes), last seen from `Hyperswarm` socket `publicKey`.
|
||||
* Peers validate chat `senderPk` against `socket.remotePublicKey`, which matches **this** — not `swarm.keyPair.publicKey`.
|
||||
*/
|
||||
this.localNoiseWirePk = /** @type {Uint8Array | null} */ (null)
|
||||
}
|
||||
|
||||
/**
|
||||
* One JSON-RPC-style round-trip to a specific peer.
|
||||
* @param {unknown} peer
|
||||
* @param {string} module
|
||||
* @param {string} method
|
||||
* @param {string[]} args
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
_rpcOne(peer, module, method, args, timeoutMs) {
|
||||
const id = this.rpcIdCounter++
|
||||
return new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => {
|
||||
this.pendingRpc.delete(id)
|
||||
reject(new Error('swarm-disk rpc: timeout'))
|
||||
}, timeoutMs)
|
||||
this.pendingRpc.set(id, (m) => {
|
||||
clearTimeout(to)
|
||||
if (m.success) {
|
||||
const r = m.result || ''
|
||||
try {
|
||||
resolve(r ? JSON.parse(r) : null)
|
||||
} catch {
|
||||
resolve(r)
|
||||
}
|
||||
} else {
|
||||
reject(new Error(m.error || 'swarm-disk rpc failed'))
|
||||
}
|
||||
})
|
||||
const chan =
|
||||
/** @type {{ chan: { messages: { send: (m: unknown) => void }[] } }} */ (
|
||||
peer
|
||||
).chan
|
||||
void chan.fullyOpened().then((opened) => {
|
||||
if (!opened) return
|
||||
if (!this.pendingRpc.has(id)) return
|
||||
try {
|
||||
chan.messages[5].send({ id, module, method, args })
|
||||
} catch (err) {
|
||||
this.pendingRpc.delete(id)
|
||||
clearTimeout(to)
|
||||
reject(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error((err && err.message) || String(err))
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC-style call to swarm peers. For **`bare_os`**, tries each peer in turn when
|
||||
* **`BARE_OS_SWARM_RPC_TRY_PEERS`** is not **`0`/`false`** (default: try all).
|
||||
* **`capabilities`** responses must include **`kernelCapabilityWords`** (wire v2).
|
||||
* @param {string} module
|
||||
* @param {string} method
|
||||
* @param {string[]} [args]
|
||||
* @param {number} [timeoutMs]
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async rpc(module, method, args = [], timeoutMs = 8000) {
|
||||
if (!this.peers.size) throw new Error('swarm-disk rpc: no peers')
|
||||
const peers = [...this.peers]
|
||||
const mod = String(module || '')
|
||||
const meth = String(method || '')
|
||||
const env = globalThis.process?.env
|
||||
const tryAll =
|
||||
String(env?.BARE_OS_SWARM_RPC_TRY_PEERS ?? '1').toLowerCase() !== '0' &&
|
||||
String(env?.BARE_OS_SWARM_RPC_TRY_PEERS ?? '1').toLowerCase() !==
|
||||
'false' &&
|
||||
mod === 'bare_os' &&
|
||||
peers.length > 1
|
||||
const perPeerMs = tryAll
|
||||
? Math.min(
|
||||
2500,
|
||||
Math.max(800, Math.floor(timeoutMs / Math.min(peers.length, 4)))
|
||||
)
|
||||
: timeoutMs
|
||||
if (tryAll) {
|
||||
/** @type {Error | null} */
|
||||
let lastErr = null
|
||||
for (const peer of peers) {
|
||||
try {
|
||||
const r = await this._rpcOne(peer, mod, meth, args, perPeerMs)
|
||||
if (meth === 'capabilities') {
|
||||
const words = getKernelCapabilityWords(r)
|
||||
if (!words) {
|
||||
lastErr = new Error(
|
||||
'swarm-disk rpc: capabilities missing kernelCapabilityWords'
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return r
|
||||
} catch (e) {
|
||||
lastErr =
|
||||
e instanceof Error ? e : new Error((e && e.message) || String(e))
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('swarm-disk rpc: all peers failed')
|
||||
}
|
||||
return this._rpcOne(peers[0], mod, meth, args, timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('corestore').default} store
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {import('hyperdrive').default} Hyperdrive
|
||||
*/
|
||||
async initPersonalDrive(store, swarm, Hyperdrive) {
|
||||
const localStore = store.namespace('bare-os-personal-v1', {
|
||||
writable: true
|
||||
})
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
if (!this.personalDrive.writable) {
|
||||
try {
|
||||
await this.personalDrive.close()
|
||||
} catch (_) {}
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
}
|
||||
if (!this.personalDrive.writable) {
|
||||
emitSwarmDiskHostLog(
|
||||
'warn',
|
||||
'[bare-os-booter] Personal Hyperdrive is not writable — identity and $HOME writes will fail. Check Corestore path permissions and that no other process holds the store read-only.',
|
||||
{
|
||||
writable: false,
|
||||
idPrefix: b4a.toString(this.personalDrive.id, 'hex').slice(0, 16)
|
||||
}
|
||||
)
|
||||
}
|
||||
emitSwarmDiskHostLog(
|
||||
'info',
|
||||
'Personal Hyperdrive mounted; joining swarm discovery',
|
||||
{
|
||||
idPrefix: b4a.toString(this.personalDrive.id, 'hex').slice(0, 16),
|
||||
writable: this.personalDrive.writable
|
||||
}
|
||||
)
|
||||
swarm.join(this.personalDrive.discoveryKey)
|
||||
}
|
||||
|
||||
addPeer(mux, socket) {
|
||||
const disk = this
|
||||
/** @type {any} */
|
||||
let chan
|
||||
|
||||
const context = {
|
||||
onread(index) {
|
||||
const data = disk.localRAM.get(index)
|
||||
if (data) chan.messages[1].send({ index, data })
|
||||
},
|
||||
ondata(m) {
|
||||
const cb = disk.pendingReads.get(m.index)
|
||||
if (cb) {
|
||||
disk.pendingReads.delete(m.index)
|
||||
cb(m.data)
|
||||
}
|
||||
},
|
||||
ongossip() {},
|
||||
async onsearchreq(m) {
|
||||
const matches = disk.os ? await disk.os.searchLocal?.(m.query) : []
|
||||
chan.messages[4].send({ id: m.id, matches: matches || [] })
|
||||
},
|
||||
onsearchres(m) {
|
||||
const st = disk.pendingSearches.get(m.id)
|
||||
if (!st || st.settled) return
|
||||
st.results.push(m.matches || [])
|
||||
st.remaining--
|
||||
if (st.remaining <= 0) st.finish()
|
||||
},
|
||||
async onrpcreq(m) {
|
||||
if (!disk.os) {
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: false,
|
||||
result: '',
|
||||
error: 'OS not initialized'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await disk.os.execRpc?.(m.module, m.method, m.args)
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: true,
|
||||
result: String(result ?? ''),
|
||||
error: ''
|
||||
})
|
||||
} catch (err) {
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: false,
|
||||
result: '',
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
},
|
||||
onrpcres(m) {
|
||||
const cb = disk.pendingRpc.get(m.id)
|
||||
if (cb) {
|
||||
disk.pendingRpc.delete(m.id)
|
||||
cb(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chan = mux.createChannel({ protocol: PROTOCOL_NAME, userData: context })
|
||||
|
||||
chan.addMessage({
|
||||
encoding: c.uint32,
|
||||
onmessage: (index, ch) => ch.userData.onread(index)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.index)
|
||||
c.buffer.preencode(state, m.data)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.index)
|
||||
c.buffer.encode(state, m.data)
|
||||
},
|
||||
decode(state) {
|
||||
return { index: c.uint32.decode(state), data: c.buffer.decode(state) }
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.ondata(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: c.buffer,
|
||||
onmessage: (bitfield, ch) => ch.userData.ongossip(bitfield)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.query)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.query)
|
||||
},
|
||||
decode(state) {
|
||||
return { id: c.uint32.decode(state), query: c.string.decode(state) }
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onsearchreq(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.array(c.string).preencode(state, m.matches)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.array(c.string).encode(state, m.matches)
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
matches: c.array(c.string).decode(state)
|
||||
}
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onsearchres(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.module)
|
||||
c.string.preencode(state, m.method)
|
||||
c.array(c.string).preencode(state, m.args)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.module)
|
||||
c.string.encode(state, m.method)
|
||||
c.array(c.string).encode(state, m.args)
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
module: c.string.decode(state),
|
||||
method: c.string.decode(state),
|
||||
args: c.array(c.string).decode(state)
|
||||
}
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onrpcreq(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.bool.preencode(state, m.success)
|
||||
c.string.preencode(state, m.result)
|
||||
c.string.preencode(state, m.error)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.bool.encode(state, m.success)
|
||||
c.string.encode(state, m.result || '')
|
||||
c.string.encode(state, m.error || '')
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
success: c.bool.decode(state),
|
||||
result: c.string.decode(state),
|
||||
error: c.string.decode(state)
|
||||
}
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onrpcres(m)
|
||||
})
|
||||
|
||||
chan.open()
|
||||
|
||||
const appChOn =
|
||||
globalThis.process &&
|
||||
globalThis.process.env &&
|
||||
(globalThis.process.env.BARE_OS_PROTOMUX_APP_CHANNEL === '1' ||
|
||||
globalThis.process.env.BARE_OS_PROTOMUX_APP_CHANNEL === 'true')
|
||||
if (appChOn) {
|
||||
mux.pair({ protocol: PROTOCOL_APP_CHANNEL_NAME }, (achan) => {
|
||||
achan.addMessage({
|
||||
encoding: c.buffer,
|
||||
onmessage: () => {
|
||||
try {
|
||||
this.protomuxAppChannelRxTotal++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
achan.open()
|
||||
})
|
||||
}
|
||||
|
||||
const capChOn =
|
||||
globalThis.process &&
|
||||
globalThis.process.env &&
|
||||
(globalThis.process.env.BARE_OS_PROTOMUX_CAP_CHANNEL === '1' ||
|
||||
globalThis.process.env.BARE_OS_PROTOMUX_CAP_CHANNEL === 'true')
|
||||
if (capChOn) {
|
||||
mux.pair({ protocol: PROTOCOL_CAP_CHANNEL_NAME }, (cchan) => {
|
||||
cchan.addMessage({
|
||||
encoding: c.buffer,
|
||||
onmessage: (buf) => {
|
||||
try {
|
||||
const n = buf ? buf.byteLength : 0
|
||||
if (n > BARE_OS_PROTOMUX_CAP_CHANNEL_MESSAGE_MAX_BYTES) {
|
||||
emitSwarmDiskHostLog('warn', 'protomux_cap_payload_oversized', {
|
||||
byteLength: n,
|
||||
maxBytes: BARE_OS_PROTOMUX_CAP_CHANNEL_MESSAGE_MAX_BYTES
|
||||
})
|
||||
return
|
||||
}
|
||||
this.protomuxCapChannelRxTotal++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
cchan.open()
|
||||
})
|
||||
}
|
||||
|
||||
const peer = {
|
||||
chan,
|
||||
mux,
|
||||
socket,
|
||||
id: null,
|
||||
chatChan: null,
|
||||
meshdropChan: null
|
||||
}
|
||||
if (
|
||||
bareOsChatMuxEnabled(globalThis.process?.env) &&
|
||||
this.bareOsChatService &&
|
||||
mux.stream &&
|
||||
!mux.stream.destroyed
|
||||
) {
|
||||
try {
|
||||
this.bareOsChatService.pairOnMux(this, mux, socket, peer)
|
||||
} catch (e) {
|
||||
emitSwarmDiskHostLog('warn', 'bare_os_chat_pair_on_connect_failed', {
|
||||
message:
|
||||
(e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
bareOsMeshdropMuxEnabled(globalThis.process?.env) &&
|
||||
this.bareOsMeshdropService &&
|
||||
mux.stream &&
|
||||
!mux.stream.destroyed
|
||||
) {
|
||||
try {
|
||||
this.bareOsMeshdropService.pairOnMux(this, mux, socket, peer)
|
||||
} catch (e) {
|
||||
emitSwarmDiskHostLog(
|
||||
'warn',
|
||||
'bare_os_meshdrop_pair_on_connect_failed',
|
||||
{
|
||||
message:
|
||||
(e && /** @type {{ message?: string }} */ (e).message) ||
|
||||
String(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync local Noise static key once the secret stream exposes `publicKey` (may follow handshake). */
|
||||
const cacheLocalNoiseWirePk = () => {
|
||||
if (socket.publicKey && socket.publicKey.byteLength === 32) {
|
||||
disk.localNoiseWirePk = b4a.from(socket.publicKey)
|
||||
}
|
||||
}
|
||||
cacheLocalNoiseWirePk()
|
||||
|
||||
this.peers.add(peer)
|
||||
|
||||
const pendingIndices = [...this.pendingReads.keys()]
|
||||
for (const idx of pendingIndices) {
|
||||
this._sendReadIndexToPeer(peer, idx)
|
||||
}
|
||||
|
||||
const collabNd =
|
||||
globalThis.process &&
|
||||
globalThis.process.env &&
|
||||
(globalThis.process.env.BARE_OS_COLLAB_SESSION_NDJSON === '1' ||
|
||||
globalThis.process.env.BARE_OS_COLLAB_SESSION_NDJSON === 'true')
|
||||
if (collabNd) {
|
||||
emitSwarmDiskHostLog('info', 'collab_session_peer', {
|
||||
peerCount: this.peers.size,
|
||||
capChannelEnabled: capChOn,
|
||||
appChannelEnabled: appChOn,
|
||||
note: 'Host NDJSON preview; mirror to personal-drive tooling off-guest if policy allows.'
|
||||
})
|
||||
}
|
||||
|
||||
const setPeerId = () => {
|
||||
if (socket.remotePublicKey && !peer.id) {
|
||||
peer.id = b4a.toString(socket.remotePublicKey, 'hex')
|
||||
return true
|
||||
}
|
||||
if (socket.handshakeHash && !peer.id) {
|
||||
peer.id = b4a.toString(socket.handshakeHash, 'hex')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
socket.on('handshake', () => {
|
||||
setPeerId()
|
||||
cacheLocalNoiseWirePk()
|
||||
})
|
||||
if (!setPeerId()) {
|
||||
let attempts = 0
|
||||
const tryAgain = () => {
|
||||
attempts++
|
||||
if (attempts > 10) {
|
||||
if (!peer.id) peer.id = 'peer-' + Date.now().toString(36)
|
||||
return
|
||||
}
|
||||
if (!setPeerId()) setTimeout(tryAgain, attempts * 50)
|
||||
}
|
||||
setTimeout(tryAgain, 50)
|
||||
}
|
||||
|
||||
mux.stream.on('close', () => {
|
||||
this.peers.delete(peer)
|
||||
})
|
||||
|
||||
if (this.drive)
|
||||
this.drive.replicate(mux.stream, { live: true, download: true })
|
||||
if (this.personalDrive) this.personalDrive.replicate(mux.stream)
|
||||
for (const d of this.auxiliaryDrives || []) {
|
||||
try {
|
||||
d.replicate(mux.stream, { live: true, download: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protomux message 0: request block index (MBR). Only send after channel pairing.
|
||||
* @param {unknown} peer
|
||||
* @param {number} index
|
||||
*/
|
||||
_sendReadIndexToPeer(peer, index) {
|
||||
const chan =
|
||||
/** @type {{ chan: { messages: { send: (idx: number) => void }[] } }} */ (
|
||||
peer
|
||||
).chan
|
||||
void chan.fullyOpened().then((opened) => {
|
||||
if (!opened) return
|
||||
if (!this.pendingReads.has(index)) return
|
||||
try {
|
||||
chan.messages[0].send(index)
|
||||
} catch {
|
||||
/* ignore — channel closed or not ready */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-broadcast a pending block read to every connected peer (lossy links, slow pairing).
|
||||
* @param {number} index
|
||||
*/
|
||||
_broadcastPendingReadIndex(index) {
|
||||
for (const p of this.peers) {
|
||||
this._sendReadIndexToPeer(p, index)
|
||||
}
|
||||
}
|
||||
|
||||
async read(index) {
|
||||
if (this.localRAM.has(index)) return this.localRAM.get(index)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutMs = mbrReadTimeoutMsForDisk(this)
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let rebroadcastIv = null
|
||||
const timeout = setTimeout(() => {
|
||||
if (rebroadcastIv) clearInterval(rebroadcastIv)
|
||||
this.pendingReads.delete(index)
|
||||
reject(new Error('MBR read timeout'))
|
||||
}, timeoutMs)
|
||||
this.pendingReads.set(index, (data) => {
|
||||
if (rebroadcastIv) clearInterval(rebroadcastIv)
|
||||
clearTimeout(timeout)
|
||||
resolve(data)
|
||||
})
|
||||
const rbMs = mbrReadRebroadcastMsFromEnv(globalThis.process?.env)
|
||||
this._broadcastPendingReadIndex(index)
|
||||
if (rbMs > 0) {
|
||||
rebroadcastIv = setInterval(() => {
|
||||
if (!this.pendingReads.has(index)) {
|
||||
if (rebroadcastIv) clearInterval(rebroadcastIv)
|
||||
return
|
||||
}
|
||||
this._broadcastPendingReadIndex(index)
|
||||
}, rbMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
const id = this.searchIdCounter++
|
||||
const peerList = [...this.peers]
|
||||
const n = peerList.length
|
||||
if (!n) return []
|
||||
const self = this
|
||||
return new Promise((resolve) => {
|
||||
/** @type {unknown[][]} */
|
||||
const results = []
|
||||
const state = {
|
||||
remaining: n,
|
||||
results,
|
||||
settled: false,
|
||||
timeout: /** @type {ReturnType<typeof setTimeout> | null} */ (null),
|
||||
finish() {
|
||||
if (state.settled) return
|
||||
state.settled = true
|
||||
if (state.timeout) clearTimeout(state.timeout)
|
||||
self.pendingSearches.delete(id)
|
||||
resolve(results.flat())
|
||||
}
|
||||
}
|
||||
state.timeout = setTimeout(() => state.finish(), 3000)
|
||||
self.pendingSearches.set(id, state)
|
||||
for (const peer of peerList) {
|
||||
void peer.chan.fullyOpened().then((opened) => {
|
||||
if (!opened) return
|
||||
if (state.settled) return
|
||||
try {
|
||||
peer.chan.messages[3].send({ id, query })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)pair `bare-os-chat-v1` on every live mux — used after `ensureDiskBareOsChatTransport` replaces
|
||||
* the service (guest → user, logout → guest) so `peer.chatChan` and handlers match the active instance.
|
||||
*/
|
||||
async pairBareOsChatExistingPeers() {
|
||||
if (!bareOsChatMuxEnabled(globalThis.process?.env)) return
|
||||
const svc = this.bareOsChatService
|
||||
if (!svc || typeof svc.pairOnMux !== 'function') return
|
||||
for (const peer of [...this.peers]) {
|
||||
const st = peer.mux && peer.mux.stream
|
||||
if (!st || st.destroyed) continue
|
||||
try {
|
||||
const mux = peer.mux
|
||||
const wired = peer.chatChan
|
||||
/** @type {Promise<unknown>[]} */
|
||||
const closing = []
|
||||
if (wired && typeof wired.close === 'function') {
|
||||
try {
|
||||
wired.close()
|
||||
if (typeof wired.fullyClosed === 'function')
|
||||
closing.push(wired.fullyClosed())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
peer.chatChan = null
|
||||
const prevCh =
|
||||
mux &&
|
||||
typeof mux.getLastChannel === 'function' &&
|
||||
mux.getLastChannel({ protocol: PROTOCOL_CHAT_CHANNEL_NAME })
|
||||
if (prevCh && typeof prevCh.close === 'function' && prevCh !== wired) {
|
||||
try {
|
||||
prevCh.close()
|
||||
if (typeof prevCh.fullyClosed === 'function')
|
||||
closing.push(prevCh.fullyClosed())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (closing.length) await Promise.all(closing)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (typeof peer.mux.unpair === 'function') {
|
||||
try {
|
||||
peer.mux.unpair({ protocol: PROTOCOL_CHAT_CHANNEL_NAME })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
svc.pairOnMux(this, peer.mux, peer.socket, peer)
|
||||
} catch (e) {
|
||||
emitSwarmDiskHostLog('warn', 'bare_os_chat_late_pair_failed', {
|
||||
message:
|
||||
(e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)pair `bare-os-meshdrop-v1` on every live mux — used after meshdrop service env/user transitions.
|
||||
*/
|
||||
pairBareOsMeshdropExistingPeers() {
|
||||
if (!bareOsMeshdropMuxEnabled(globalThis.process?.env)) return
|
||||
const svc = this.bareOsMeshdropService
|
||||
if (!svc || typeof svc.pairOnMux !== 'function') return
|
||||
for (const peer of [...this.peers]) {
|
||||
const st = peer.mux && peer.mux.stream
|
||||
if (!st || st.destroyed) continue
|
||||
try {
|
||||
const mux = peer.mux
|
||||
const wired = peer.meshdropChan
|
||||
if (wired && typeof wired.close === 'function') {
|
||||
try {
|
||||
wired.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
peer.meshdropChan = null
|
||||
const prevCh =
|
||||
mux &&
|
||||
typeof mux.getLastChannel === 'function' &&
|
||||
mux.getLastChannel({ protocol: PROTOCOL_MESHDROP_CHANNEL_NAME })
|
||||
if (prevCh && typeof prevCh.close === 'function' && prevCh !== wired) {
|
||||
try {
|
||||
prevCh.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
svc.pairOnMux(this, peer.mux, peer.socket, peer)
|
||||
if (typeof peer.mux.unpair === 'function') {
|
||||
try {
|
||||
peer.mux.unpair({ protocol: PROTOCOL_MESHDROP_CHANNEL_NAME })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
emitSwarmDiskHostLog('warn', 'bare_os_meshdrop_late_pair_failed', {
|
||||
message:
|
||||
(e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Parse optional Hyperswarm connection-budget knobs from env.
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function bareOsHyperswarmOptsFromEnv(env) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const o = {}
|
||||
const peerCap = String(env?.BARE_OS_SWARM_MAX_PEERS ?? '').trim()
|
||||
if (peerCap) {
|
||||
const x = Number(peerCap)
|
||||
if (Number.isFinite(x) && x >= 1) o.maxPeers = Math.min(4096, Math.floor(x))
|
||||
}
|
||||
const mcc = String(env?.BARE_OS_SWARM_MAX_CLIENT_CONNECTIONS ?? '').trim()
|
||||
if (mcc) {
|
||||
const x = Number(mcc)
|
||||
if (Number.isFinite(x) && x >= 1) {
|
||||
o.maxClientConnections = Math.min(1_000_000, Math.floor(x))
|
||||
}
|
||||
}
|
||||
const msc = String(env?.BARE_OS_SWARM_MAX_SERVER_CONNECTIONS ?? '').trim()
|
||||
if (msc) {
|
||||
const x = Number(msc)
|
||||
if (Number.isFinite(x) && x >= 1) {
|
||||
o.maxServerConnections = Math.min(1_000_000, Math.floor(x))
|
||||
}
|
||||
}
|
||||
const mpar = String(env?.BARE_OS_SWARM_MAX_PARALLEL ?? '').trim()
|
||||
if (mpar) {
|
||||
const x = Number(mpar)
|
||||
if (Number.isFinite(x) && x >= 1) o.maxParallel = Math.min(64, Math.floor(x))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional direct-peer target for Hyperswarm joinPeer().
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function bareOsBootJoinPeerHexFromEnv(env) {
|
||||
const raw = String(env?.BARE_OS_BOOT_JOIN_PEER_HEX ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!raw) return null
|
||||
if (!/^[0-9a-f]{64}$/.test(raw)) return null
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator JSON merged into `replication_operator_sketch.protomuxOperatorSketch`.
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
*/
|
||||
export function bareOsProtomuxTuningFromEnv(env) {
|
||||
const raw = String(env?.BARE_OS_PROTOMUX_TUNING_JSON ?? '').trim()
|
||||
if (!raw) return null
|
||||
try {
|
||||
const o = JSON.parse(raw)
|
||||
return o && typeof o === 'object' && !Array.isArray(o) ? o : null
|
||||
} catch {
|
||||
return { schema: 1, parseError: true }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user