Files
bare-operating-system/packages/bare-os-booter/lib/p2p/bare-holesail-managed.js
T
2026-08-18 18:11:34 -04:00

928 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../vfs/bare-os-var-log.js'
import { bindFallbackEnabled, isAddrInUse } from '../posix/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 (164 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)
}