This commit is contained in:
2026-08-18 18:11:28 -04:00
parent 0e9a650fa2
commit bbaf47028f
259 changed files with 0 additions and 0 deletions
@@ -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 (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)
}