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 }>} */ const managedInstances = new Map() /** @type {(() => void)[]} */ let managedHookUnsubs = [] let managedServiceRunning = false /** * @param {Record} 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} env * @param {string} [_id] */ export function bareHolesailManagedEnvForConnectionId(env, _id) { return env } function logLine(ctx, line) { void appendVarLog(ctx, HOLESAIL_LOG, 'holesail', line) } /** * @param {Record} 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} raw * @returns {{ ok: true, entry: Record } | { 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} */ (raw).server ) const client = bareHolesailEnvTruthy( /** @type {Record} */ (raw).client ) if (server === client) { return { ok: false, err: 'set exactly one of server or client' } } /** @type {Record} */ 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} conn * @param {Record} 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} conn one `state.connections[id]` object * @param {Record} env * @returns {Record} */ export function bareHolesailManagedNewHolesailOpts(conn, env) { const c = /** @type {Record} */ (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} */ 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} */ (parsed) const conns = o.connections const connections = conns && typeof conns === 'object' && !Array.isArray(conns) ? /** @type {Record} */ (conns) : {} return { version: 1, connections } } /** * @param {{ version: number, connections: Record }} a * @param {{ version: number, connections: Record }} 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} */ (cur) const n = /** @type {Record} */ (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} ctx * @param {{ readFile: (p: string) => Promise }} 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} ctx * @param {Record} env * @param {string} primaryPath * @returns {Promise<{ version: number, connections: Record } | 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 }} */ 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} ctx * @param {Record} 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} ctx * @param {Record} env * @param {{ version: number, connections: Record }} 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} ctx * @param {Record} 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} */ (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} ctx * @param {Record} 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} ctx * @param {Record} env * @param {string} id * @returns {Promise} 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} ctx * @param {Record} 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} */ (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} */ (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} */ (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} */ (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} ctx * @param {Record} 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} */ (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} */ (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} 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} ctx * @param {Record} 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} ctx * @param {Record} 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} 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} ctx * @param {Record} 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} */ (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} 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) }