Orginize
This commit is contained in:
@@ -0,0 +1,928 @@
|
||||
import { dirname } from '#host-path'
|
||||
|
||||
/**
|
||||
* Multi-tunnel Holesail manager: persisted state on the VFS, one Holesail instance per entry.
|
||||
* Used when managed mode is on (stock default with BARE_OS_HOLESAIL_INITD/MANAGED).
|
||||
*
|
||||
* State path: **`BARE_OS_HOLESAIL_STATE`** override, else **`~/.holesail/state.json`** (resolves under **`$HOME`**).
|
||||
* Managed Holesail starts only after unlock; legacy **`/.bare/holesail/**`** files may be merged on first read.
|
||||
*
|
||||
* **Server identity** — **`seed`** is the **64-hex** ctor secret (`bare-crypto`); it is passed to **`Holesail({ key })`**
|
||||
* and **must not** be replaced with the z32 URL suffix (different input ⇒ different tunnel). **`key`** holds the full
|
||||
* **`hs://…`** from **`hs.info.url`** after **`ready()`** for sharing. Legacy rows may have only **`key`** (no **`seed`**).
|
||||
* Upstream holesail is AGPL-3.0.
|
||||
*/
|
||||
import { randomBytes } from 'bare-crypto'
|
||||
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
|
||||
import { loadHolesailConstructor } from './bare-holesail-loader.js'
|
||||
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
|
||||
import { bindFallbackEnabled, isAddrInUse } from './bare-os-bind-fallback.js'
|
||||
|
||||
/** Per-user managed state (unlocked shells; resolves under `$HOME`). */
|
||||
export const BARE_HOLESAIL_STATE_USER = '~/.holesail/state.json'
|
||||
|
||||
/** Legacy guest-only managed state (migration source only). */
|
||||
export const BARE_HOLESAIL_STATE_GUEST = '/.bare/holesail/guest/state.json'
|
||||
|
||||
/** Pre-split single file at personal root (migration source only). */
|
||||
export const BARE_HOLESAIL_STATE_LEGACY_ROOT = '/.bare/holesail/state.json'
|
||||
|
||||
/** @deprecated Use {@link BARE_HOLESAIL_STATE_LEGACY_ROOT}. */
|
||||
export const BARE_HOLESAIL_STATE_STABLE = BARE_HOLESAIL_STATE_LEGACY_ROOT
|
||||
|
||||
/** @deprecated Alias for {@link BARE_HOLESAIL_STATE_USER}. */
|
||||
export const BARE_HOLESAIL_STATE_LEGACY = BARE_HOLESAIL_STATE_USER
|
||||
|
||||
const HOLESAIL_LOG = `${BARE_OS_VAR_LOG_DIR}/holesail.log`
|
||||
|
||||
/** @type {Map<string, { hs: unknown, entry: Record<string, unknown> }>} */
|
||||
const managedInstances = new Map()
|
||||
|
||||
/** @type {(() => void)[]} */
|
||||
let managedHookUnsubs = []
|
||||
|
||||
let managedServiceRunning = false
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedStatePath(env) {
|
||||
const raw = String(env.BARE_OS_HOLESAIL_STATE || '').trim()
|
||||
if (raw) return raw
|
||||
return BARE_HOLESAIL_STATE_USER
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} [_id]
|
||||
*/
|
||||
export function bareHolesailManagedEnvForConnectionId(env, _id) {
|
||||
return env
|
||||
}
|
||||
|
||||
function logLine(ctx, line) {
|
||||
void appendVarLog(ctx, HOLESAIL_LOG, 'holesail', line)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} dir
|
||||
*/
|
||||
async function ensureDir(ctx, dir) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.mkdir !== 'function') return
|
||||
try {
|
||||
await vfs.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} hs
|
||||
*/
|
||||
async function safeCloseHolesail(hs) {
|
||||
if (!hs || typeof hs.close !== 'function') return
|
||||
try {
|
||||
await hs.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedValidateId(id) {
|
||||
const s = String(id || '').trim()
|
||||
if (!s || s.length > 64) return { ok: false, err: 'invalid id (1–64 chars)' }
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(s))
|
||||
return { ok: false, err: 'invalid id (use [a-zA-Z0-9._-])' }
|
||||
return { ok: true, id: s }
|
||||
}
|
||||
|
||||
/** 32 bytes hex — upstream holesail expects `key` length ≥ 32 chars for servers. */
|
||||
const HOLESAIL_SEED_HEX_LEN = 64
|
||||
|
||||
/**
|
||||
* New server tunnel secret (persist in **`state.connections[id].seed`**; passed to **`Holesail({ key })`**).
|
||||
*/
|
||||
export function bareHolesailManagedGenerateSeedHex() {
|
||||
return randomBytes(HOLESAIL_SEED_HEX_LEN / 2).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
export function bareHolesailManagedSeedLooksValid(s) {
|
||||
return /^[0-9a-f]{64}$/i.test(String(s || '').trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract holesail **`key`** material from a full **`hs://…`** URL or bare connection string (matches upstream **`urlParser`**).
|
||||
* @param {string} urlOrHs
|
||||
*/
|
||||
export function bareHolesailManagedHsUrlKeySuffix(urlOrHs) {
|
||||
const u = String(urlOrHs || '').trim()
|
||||
if (u.length < 10) return ''
|
||||
if (u.substring(0, 5).toLowerCase() !== 'hs://') return ''
|
||||
return u.substring(9)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid persisted **`seed`**: 64-char hex (pre-mint) or holesail **z32-style** suffix (≥32 chars, alphanumeric).
|
||||
* @param {string} s
|
||||
*/
|
||||
export function bareHolesailManagedPersistentSeedLooksValid(s) {
|
||||
const t = String(s || '').trim()
|
||||
if (!t) return false
|
||||
if (/^[0-9a-f]{64}$/i.test(t)) return true
|
||||
if (t.length >= 32 && t.length <= 128 && /^[a-z0-9]+$/i.test(t)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} raw
|
||||
* @returns {{ ok: true, entry: Record<string, unknown> } | { ok: false, err: string }}
|
||||
*/
|
||||
export function bareHolesailManagedNormalizeEntry(raw) {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
return { ok: false, err: 'connection entry must be an object' }
|
||||
const server = bareHolesailEnvTruthy(
|
||||
/** @type {Record<string, unknown>} */ (raw).server
|
||||
)
|
||||
const client = bareHolesailEnvTruthy(
|
||||
/** @type {Record<string, unknown>} */ (raw).client
|
||||
)
|
||||
if (server === client) {
|
||||
return { ok: false, err: 'set exactly one of server or client' }
|
||||
}
|
||||
/** @type {Record<string, unknown>} */
|
||||
const entry = { server, client }
|
||||
const key = String(raw.key ?? '').trim()
|
||||
const seed = String(raw.seed ?? '').trim()
|
||||
if (client && !key) return { ok: false, err: 'client requires key' }
|
||||
|
||||
if (seed) {
|
||||
if (!bareHolesailManagedPersistentSeedLooksValid(seed))
|
||||
return {
|
||||
ok: false,
|
||||
err: 'invalid seed (64 hex chars or hs:// key suffix / z32)'
|
||||
}
|
||||
entry.seed = seed.toLowerCase()
|
||||
}
|
||||
|
||||
if (key) entry.key = key
|
||||
|
||||
if (raw.secure !== undefined && raw.secure !== null && String(raw.secure) !== '')
|
||||
entry.secure = bareHolesailEnvTruthy(raw.secure)
|
||||
|
||||
if (raw.port != null && raw.port !== '') {
|
||||
const n = Number.parseInt(String(raw.port), 10)
|
||||
if (!Number.isFinite(n)) return { ok: false, err: 'invalid port' }
|
||||
entry.port = n
|
||||
}
|
||||
|
||||
const host = String(raw.host ?? '').trim()
|
||||
if (host) entry.host = host
|
||||
|
||||
if (raw.udp !== undefined && raw.udp !== null && String(raw.udp) !== '')
|
||||
entry.udp = bareHolesailEnvTruthy(raw.udp)
|
||||
|
||||
if (raw.log !== undefined && raw.log !== null && raw.log !== '') {
|
||||
if (typeof raw.log === 'number') entry.log = raw.log
|
||||
else if (/^\d+$/.test(String(raw.log)))
|
||||
entry.log = Number.parseInt(String(raw.log), 10)
|
||||
else entry.log = bareHolesailEnvTruthy(raw.log)
|
||||
}
|
||||
|
||||
const enabled =
|
||||
raw.enabled === undefined || raw.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(raw.enabled)
|
||||
entry.enabled = enabled
|
||||
|
||||
return { ok: true, entry }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare persisted shareable URL / key string to live `hs.info.url` (case-insensitive trim).
|
||||
* @param {string} persisted
|
||||
* @param {string} live
|
||||
*/
|
||||
export function bareHolesailManagedPersistedUrlMatchesLive(persisted, live) {
|
||||
const p = String(persisted ?? '').trim().toLowerCase()
|
||||
const l = String(live ?? '').trim().toLowerCase()
|
||||
if (!p || !l) return false
|
||||
return p === l
|
||||
}
|
||||
|
||||
/**
|
||||
* `log` for managed tunnels: row → `BARE_OS_HOLESAIL_MANAGED_LOG` / `BARE_OS_HOLESAIL_LOG` → INFO (1),
|
||||
* or DEBUG (0) when `BARE_OS_HOLESAIL_DEBUG` is set (reference scripts often use `log: 0`).
|
||||
* @param {Record<string, unknown>} conn
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedLogLevel(conn, env) {
|
||||
if (conn.log !== undefined && conn.log !== null && conn.log !== '') {
|
||||
if (typeof conn.log === 'number') return conn.log
|
||||
if (/^\d+$/.test(String(conn.log)))
|
||||
return Number.parseInt(String(conn.log), 10)
|
||||
return bareHolesailEnvTruthy(conn.log) ? 1 : 0
|
||||
}
|
||||
const raw =
|
||||
env.BARE_OS_HOLESAIL_MANAGED_LOG ?? env.BARE_OS_HOLESAIL_LOG ?? ''
|
||||
const s = String(raw).trim()
|
||||
if (s !== '' && /^\d+$/.test(s)) {
|
||||
const n = Number.parseInt(s, 10)
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 1
|
||||
}
|
||||
if (bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_DEBUG)) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for `new Holesail(opts)`.
|
||||
*
|
||||
* **Server** — Ctor `key` field: **64-hex `seed`** first, then a valid **persisted non-hex seed** (z32 suffix migrated
|
||||
* from **`hs://…`**), then the full **`hs://…`** **`key`** (legacy). Never replace a hex **`seed`** with only the URL
|
||||
* suffix written as ctor material — upstream hashes the ctor string; hex ≠ z32 ⇒ different URLs.
|
||||
*
|
||||
* **Client** — Passthrough **`key`** (typically **`hs://…`**).
|
||||
*
|
||||
* @param {Record<string, unknown>} conn one `state.connections[id]` object
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function bareHolesailManagedNewHolesailOpts(conn, env) {
|
||||
const c = /** @type {Record<string, unknown>} */ (conn)
|
||||
const seedStr = c.seed != null ? String(c.seed).trim() : ''
|
||||
const keyFull = c.key != null ? String(c.key).trim() : ''
|
||||
|
||||
const server = bareHolesailEnvTruthy(c.server)
|
||||
const client = bareHolesailEnvTruthy(c.client)
|
||||
|
||||
let secure = false
|
||||
if (c.secure !== undefined && c.secure !== null && String(c.secure) !== '') {
|
||||
secure = bareHolesailEnvTruthy(c.secure)
|
||||
} else if (seedStr && bareHolesailManagedSeedLooksValid(seedStr)) {
|
||||
secure = false
|
||||
} else if (keyFull) {
|
||||
secure = /^hs:\/\/s/i.test(keyFull)
|
||||
}
|
||||
|
||||
let udp = false
|
||||
if (c.udp !== undefined && c.udp !== null && String(c.udp) !== '')
|
||||
udp = bareHolesailEnvTruthy(c.udp)
|
||||
|
||||
const log = bareHolesailManagedLogLevel(conn, env)
|
||||
|
||||
/** @type {string} */
|
||||
let keyOpt = ''
|
||||
if (server) {
|
||||
if (bareHolesailManagedSeedLooksValid(seedStr)) keyOpt = seedStr.toLowerCase()
|
||||
else if (
|
||||
seedStr &&
|
||||
bareHolesailManagedPersistentSeedLooksValid(seedStr)
|
||||
)
|
||||
keyOpt = seedStr.toLowerCase()
|
||||
else if (/^hs:\/\//i.test(keyFull)) keyOpt = keyFull
|
||||
else if (seedStr) keyOpt = seedStr.toLowerCase()
|
||||
else keyOpt = keyFull
|
||||
} else {
|
||||
keyOpt = keyFull
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const opts = {
|
||||
server,
|
||||
client,
|
||||
port: c.port,
|
||||
host: c.host != null ? String(c.host) : undefined,
|
||||
key: keyOpt,
|
||||
secure,
|
||||
udp
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
function coerceState(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { version: 1, connections: {} }
|
||||
}
|
||||
const o = /** @type {Record<string, unknown>} */ (parsed)
|
||||
const conns = o.connections
|
||||
const connections =
|
||||
conns && typeof conns === 'object' && !Array.isArray(conns)
|
||||
? /** @type {Record<string, unknown>} */ (conns)
|
||||
: {}
|
||||
return { version: 1, connections }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} a
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} b
|
||||
*/
|
||||
function mergeCoercedHolesailStates(a, b) {
|
||||
const conns = { ...a.connections }
|
||||
for (const [id, raw] of Object.entries(b.connections || {})) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const cur = conns[id]
|
||||
if (!cur || typeof cur !== 'object') {
|
||||
conns[id] = raw
|
||||
continue
|
||||
}
|
||||
const c = /** @type {Record<string, unknown>} */ (cur)
|
||||
const n = /** @type {Record<string, unknown>} */ (raw)
|
||||
const ck = String(c.key ?? '').trim()
|
||||
const nk = String(n.key ?? '').trim()
|
||||
const cseed = String(c.seed ?? '').trim()
|
||||
const nseed = String(n.seed ?? '').trim()
|
||||
if (nk && !ck) conns[id] = { ...c, ...n, key: nk }
|
||||
else if (ck && !nk) conns[id] = { ...n, ...c, key: ck }
|
||||
else if (nseed && !cseed) conns[id] = { ...c, ...n, seed: nseed }
|
||||
else if (cseed && !nseed) conns[id] = { ...n, ...c, seed: cseed }
|
||||
else conns[id] = { ...c, ...n }
|
||||
}
|
||||
return { version: 1, connections: conns }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ readFile: (p: string) => Promise<unknown> }} vfs
|
||||
* @param {string} logicalPath
|
||||
*/
|
||||
async function tryReadCoercedStateFile(ctx, vfs, logicalPath) {
|
||||
try {
|
||||
const buf = await vfs.readFile(logicalPath)
|
||||
const text = ctx.b4a
|
||||
? ctx.b4a.toString(buf, 'utf8')
|
||||
: Buffer.from(buf).toString('utf8')
|
||||
return coerceState(JSON.parse(text))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time upgrade when the primary state file is missing or empty: merge legacy sources
|
||||
* (`/.bare/holesail/state.json`, old guest file, `/home/guest/.holesail/state.json`).
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} primaryPath
|
||||
* @returns {Promise<{ version: number, connections: Record<string, unknown> } | null>}
|
||||
*/
|
||||
async function bareHolesailManagedTryMigrateLegacyState(ctx, env, primaryPath) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.resolveLogical !== 'function')
|
||||
return null
|
||||
|
||||
void primaryPath
|
||||
/** @type {string[]} */
|
||||
const paths = [
|
||||
BARE_HOLESAIL_STATE_LEGACY_ROOT,
|
||||
BARE_HOLESAIL_STATE_GUEST,
|
||||
'/home/guest/.holesail/state.json'
|
||||
]
|
||||
|
||||
/** @type {{ version: number, connections: Record<string, unknown> }} */
|
||||
let merged = { version: 1, connections: {} }
|
||||
const seen = new Set()
|
||||
for (const p of paths) {
|
||||
const norm = String(p || '').trim()
|
||||
if (!norm || seen.has(norm)) continue
|
||||
seen.add(norm)
|
||||
const chunk = await tryReadCoercedStateFile(ctx, vfs, norm)
|
||||
if (chunk && Object.keys(chunk.connections).length > 0) {
|
||||
merged = mergeCoercedHolesailStates(merged, chunk)
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged.connections).length === 0) return null
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export async function bareHolesailManagedReadState(ctx, env) {
|
||||
const path = bareHolesailManagedStatePath(env)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function') {
|
||||
return { path, state: { version: 1, connections: {} } }
|
||||
}
|
||||
try {
|
||||
const buf = await vfs.readFile(path)
|
||||
const text = ctx.b4a ? ctx.b4a.toString(buf, 'utf8') : Buffer.from(buf).toString('utf8')
|
||||
const parsed = JSON.parse(text)
|
||||
const state = coerceState(parsed)
|
||||
if (Object.keys(state.connections).length > 0) return { path, state }
|
||||
} catch {
|
||||
/* missing or corrupt */
|
||||
}
|
||||
const migrated = await bareHolesailManagedTryMigrateLegacyState(ctx, env, path)
|
||||
if (migrated && Object.keys(migrated.connections).length > 0) {
|
||||
try {
|
||||
await bareHolesailManagedWriteState(ctx, env, migrated)
|
||||
} catch {
|
||||
/* return merged in-memory even if persist fails */
|
||||
}
|
||||
return { path, state: migrated }
|
||||
}
|
||||
return { path, state: { version: 1, connections: {} } }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {{ version: number, connections: Record<string, unknown> }} state
|
||||
*/
|
||||
export async function bareHolesailManagedWriteState(ctx, env, state) {
|
||||
const path = bareHolesailManagedStatePath(env)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.writeFile !== 'function') {
|
||||
throw new Error('holesail: vfs.writeFile unavailable')
|
||||
}
|
||||
await ensureDir(ctx, dirname(path))
|
||||
const body = JSON.stringify(state, null, 2) + '\n'
|
||||
await vfs.writeFile(path, ctx.b4a.from(body))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a managed **server** row has a **stable ctor secret** before `new Holesail`.
|
||||
*
|
||||
* - **64-hex `seed`** — authoritative; never replaced by sync.
|
||||
* - **Persisted z32 `seed`** (from migration or upstream) — same as ctor material; **do not clear** (was a bug).
|
||||
* - **`hs://…` `key` only** (legacy) — migrate suffix into **`seed`** or mint **64-hex** **`seed`** (`bare-crypto`).
|
||||
*
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedEnsureServerSeedPersisted(ctx, env, id) {
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[id]
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
const row = /** @type {Record<string, unknown>} */ (raw)
|
||||
if (!bareHolesailEnvTruthy(row.server)) return
|
||||
|
||||
const seedRaw = String(row.seed ?? '').trim()
|
||||
const key = String(row.key ?? '').trim()
|
||||
|
||||
if (seedRaw && bareHolesailManagedSeedLooksValid(seedRaw)) {
|
||||
row.seed = seedRaw.toLowerCase()
|
||||
state.connections[id] = row
|
||||
return
|
||||
}
|
||||
|
||||
if (seedRaw && bareHolesailManagedPersistentSeedLooksValid(seedRaw)) {
|
||||
row.seed = seedRaw.toLowerCase()
|
||||
state.connections[id] = row
|
||||
return
|
||||
}
|
||||
|
||||
if (/^hs:\/\//i.test(key)) {
|
||||
const sfx = bareHolesailManagedHsUrlKeySuffix(key)
|
||||
if (sfx && bareHolesailManagedPersistentSeedLooksValid(sfx)) {
|
||||
row.seed = sfx.toLowerCase()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: persisted hs url suffix as seed for ${id}`)
|
||||
return
|
||||
}
|
||||
row.seed = bareHolesailManagedGenerateSeedHex()
|
||||
delete row.key
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: minted new hex seed (hs key had no stable suffix) for ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (bareHolesailManagedSeedLooksValid(key)) {
|
||||
row.seed = key.toLowerCase()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: migrated hex key → seed for ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
row.seed = bareHolesailManagedGenerateSeedHex()
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
logLine(ctx, `managed: persisted new server seed for ${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareHolesailManagedDebugEnabled(ctx, env) {
|
||||
return bareHolesailEnvTruthy(env.BARE_OS_HOLESAIL_DEBUG)
|
||||
}
|
||||
|
||||
/**
|
||||
* When a managed **server** is live, persist **`hs.info.url`** into **`key`** only.
|
||||
* **Never** overwrite **`seed`** (64-hex ctor secret): replacing it with the z32 URL suffix changes the ctor input
|
||||
* and mints a **different** tunnel on the next login.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>} true if `state.json` was written
|
||||
*/
|
||||
/**
|
||||
* @param {unknown} hs
|
||||
* @param {number} cfgPort
|
||||
*/
|
||||
function bareHolesailManagedResolvedListenPort(hs, cfgPort) {
|
||||
try {
|
||||
const info =
|
||||
hs && typeof hs === 'object' && 'info' in hs
|
||||
? /** @type {{ info?: unknown }} */ (hs).info
|
||||
: null
|
||||
if (info && typeof info === 'object') {
|
||||
const p = Number(/** @type {{ port?: unknown }} */ (info).port)
|
||||
if (Number.isFinite(p) && p > 0) return p
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const n = Number(cfgPort)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
* @param {number} newPort
|
||||
* @param {number} previousPort
|
||||
*/
|
||||
async function persistManagedListenPortAfterFallback(
|
||||
ctx,
|
||||
env,
|
||||
id,
|
||||
newPort,
|
||||
previousPort
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(newPort) ||
|
||||
newPort <= 0 ||
|
||||
!Number.isFinite(previousPort) ||
|
||||
previousPort <= 0 ||
|
||||
newPort === previousPort
|
||||
)
|
||||
return
|
||||
try {
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const cur = state.connections[id]
|
||||
if (!cur || typeof cur !== 'object') return
|
||||
const row = /** @type {Record<string, unknown>} */ (cur)
|
||||
row.port = newPort
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
const rec = managedInstances.get(id)
|
||||
if (rec?.entry && typeof rec.entry === 'object') {
|
||||
/** @type {Record<string, unknown>} */ (rec.entry).port = newPort
|
||||
}
|
||||
logLine(
|
||||
ctx,
|
||||
`managed: persisted listen port ${newPort} (fallback from ${previousPort}) for ${id}`
|
||||
)
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: persist listen port ${id}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function bareHolesailManagedSyncPersistedServerKey(ctx, env, id) {
|
||||
const url = bareHolesailManagedRuntimeUrl(id).trim()
|
||||
if (!url) return false
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const cur = state.connections[id]
|
||||
if (!cur || typeof cur !== 'object') return false
|
||||
const row = /** @type {Record<string, unknown>} */ (cur)
|
||||
if (!bareHolesailEnvTruthy(row.server)) return false
|
||||
|
||||
const prev = String(row.key ?? '').trim()
|
||||
if (prev && bareHolesailManagedPersistedUrlMatchesLive(prev, url)) return false
|
||||
|
||||
row.key = url
|
||||
state.connections[id] = row
|
||||
await bareHolesailManagedWriteState(ctx, env, state)
|
||||
const rec = managedInstances.get(id)
|
||||
if (rec && rec.entry && typeof rec.entry === 'object') {
|
||||
/** @type {Record<string, unknown>} */ (rec.entry).key = url
|
||||
}
|
||||
logLine(ctx, `managed: synced server hs url for ${id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one managed tunnel from the **current** `state.json` row for `id` (no normalize — uses disk as-is).
|
||||
* Re-reads state immediately before `new Holesail` so `key` matches `~/.holesail/state.json`, including full `hs://…`.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
async function startManagedInstance(ctx, env, id) {
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(ctx, env, id)
|
||||
await yieldForStateCoalescence()
|
||||
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[id]
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
|
||||
const conn = /** @type {Record<string, unknown>} */ (raw)
|
||||
|
||||
const enabled =
|
||||
conn.enabled === undefined || conn.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(conn.enabled)
|
||||
if (!enabled) return
|
||||
|
||||
const server = bareHolesailEnvTruthy(conn.server)
|
||||
const client = bareHolesailEnvTruthy(conn.client)
|
||||
if (server === client) {
|
||||
logLine(ctx, `managed: skip ${id}: set exactly one of server or client`)
|
||||
return
|
||||
}
|
||||
|
||||
const keyStr = String(conn.key ?? '').trim()
|
||||
const seedStr = String(conn.seed ?? '').trim()
|
||||
if (client && !keyStr) {
|
||||
logLine(ctx, `managed: skip ${id}: client requires key`)
|
||||
return
|
||||
}
|
||||
|
||||
if (managedInstances.has(id)) {
|
||||
if (!server) return
|
||||
|
||||
const live = bareHolesailManagedRuntimeUrl(id).trim()
|
||||
const rec = managedInstances.get(id)
|
||||
const prevRow =
|
||||
rec?.entry && typeof rec.entry === 'object'
|
||||
? /** @type {Record<string, unknown>} */ (rec.entry)
|
||||
: null
|
||||
const prevSeed = prevRow ? String(prevRow.seed ?? '').trim() : ''
|
||||
|
||||
if (seedStr && prevSeed && seedStr.toLowerCase() === prevSeed.toLowerCase() && live)
|
||||
return
|
||||
|
||||
if (
|
||||
keyStr &&
|
||||
live &&
|
||||
bareHolesailManagedPersistedUrlMatchesLive(keyStr, live) &&
|
||||
(!seedStr || seedStr.toLowerCase() === prevSeed.toLowerCase())
|
||||
)
|
||||
return
|
||||
|
||||
await stopManagedInstance(ctx, id)
|
||||
}
|
||||
|
||||
const Holesail = await loadHolesailConstructor(ctx)
|
||||
const cfg = bareHolesailManagedNewHolesailOpts(conn, env)
|
||||
if (server && !String(cfg.key ?? '').trim()) {
|
||||
logLine(ctx, `managed: skip ${id}: server ctor key material empty after ensure`)
|
||||
return
|
||||
}
|
||||
const requestedPort =
|
||||
cfg.port !== undefined && cfg.port !== null ? Number(cfg.port) : NaN
|
||||
|
||||
let hs = new Holesail(cfg)
|
||||
try {
|
||||
await hs.ready()
|
||||
} catch (e) {
|
||||
if (!bindFallbackEnabled(env) || !isAddrInUse(e)) throw e
|
||||
await safeCloseHolesail(hs)
|
||||
cfg.port = 0
|
||||
hs = new Holesail(cfg)
|
||||
await hs.ready()
|
||||
logLine(
|
||||
ctx,
|
||||
`managed: ${id} ready after EADDRINUSE fallback (ephemeral port)`
|
||||
)
|
||||
}
|
||||
managedInstances.set(id, { hs, entry: { ...conn } })
|
||||
|
||||
const cfgPortAfter =
|
||||
typeof cfg.port === 'number'
|
||||
? cfg.port
|
||||
: cfg.port != null
|
||||
? Number(cfg.port)
|
||||
: NaN
|
||||
const resolved = bareHolesailManagedResolvedListenPort(hs, cfgPortAfter)
|
||||
if (
|
||||
bindFallbackEnabled(env) &&
|
||||
Number.isFinite(requestedPort) &&
|
||||
requestedPort > 0 &&
|
||||
resolved > 0 &&
|
||||
resolved !== requestedPort
|
||||
) {
|
||||
await persistManagedListenPortAfterFallback(
|
||||
ctx,
|
||||
env,
|
||||
id,
|
||||
resolved,
|
||||
requestedPort
|
||||
)
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await bareHolesailManagedSyncPersistedServerKey(ctx, env, id)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: persist key ${id} skipped: ${msg}`)
|
||||
}
|
||||
}
|
||||
logLine(ctx, `managed: started ${id}`)
|
||||
if (bareHolesailManagedDebugEnabled(ctx, env)) {
|
||||
try {
|
||||
const info = hs.info
|
||||
const url = info && typeof info === 'object' ? info.url : ''
|
||||
ctx.console?.log?.(`[bare-holesail] managed ${id} url=${String(url).slice(0, 120)}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} id
|
||||
*/
|
||||
async function stopManagedInstance(ctx, id) {
|
||||
const rec = managedInstances.get(id)
|
||||
if (!rec) return
|
||||
managedInstances.delete(id)
|
||||
await safeCloseHolesail(rec.hs)
|
||||
logLine(ctx, `managed: stopped ${id}`)
|
||||
}
|
||||
|
||||
/** Yield one tick so `bare-os-www` / `bare-os-ssh-holesail` (and other writers) can finish persisting `~/.holesail/state.json`. */
|
||||
function yieldForStateCoalescence() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof globalThis.setImmediate === 'function') globalThis.setImmediate(resolve)
|
||||
else queueMicrotask(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start every enabled connection from a fresh read of disk (used for initial start + reconcile pass).
|
||||
* **`onlyNew`** — when `true`, skip ids already live in **`managedInstances`** so a second pass can pick up
|
||||
* connections written after the first pass (e.g. **bare-os-www**, **bare-openssh**) without destroying running tunnels.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {{ onlyNew?: boolean }} [opts]
|
||||
*/
|
||||
async function startManagedConnectionsFromDisk(ctx, env, opts = {}) {
|
||||
const onlyNew = opts.onlyNew === true
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const idsSorted = Object.keys(state.connections).sort()
|
||||
for (const id of idsSorted) {
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) {
|
||||
logLine(ctx, `managed: skip invalid id ${id}: ${idOk.err}`)
|
||||
continue
|
||||
}
|
||||
if (onlyNew && managedInstances.has(idOk.id)) continue
|
||||
try {
|
||||
await startManagedInstance(ctx, env, idOk.id)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
logLine(ctx, `managed: start ${id} failed: ${msg}`)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-holesail] managed ${id}: ${msg}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export async function bareHolesailManagedStartService(ctx, env) {
|
||||
if (managedServiceRunning) return
|
||||
|
||||
managedServiceRunning = true
|
||||
|
||||
await startManagedConnectionsFromDisk(ctx, env)
|
||||
await yieldForStateCoalescence()
|
||||
await startManagedConnectionsFromDisk(ctx, env, { onlyNew: true })
|
||||
|
||||
managedHookUnsubs.push(
|
||||
ctx.bareOsRegisterSuspendHook(async () => {
|
||||
for (const { hs } of managedInstances.values()) {
|
||||
if (hs && typeof hs.pause === 'function') {
|
||||
try {
|
||||
await hs.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
managedHookUnsubs.push(
|
||||
ctx.bareOsRegisterResumeHook(async () => {
|
||||
for (const { hs } of managedInstances.values()) {
|
||||
if (hs && typeof hs.resume === 'function') {
|
||||
try {
|
||||
await hs.resume()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logLine(ctx, 'managed: service ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function bareHolesailManagedStopService(ctx) {
|
||||
for (const u of managedHookUnsubs.splice(0)) {
|
||||
try {
|
||||
u()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const ids = [...managedInstances.keys()]
|
||||
for (const id of ids) {
|
||||
await stopManagedInstance(ctx, id)
|
||||
}
|
||||
managedServiceRunning = false
|
||||
logLine(ctx, 'managed: service stopped')
|
||||
}
|
||||
|
||||
export function bareHolesailManagedServiceIsRunning() {
|
||||
return managedServiceRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedRuntimeRunning(id) {
|
||||
return managedInstances.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function bareHolesailManagedRuntimeUrl(id) {
|
||||
const rec = managedInstances.get(id)
|
||||
if (!rec || !rec.hs) return ''
|
||||
try {
|
||||
const info = rec.hs.info
|
||||
if (info && typeof info === 'object' && 'url' in info)
|
||||
return String(/** @type {{ url?: string }} */ (info).url || '')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedStartOne(ctx, env, id) {
|
||||
if (!bareHolesailManagedServiceIsRunning()) {
|
||||
throw new Error('holesail: managed daemon is not running (enable bare-holesail + MANAGED)')
|
||||
}
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) throw new Error(idOk.err)
|
||||
const { state } = await bareHolesailManagedReadState(ctx, env)
|
||||
const raw = state.connections[idOk.id]
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error(`holesail: unknown connection ${idOk.id}`)
|
||||
const row = /** @type {Record<string, unknown>} */ (raw)
|
||||
const en =
|
||||
row.enabled === undefined || row.enabled === null
|
||||
? true
|
||||
: bareHolesailEnvTruthy(row.enabled)
|
||||
if (!en) throw new Error(`holesail: ${idOk.id} is disabled`)
|
||||
await startManagedInstance(ctx, env, idOk.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function bareHolesailManagedStopOne(ctx, id) {
|
||||
const idOk = bareHolesailManagedValidateId(id)
|
||||
if (!idOk.ok) throw new Error(idOk.err)
|
||||
await stopManagedInstance(ctx, idOk.id)
|
||||
}
|
||||
Reference in New Issue
Block a user