928 lines
29 KiB
JavaScript
928 lines
29 KiB
JavaScript
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)
|
||
} |