This commit is contained in:
Raven Scott
2026-04-22 20:18:19 -04:00
parent 835de02ae5
commit c1fb344784
4 changed files with 172 additions and 13 deletions
@@ -6,8 +6,14 @@ import { dirname } from '#host-path'
*
* 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** — Stock rows persist a **`seed`** (64 hex chars from **`bare-crypto` `randomBytes(32)`**).
* That value is passed to upstream **`Holesail`** as constructor **`key`** (non-`hs://` form) so each login
* mints the **same** `hs://…` URL; after **`ready()`** the canonical URL is stored in **`key`** for sharing.
* Legacy rows with only an **`hs://…`** `key` and no **`seed`** keep using the URL string until replaced.
* 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'
@@ -95,6 +101,23 @@ export function bareHolesailManagedValidateId(id) {
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())
}
/**
* @param {Record<string, unknown>} raw
* @returns {{ ok: true, entry: Record<string, unknown> } | { ok: false, err: string }}
@@ -114,8 +137,15 @@ export function bareHolesailManagedNormalizeEntry(raw) {
/** @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 (!bareHolesailManagedSeedLooksValid(seed))
return { ok: false, err: 'invalid seed (expect 64 hex chars)' }
entry.seed = seed.toLowerCase()
}
if (key) entry.key = key
if (raw.secure !== undefined && raw.secure !== null && String(raw.secure) !== '')
@@ -186,10 +216,12 @@ export function bareHolesailManagedLogLevel(conn, env) {
}
/**
* Constructor options for `new Holesail(opts)` — now passes the **full key** (`hs://0000...` or `hs://s000...`)
* exactly as stored in state.json. secure is explicitly derived from the key prefix when not set in the row.
* Constructor options for `new Holesail(opts)`.
*
* This matches the working standalone script that reuses the exact hs:// key.
* **Server** — Prefer persisted **`seed`** (hex) as **`opts.key`** so restarts reproduce the same **`hs://…`** URL.
* If only a legacy **`hs://…`** share string exists (**no seed**), pass that string as **`opts.key`**.
*
* **Client** — Passthrough **`key`** (typically **`hs://…`**).
*
* @param {Record<string, unknown>} conn one `state.connections[id]` object
* @param {Record<string, string | undefined>} env
@@ -197,15 +229,17 @@ export function bareHolesailManagedLogLevel(conn, env) {
*/
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)
// Derive secure from key prefix if not explicitly set in the row
let secure = false
if (c.secure !== undefined && c.secure !== null && String(c.secure) !== '') {
secure = bareHolesailEnvTruthy(c.secure)
} else if (seedStr) {
secure = false
} else if (keyFull) {
secure = /^hs:\/\/s/i.test(keyFull)
}
@@ -216,14 +250,23 @@ export function bareHolesailManagedNewHolesailOpts(conn, env) {
const log = bareHolesailManagedLogLevel(conn, env)
/** @type {string} */
let keyOpt = ''
if (server) {
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: keyFull, // ← Full hs://... key (critical fix)
secure, // ← Explicit secure flag
key: keyOpt,
secure,
udp,
log
}
@@ -264,8 +307,12 @@ function mergeCoercedHolesailStates(a, b) {
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 }
@@ -372,6 +419,50 @@ export async function bareHolesailManagedWriteState(ctx, env, state) {
await vfs.writeFile(path, ctx.b4a.from(body))
}
/**
* Ensure a managed **server** row has a **`seed`** before `new Holesail`, unless it is a legacy row that
* only stores an **`hs://…`** URL in **`key`** (no deterministic seed possible).
*
* Idempotent; persists **`state.json`** when a new seed is minted or hex-only **`key`** is migrated into **`seed`**.
*
* @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
let seed = String(row.seed ?? '').trim()
if (seed && !bareHolesailManagedSeedLooksValid(seed)) seed = ''
const key = String(row.key ?? '').trim()
if (seed) {
row.seed = seed.toLowerCase()
state.connections[id] = row
return
}
if (/^hs:\/\//i.test(key)) 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
@@ -420,6 +511,8 @@ export async function bareHolesailManagedSyncPersistedServerKey(ctx, env, id) {
* @param {string} id
*/
async function startManagedInstance(ctx, env, id) {
await bareHolesailManagedEnsureServerSeedPersisted(ctx, env, id)
const { state } = await bareHolesailManagedReadState(ctx, env)
const raw = state.connections[id]
if (!raw || typeof raw !== 'object') return
@@ -440,19 +533,35 @@ async function startManagedInstance(ctx, env, id) {
}
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 && keyStr) {
const live = bareHolesailManagedRuntimeUrl(id).trim()
if (bareHolesailManagedPersistedUrlMatchesLive(keyStr, live)) return
await stopManagedInstance(ctx, id)
} else {
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)
@@ -3,6 +3,7 @@
*/
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
import {
bareHolesailManagedGenerateSeedHex,
bareHolesailManagedReadState,
bareHolesailManagedServiceIsRunning,
bareHolesailManagedStartOne,
@@ -85,8 +86,16 @@ export async function ensureBareOsWwwHolesailTunnel(ctx, env, port) {
/** @type {Record<string, unknown>} */
const next = { ...want }
if (existing && typeof existing === 'object') {
const k = String(/** @type {Record<string, unknown>} */ (existing).key ?? '').trim()
const ex = /** @type {Record<string, unknown>} */ (existing)
const k = String(ex.key ?? '').trim()
if (k) next.key = k
const s = String(ex.seed ?? '').trim()
if (s) next.seed = s
}
const legacyHsOnly =
/^hs:\/\//i.test(String(next.key ?? '').trim()) && !String(next.seed ?? '').trim()
if (!legacyHsOnly && !String(next.seed ?? '').trim()) {
next.seed = bareHolesailManagedGenerateSeedHex()
}
state.connections[id] = next
await bareHolesailManagedWriteState(
@@ -6,6 +6,7 @@ import {
bareHolesailManagedNewHolesailOpts,
bareHolesailManagedNormalizeEntry,
bareHolesailManagedPersistedUrlMatchesLive,
bareHolesailManagedSeedLooksValid,
bareHolesailManagedStatePath,
bareHolesailManagedValidateId
} from './lib/bare-holesail-managed.js'
@@ -65,6 +66,42 @@ test('bareHolesailManagedNormalizeEntry client needs key', (t) => {
t.absent(r.ok)
})
test('bareHolesailManagedNormalizeEntry server seed must be 64 hex chars', (t) => {
const bad = bareHolesailManagedNormalizeEntry({
server: true,
seed: 'tooshort'
})
t.absent(bad.ok)
const good = bareHolesailManagedNormalizeEntry({
server: true,
seed: 'ab'.repeat(32)
})
t.ok(good.ok)
t.is(/** @type {{ entry: { seed: string } }} */ (good).entry.seed, 'ab'.repeat(32))
})
test('bareHolesailManagedSeedLooksValid', (t) => {
t.ok(bareHolesailManagedSeedLooksValid('a'.repeat(64)))
t.absent(bareHolesailManagedSeedLooksValid('z'.repeat(64)))
})
test('bareHolesailManagedNewHolesailOpts server prefers seed over hs key', (t) => {
const seed = 'a'.repeat(64)
const o = bareHolesailManagedNewHolesailOpts(
{
server: true,
client: false,
port: 8088,
host: '127.0.0.1',
seed,
key: 'hs://0000ignored000000000000000000000000000000000000000000'
},
{}
)
t.is(o.key, seed)
t.is(o.secure, false)
})
test('bareHolesailManagedNewHolesailOpts matches reference Node pattern', (t) => {
const pubKey = 'hs://0000aaaabbbbccccddddeeeeffffgggg'
const o = bareHolesailManagedNewHolesailOpts(
@@ -19,6 +19,7 @@ import {
import { findBareServiceDefinition } from './lib/bare-initd.js'
import {
bareHolesailManagedReadState,
bareHolesailManagedSeedLooksValid,
bareHolesailManagedWriteState
} from './lib/bare-holesail-managed.js'
import {
@@ -244,6 +245,8 @@ test('ensureBareOsWwwHolesailTunnel persists managed server entry', async (t) =>
t.ok(row && /** @type {{ server?: boolean }} */ (row).server === true)
t.is(/** @type {{ port?: number }} */ (row).port, p)
t.is(/** @type {{ host?: string }} */ (row).host, '127.0.0.1')
const seedStr = /** @type {{ seed?: string }} */ (row).seed
t.ok(seedStr && bareHolesailManagedSeedLooksValid(seedStr))
await store.close()
rmSync(dir, { recursive: true, force: true })
})
@@ -292,6 +295,7 @@ test('ensureBareOsWwwHolesailTunnel preserves existing hs key when normalizing r
t.is(row.port, p)
t.is(row.host, '127.0.0.1')
t.is(row.key, keepKey)
t.absent(/** @type {{ seed?: string }} */ (row).seed)
await store.close()
rmSync(dir, { recursive: true, force: true })
})