diff --git a/README.md b/README.md index d22b79c..ab3ffcf 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,18 @@ Java Edition always connects to **localhost**. The Bare app owns Squid’s lifec ## Development ```sh -# Phase 1+ (Bare) -npm install # dev machine only -npm start # bare bin.mjs --no-updates -# Minecraft Java → 127.0.0.1:25565 +npm install # dev machine only; postinstall patches Bare engines + imports +BARE=./node_modules/bare-runtime/bin/bare + +$BARE bin.mjs create demo +$BARE bin.mjs host demo --port 25565 # print fj1. invite; local Squid +$BARE bin.mjs join 'fj1.…' --port 25566 # guest tunnel + +# Minecraft Java → 127.0.0.1: +npm test ``` -See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [developer_docs/CONTRIBUTING.md](./developer_docs/CONTRIBUTING.md). +See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [living_docs/CURRENT_STATUS.md](./living_docs/CURRENT_STATUS.md). ## License diff --git a/app.js b/app.js index c81e847..bfbffff 100644 --- a/app.js +++ b/app.js @@ -8,11 +8,14 @@ const { SquidManager } = require('./lib/squid-manager') const { createWorld, listWorlds, resolveWorldPaths } = require('./lib/worlds') const { worldsRoot } = require('./lib/paths') const { ensureDir } = require('./lib/worlds') +const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel') +const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys') +const { encodeInvite, decodeInvite, decodeKey } = require('./lib/invite') /** * Flying Jib Bare application shell. * - Optional pear-runtime worker for OTA (hello-pear-bare pattern) - * - Squid runs in this process under Bare (ADR-0013) + * - Squid + HyperDHT tunnels run in this process under Bare (ADR-0013) */ module.exports = class App extends ReadyResource { constructor({ dir, appPath, updates, version, upgrade, name }) { @@ -26,14 +29,16 @@ module.exports = class App extends ReadyResource { this.IPC = null this.pipe = null this.squid = null + this.tunnelHost = null + this.tunnelClient = null this.activeWorld = null + this._shuttingDown = false } _open() { ensureDir(worldsRoot(this.dir)) ensureDir(path.join(this.dir, 'corestore')) - // Pear OTA worker (optional — disabled when --no-updates and no upgrade key) const enableWorker = this.updates !== false && this.upgrade && !String(this.upgrade).includes(' {}) + this.tunnelClient = null + } + if (this.tunnelHost) { + await this.tunnelHost.close().catch(() => {}) + this.tunnelHost = null + } if (this.squid) { await this.squid.close().catch(() => {}) this.squid = null } + this.activeWorld = null const pipe = this.pipe const IPC = this.IPC this.pipe = null @@ -117,7 +131,7 @@ module.exports = class App extends ReadyResource { port, version, motd: meta.motd || `Flying Jib — ${meta.name}`, - logging: true + logging: false }) this.squid.on('error', (err) => this.emit('error', err)) @@ -130,24 +144,107 @@ module.exports = class App extends ReadyResource { return this.squid.status } + /** + * Start Squid + HyperDHT host tunnel; return status + invite string. + * @param {{ world: string, port?: number, version?: string }} opts + */ + async hostWorld(opts) { + const squidStatus = await this.startWorld(opts) + const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id) + + this.tunnelHost = new WorldTunnelHost({ + localPort: squidStatus.port, + seed: keys.seed, + cap: keys.cap + }) + this.tunnelHost.on('connection', () => { + this.emit('message', 'Remote peer connected via tunnel') + }) + this.tunnelHost.on('error', (err) => this.emit('error', err)) + await this.tunnelHost.ready() + + const invite = encodeInvite({ + type: 'private-world', + worldKey: keys.publicKeyZ32, + cap: keys.capZ32, + name: this.activeWorld.name, + mcVersion: this.activeWorld.version, + portHint: squidStatus.port + }) + + return { + squid: squidStatus, + tunnel: this.tunnelHost.status, + invite + } + } + + /** + * Join a private world via fj1. invite (client tunnel only; no local Squid). + * @param {{ invite: string, localPort?: number }} opts + */ + async joinWorld(opts) { + if (this.tunnelClient) { + throw new Error('Already joined a remote world; stop first') + } + const inv = decodeInvite(opts.invite) + const publicKey = decodeKey(inv.worldKey) + const cap = decodeKey(inv.cap) + + this.tunnelClient = new WorldTunnelClient({ + publicKey, + cap, + localPort: opts.localPort != null ? Number(opts.localPort) : 0 + }) + this.tunnelClient.on('error', (err) => this.emit('error', err)) + await this.tunnelClient.ready() + + return { + invite: inv, + tunnel: this.tunnelClient.status + } + } + async stopWorld() { - if (!this.squid) return false - await this.squid.close() - this.squid = null + let stopped = false + if (this.tunnelClient) { + await this.tunnelClient.close().catch(() => {}) + this.tunnelClient = null + stopped = true + } + if (this.tunnelHost) { + await this.tunnelHost.close().catch(() => {}) + this.tunnelHost = null + stopped = true + } + if (this.squid) { + await this.squid.close().catch(() => {}) + this.squid = null + stopped = true + } this.activeWorld = null - return true + return stopped } getStatus() { return { storage: this.dir, world: this.activeWorld, - squid: this.squid ? this.squid.status : { running: false } + squid: this.squid ? this.squid.status : { running: false }, + tunnelHost: this.tunnelHost ? this.tunnelHost.status : null, + tunnelClient: this.tunnelClient ? this.tunnelClient.status : null } } async exit(code = 0) { + if (this._shuttingDown) return + this._shuttingDown = true if (typeof Bare !== 'undefined') Bare.exitCode = code + try { + await this.stopWorld() + } catch { + // ignore + } await this.close() } } diff --git a/bin.mjs b/bin.mjs index fd4f2db..f33dab0 100644 --- a/bin.mjs +++ b/bin.mjs @@ -2,9 +2,11 @@ * Flying Jib CLI — Bare entry (ADR-0013). * * Usage: - * bare bin.mjs create [--version 1.21.1] + * bare bin.mjs create * bare bin.mjs list - * bare bin.mjs start [--port 25565] [--no-updates] + * bare bin.mjs start [--port 25565] + * bare bin.mjs host [--port 25565] # start + share invite + * bare bin.mjs join [--port 25565] # client tunnel * bare bin.mjs status */ @@ -24,7 +26,6 @@ const appName = pkg.productName || pkg.name const isBare = typeof Bare !== 'undefined' const argv0 = isBare ? Bare.argv[0] : process.argv[0] const base = path.basename(argv0) -// bare-runtime embeds as "bare"; packaged standalone uses product name const isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0) const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2) @@ -58,6 +59,38 @@ function makeApp(storage, updates = false) { }) } +/** Attach signal handlers once; re-entrant safe via app.exit */ +function attachShutdown(app) { + let stopping = false + const shutdown = async (code) => { + if (stopping) return + stopping = true + console.log('\nStopping…') + try { + await app.exit(code) + } catch (err) { + console.error('[shutdown]', err.message || err) + } + exit(code) + } + process.on('SIGINT', () => { + shutdown(130) + }) + process.on('SIGTERM', () => { + shutdown(143) + }) + if (isBare && typeof Bare !== 'undefined' && Bare.on) { + try { + Bare.on('exit', () => { + // best-effort; async exit may not finish + }) + } catch { + // ignore + } + } + return shutdown +} + const createCmd = command( 'create', summary('Create a new local world'), @@ -107,37 +140,16 @@ const listCmd = command( const startCmd = command( 'start', - summary('Start local Flying Squid for a world (127.0.0.1 only)'), + summary('Start local Flying Squid (127.0.0.1 only, no P2P share)'), arg('', 'world name / id'), flag('--port|-p [port]', 'local Minecraft port (default 25565)'), flag('--version [ver]', 'override Minecraft version'), - flag('--no-updates', 'disable OTA updates for this run'), async (cmd) => { const storage = resolveStorage(root.flags.storage) - // paparam: --no-updates → flags.updates === false when present - const updates = cmd.flags.updates === false || root.flags.updates === false ? false : false - const app = makeApp(storage, updates) - + const app = makeApp(storage, false) app.on('message', (m) => console.log(m)) app.on('error', (err) => console.error('[error]', err)) - - const shutdown = async (code) => { - console.log('\nStopping…') - try { - await app.stopWorld() - } catch { - // ignore - } - await app.exit(code) - exit(code) - } - - process.on('SIGINT', () => { - shutdown(130) - }) - process.on('SIGTERM', () => { - shutdown(143) - }) + attachShutdown(app) await app.ready() try { @@ -147,11 +159,9 @@ const startCmd = command( version: cmd.flags.version }) console.log('') - console.log(`${appName} world running (Bare/Pear, ADR-0013)`) + console.log(`${appName} local world running`) console.log(` world: ${cmd.args.name}`) console.log(` bind: ${status.host}:${status.port}`) - console.log(` listen: ${status.listen?.address}:${status.listen?.port}`) - console.log(` version:${status.version}`) console.log('') console.log('Connect Java Edition to:') console.log(` ${status.host}:${status.port}`) @@ -166,6 +176,89 @@ const startCmd = command( } ) +const hostCmd = command( + 'host', + summary('Start world and share via HyperDHT (prints fj1. invite)'), + arg('', 'world name / id'), + flag('--port|-p [port]', 'local Minecraft port (default 25565)'), + flag('--version [ver]', 'override Minecraft version'), + async (cmd) => { + const storage = resolveStorage(root.flags.storage) + const app = makeApp(storage, false) + app.on('message', (m) => console.log(m)) + app.on('error', (err) => console.error('[error]', err)) + attachShutdown(app) + + await app.ready() + try { + const result = await app.hostWorld({ + world: cmd.args.name, + port: cmd.flags.port ? Number(cmd.flags.port) : 25565, + version: cmd.flags.version + }) + console.log('') + console.log(`${appName} hosting private world (P2P)`) + console.log(` world: ${cmd.args.name}`) + console.log(` local: ${result.squid.host}:${result.squid.port}`) + console.log(` tunnel: HyperDHT host`) + console.log('') + console.log('Invite (treat as secret — fj1. capability):') + console.log(result.invite) + console.log('') + console.log('Friends run:') + console.log(` flying-jib join ''`) + console.log('') + console.log('You connect Java Edition to:') + console.log(` ${result.squid.host}:${result.squid.port}`) + console.log('') + console.log('Press Ctrl+C to stop.') + await new Promise(() => {}) + } catch (err) { + console.error('[host failed]', err) + await app.close() + exit(1) + } + } +) + +const joinCmd = command( + 'join', + summary('Join a private world from an fj1. invite'), + arg('', 'fj1. invite string'), + flag('--port|-p [port]', 'local bind port (default: ephemeral)'), + async (cmd) => { + const storage = resolveStorage(root.flags.storage) + const app = makeApp(storage, false) + app.on('message', (m) => console.log(m)) + app.on('error', (err) => console.error('[error]', err)) + attachShutdown(app) + + await app.ready() + try { + const result = await app.joinWorld({ + invite: cmd.args.invite, + localPort: cmd.flags.port ? Number(cmd.flags.port) : 0 + }) + const port = result.tunnel.localPort + console.log('') + console.log(`${appName} joined remote world`) + console.log(` name: ${result.invite.name || '(unnamed)'}`) + console.log(` version: ${result.invite.mcVersion || 'unknown'}`) + console.log(` local: 127.0.0.1:${port}`) + console.log('') + console.log('Connect Java Edition to:') + console.log(` 127.0.0.1:${port}`) + console.log('') + console.log('Press Ctrl+C to stop.') + await new Promise(() => {}) + } catch (err) { + console.error('[join failed]', err) + await app.close() + exit(1) + } + } +) + const statusCmd = command( 'status', summary('Show storage path and worlds'), @@ -194,18 +287,16 @@ const root = command( createCmd, listCmd, startCmd, + hostCmd, + joinCmd, statusCmd ) -const program = root.parse(rawArgv) +root.parse(rawArgv) if (root.flags.version) { console.log(`${appName} v${pkg.version}`) exit(0) } -if (program === null && !rawArgv.length) { - exit(0) -} -// silence unused in some linters void __dirname void __filename diff --git a/build/squid-imports.json b/build/squid-imports.json index 7ee755e..f99b16e 100644 --- a/build/squid-imports.json +++ b/build/squid-imports.json @@ -462,5 +462,9 @@ "node:zlib": { "bare": "bare-zlib", "default": "zlib" + }, + "esbuild-import-glob(path:.,skipFiles:index.js,external.js)": { + "bare": "file:///Users/raven/dev/flying-jib/build/stubs/esbuild-import-glob.cjs", + "default": "file:///Users/raven/dev/flying-jib/build/stubs/esbuild-import-glob.cjs" } } diff --git a/build/stubs/esbuild-import-glob.cjs b/build/stubs/esbuild-import-glob.cjs new file mode 100644 index 0000000..6d6304c --- /dev/null +++ b/build/stubs/esbuild-import-glob.cjs @@ -0,0 +1,3 @@ +'use strict' +// flying-squid browser/esbuild path — unused on Bare/Node desktop +module.exports = {} diff --git a/developer_docs/BUILD_AND_RELEASE.md b/developer_docs/BUILD_AND_RELEASE.md index c22b6fa..34498d7 100644 --- a/developer_docs/BUILD_AND_RELEASE.md +++ b/developer_docs/BUILD_AND_RELEASE.md @@ -54,9 +54,10 @@ Optional packaged forms (`.AppImage`, `.pkg`, `.msix`) via bare-build package mo ## Flying Squid in the bundle -- Include Prismarine graph with **`bare-node-runtime`** `imports` map -- Pack with `bare-pack` (or bare-build graph traverse) so all modules resolve offline -- Natives: only target host prebuilds (peardock-style prune of multi-arch bloat) +- Include Prismarine graph with **`bare-node-runtime`** + project **`build/squid-imports.json`** (generated postinstall) +- **Note:** plain `bare-build` currently fails resolving flying-squid’s browser-only `esbuild-import-glob(...)` specifier. Prefer peardock-style **`bare-pack` with imports** then embed into bare-runtime (see peardock `scripts/bare-standalone.cjs`). Stub at `build/stubs/esbuild-import-glob.cjs` is prepared for that path. +- Natives: only target host prebuilds (prune multi-arch bloat) + ## Pear deployment layers diff --git a/developer_docs/PROTOCOL.md b/developer_docs/PROTOCOL.md index 02732b6..90a85ab 100644 --- a/developer_docs/PROTOCOL.md +++ b/developer_docs/PROTOCOL.md @@ -1,7 +1,7 @@ # P2P Protocol **Last updated:** 2026-07-30 -**Status:** Spec for implementation (Phases 2–4) +**Status:** Phase 2 tunnel + invites **implemented** (host/join CLI); mesh channels later **ADRs:** 0006, 0007, 0008, 0009 ## Design rules diff --git a/developer_docs/SETUP.md b/developer_docs/SETUP.md index 9421aef..dc4587e 100644 --- a/developer_docs/SETUP.md +++ b/developer_docs/SETUP.md @@ -52,18 +52,19 @@ npm install # postinstall: patches engines for Bare + generates build/squid-imports.json # Preferred (uses packaged bare-runtime ≥1.29) -npm start -- create demo -npm start -- list -npm start -- start demo --port 25565 -# equivalent: -./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/fj-a create demo -./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/fj-a start demo --port 25565 +BARE=./node_modules/bare-runtime/bin/bare -# Custom storage (simulate two peers on one machine) -./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/flying-jib-a start demo +$BARE bin.mjs --storage /tmp/fj-a create demo +$BARE bin.mjs --storage /tmp/fj-a list +$BARE bin.mjs --storage /tmp/fj-a start demo --port 25565 # local only + +# Private world share (Phase 2) +$BARE bin.mjs --storage /tmp/fj-host host demo --port 25565 +# copy the printed fj1.… invite, then on another machine/storage: +$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25565 ``` -Connect Java Edition to `127.0.0.1:25565` (or the port shown). +Connect Java Edition to `127.0.0.1:` shown by the CLI (host or guest). **Notes:** diff --git a/lib/invite.js b/lib/invite.js new file mode 100644 index 0000000..691c949 --- /dev/null +++ b/lib/invite.js @@ -0,0 +1,96 @@ +'use strict' + +/** + * fj1. invite mint/parse (ADR-0009). + * Payload is JSON → UTF-8 → z32 for debuggability (Phase 2). + */ + +const z32 = require('z32') +const b4a = require('b4a') + +const PREFIX = 'fj1.' +const CURRENT_VERSION = 1 + +/** + * @param {object} payload + * @returns {string} + */ +function encodeInvite(payload) { + const body = { + v: payload.v != null ? payload.v : CURRENT_VERSION, + type: payload.type || 'private-world', + worldKey: payload.worldKey, + cap: payload.cap, + name: payload.name || null, + mcVersion: payload.mcVersion || null, + portHint: payload.portHint != null ? payload.portHint : null, + expires: payload.expires != null ? payload.expires : null + } + if (!body.worldKey || !body.cap) { + throw new Error('invite requires worldKey and cap') + } + const json = JSON.stringify(body) + return PREFIX + z32.encode(b4a.from(json)) +} + +/** + * @param {string} str + * @returns {object} + */ +function decodeInvite(str) { + const s = String(str || '').trim() + if (!s.startsWith(PREFIX)) { + throw new Error('Invalid invite: expected fj1. prefix') + } + let json + try { + json = b4a.toString(z32.decode(s.slice(PREFIX.length))) + } catch (err) { + throw new Error('Invalid invite: bad z32 payload') + } + let body + try { + body = JSON.parse(json) + } catch { + throw new Error('Invalid invite: bad JSON payload') + } + if (body.v !== CURRENT_VERSION) { + throw new Error(`Unsupported invite version: ${body.v}`) + } + if (body.type !== 'private-world') { + throw new Error(`Unsupported invite type: ${body.type}`) + } + if (!body.worldKey || !body.cap) { + throw new Error('Invalid invite: missing worldKey or cap') + } + if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) { + throw new Error('Invite expired') + } + return body +} + +/** + * Decode z32 public key / cap to Buffer. + * @param {string} z + * @returns {Buffer} + */ +function decodeKey(z) { + return b4a.from(z32.decode(String(z))) +} + +/** + * @param {Buffer|Uint8Array} buf + * @returns {string} + */ +function encodeKey(buf) { + return z32.encode(b4a.from(buf)) +} + +module.exports = { + PREFIX, + CURRENT_VERSION, + encodeInvite, + decodeInvite, + decodeKey, + encodeKey +} diff --git a/lib/tunnel-keys.js b/lib/tunnel-keys.js new file mode 100644 index 0000000..a9e86fb --- /dev/null +++ b/lib/tunnel-keys.js @@ -0,0 +1,65 @@ +'use strict' + +/** + * Persist per-world HyperDHT tunnel seed + capability under the world folder. + */ + +const fs = require('fs') +const path = require('path') +const crypto = require('hypercore-crypto') +const b4a = require('b4a') +const { worldDir } = require('./paths') +const { encodeKey } = require('./invite') + +function tunnelPath(storageDir, worldId) { + return path.join(worldDir(storageDir, worldId), 'tunnel.json') +} + +/** + * Load or create tunnel secrets for a world. + * @returns {{ seed: Buffer, cap: Buffer, publicKeyZ32: string, capZ32: string }} + */ +function loadOrCreateTunnelKeys(storageDir, worldId) { + const file = tunnelPath(storageDir, worldId) + if (fs.existsSync(file)) { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) + const seed = b4a.from(raw.seed, 'hex') + const cap = b4a.from(raw.cap, 'hex') + const keyPair = require('hyperdht').keyPair(seed) + return { + seed, + cap, + publicKeyZ32: encodeKey(keyPair.publicKey), + capZ32: encodeKey(cap), + publicKey: keyPair.publicKey + } + } + + const seed = crypto.randomBytes(32) + const cap = crypto.randomBytes(32) + const keyPair = require('hyperdht').keyPair(seed) + const data = { + seed: b4a.toString(seed, 'hex'), + cap: b4a.toString(cap, 'hex'), + publicKey: encodeKey(keyPair.publicKey), + createdAt: Date.now() + } + fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + try { + fs.chmodSync(file, 0o600) + } catch { + // windows + } + return { + seed, + cap, + publicKeyZ32: data.publicKey, + capZ32: encodeKey(cap), + publicKey: keyPair.publicKey + } +} + +module.exports = { + tunnelPath, + loadOrCreateTunnelKeys +} diff --git a/lib/world-tunnel.js b/lib/world-tunnel.js new file mode 100644 index 0000000..cbd0069 --- /dev/null +++ b/lib/world-tunnel.js @@ -0,0 +1,289 @@ +'use strict' + +/** + * HyperDHT TCP byte-pipe tunnel (ADR-0006). + * Host: DHT listen → pipe each connection to 127.0.0.1:squidPort + * Guest: local 127.0.0.1 accept → DHT connect(worldKey) → pipe + * + * Shared `cap` (32 bytes): first 36 bytes of the guest→host direction are + * FJ1C || cap; host verifies then forwards remaining MC bytes. + */ + +const net = require('net') +const { Transform } = require('stream') +const ReadyResource = require('ready-resource') +const DHT = require('hyperdht') +const b4a = require('b4a') +const crypto = require('hypercore-crypto') +const { forceLoopbackHost } = require('./bind-guard') + +const CAP_MAGIC = b4a.from('FJ1C') // 4 + 32 = 36 byte header + +function bindPipes(a, b) { + a.pipe(b) + b.pipe(a) + const destroy = (err) => { + try { + a.destroy(err) + } catch { + /* ignore */ + } + try { + b.destroy(err) + } catch { + /* ignore */ + } + } + a.on('error', destroy) + b.on('error', destroy) +} + +/** Host-side: strip and verify capability header, then pass through. */ +function createCapVerifyTransform(expectedCap) { + if (!expectedCap || expectedCap.byteLength !== 32) { + return new Transform({ + transform(chunk, enc, cb) { + this.push(chunk) + cb() + } + }) + } + let buf = b4a.alloc(0) + let verified = false + return new Transform({ + transform(chunk, enc, cb) { + if (verified) { + this.push(chunk) + return cb() + } + buf = b4a.concat([buf, chunk]) + if (buf.byteLength < 36) return cb() + const magic = buf.subarray(0, 4) + const cap = buf.subarray(4, 36) + if (!b4a.equals(magic, CAP_MAGIC) || !b4a.equals(cap, expectedCap)) { + return cb(new Error('capability rejected')) + } + verified = true + const rest = buf.subarray(36) + buf = b4a.alloc(0) + if (rest.byteLength) this.push(rest) + cb() + } + }) +} + +/** Guest-side: prepend cap header once, then pass through. */ +function createCapSendTransform(cap) { + if (!cap || cap.byteLength !== 32) { + return new Transform({ + transform(chunk, enc, cb) { + this.push(chunk) + cb() + } + }) + } + let sent = false + return new Transform({ + transform(chunk, enc, cb) { + if (!sent) { + sent = true + this.push(b4a.concat([CAP_MAGIC, cap, chunk])) + return cb() + } + this.push(chunk) + cb() + } + }) +} + +class WorldTunnelHost extends ReadyResource { + /** + * @param {object} opts + * @param {number} opts.localPort Squid port on 127.0.0.1 + * @param {Buffer} [opts.seed] 32-byte seed for DHT keyPair + * @param {Buffer} [opts.cap] 32-byte shared secret + * @param {number} [opts.maxConnections=32] + */ + constructor(opts) { + super() + this.localPort = Number(opts.localPort) + this.localHost = forceLoopbackHost('127.0.0.1') + this.seed = opts.seed || crypto.randomBytes(32) + this.cap = opts.cap || crypto.randomBytes(32) + this.maxConnections = opts.maxConnections || 32 + this.dht = null + this.server = null + this.keyPair = null + this._connections = 0 + } + + get publicKey() { + return this.keyPair && this.keyPair.publicKey + } + + get status() { + return { + role: 'host', + running: this.opened && !this.closed, + localHost: this.localHost, + localPort: this.localPort, + publicKey: this.publicKey, + connections: this._connections + } + } + + async _open() { + this.dht = new DHT() + this.keyPair = DHT.keyPair(this.seed) + this.server = this.dht.createServer({ + firewall: () => false + }) + + this.server.on('connection', (noiseSocket) => { + this._onRemote(noiseSocket) + }) + + await this.server.listen(this.keyPair) + this.emit('listening', this.publicKey) + } + + _onRemote(noiseSocket) { + if (this._connections >= this.maxConnections) { + noiseSocket.destroy() + return + } + this._connections++ + noiseSocket.once('close', () => { + this._connections = Math.max(0, this._connections - 1) + }) + + const gate = createCapVerifyTransform(this.cap) + const local = net.connect({ host: this.localHost, port: this.localPort }) + + gate.on('error', (err) => { + noiseSocket.destroy(err) + local.destroy(err) + }) + local.on('error', () => noiseSocket.destroy()) + noiseSocket.on('error', () => local.destroy()) + + // remote → gate → local ; local → remote + noiseSocket.pipe(gate).pipe(local) + local.pipe(noiseSocket) + + this.emit('connection') + } + + async _close() { + if (this.server) { + try { + await this.server.close() + } catch { + /* ignore */ + } + this.server = null + } + if (this.dht) { + try { + await this.dht.destroy() + } catch { + /* ignore */ + } + this.dht = null + } + } +} + +class WorldTunnelClient extends ReadyResource { + /** + * @param {object} opts + * @param {Buffer} opts.publicKey host DHT public key + * @param {Buffer} [opts.cap] + * @param {number} [opts.localPort=0] 0 = ephemeral + */ + constructor(opts) { + super() + if (!opts || !opts.publicKey) throw new Error('WorldTunnelClient requires publicKey') + this.publicKey = b4a.from(opts.publicKey) + this.cap = opts.cap ? b4a.from(opts.cap) : null + this.localPort = opts.localPort != null ? Number(opts.localPort) : 0 + this.localHost = forceLoopbackHost('127.0.0.1') + this.dht = null + this.server = null + this._boundPort = null + } + + get status() { + return { + role: 'client', + running: this.opened && !this.closed, + localHost: this.localHost, + localPort: this._boundPort, + remotePublicKey: this.publicKey + } + } + + async _open() { + this.dht = new DHT() + this.server = net.createServer((localSocket) => { + this._onLocal(localSocket) + }) + + await new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(this.localPort, this.localHost, () => { + const addr = this.server.address() + this._boundPort = addr && addr.port + resolve() + }) + }) + this.emit('listening', this._boundPort) + } + + _onLocal(localSocket) { + const remote = this.dht.connect(this.publicKey) + const gate = createCapSendTransform(this.cap) + + const open = () => { + localSocket.on('error', () => remote.destroy()) + remote.on('error', () => localSocket.destroy()) + gate.on('error', () => { + localSocket.destroy() + remote.destroy() + }) + // local → gate (prepends cap once) → remote ; remote → local + localSocket.pipe(gate).pipe(remote) + remote.pipe(localSocket) + } + + if (remote.opened) open() + else remote.once('open', open) + } + + async _close() { + if (this.server) { + await new Promise((resolve) => { + this.server.close(() => resolve()) + setTimeout(resolve, 2000) + }) + this.server = null + } + if (this.dht) { + try { + await this.dht.destroy() + } catch { + /* ignore */ + } + this.dht = null + } + this._boundPort = null + } +} + +module.exports = { + WorldTunnelHost, + WorldTunnelClient, + CAP_MAGIC, + createCapVerifyTransform, + createCapSendTransform +} diff --git a/living_docs/CHANGELOG.md b/living_docs/CHANGELOG.md index e58bb00..fe1b55f 100644 --- a/living_docs/CHANGELOG.md +++ b/living_docs/CHANGELOG.md @@ -11,6 +11,11 @@ and this project aims to follow [Semantic Versioning](https://semver.org/). ### Added +- Phase 2 private world sharing (core): + - CLI: `host` (share), `join` (client tunnel) + - HyperDHT world tunnel with capability header + - `fj1.` invite encode/decode + - Per-world tunnel key persistence - Phase 1 Bare application scaffold: - CLI: `create`, `list`, `start`, `status` - `lib/squid-manager.js` — embedded Flying Squid, forced `127.0.0.1` diff --git a/living_docs/CURRENT_STATUS.md b/living_docs/CURRENT_STATUS.md index df735cf..3f2f21b 100644 --- a/living_docs/CURRENT_STATUS.md +++ b/living_docs/CURRENT_STATUS.md @@ -1,14 +1,14 @@ # Flying Jib — Current Status **Snapshot date:** 2026-07-30 -**Phase:** 1 **In Progress** (local Bare Squid works) -**Product:** Flying Jib — decentralized P2P Minecraft mesh on **Bare/Pear** +**Phase:** 1 largely done · **Phase 2 core working** (host/join tunnel) +**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh --- ## One-line summary -**Phase 1 core works:** Bare CLI starts Flying Squid on **`127.0.0.1` only** (bind-guard + tests). Next: world UX polish, `bare-build --standalone` package, then Phase 2 tunnels. +**Bare CLI hosts Flying Squid on loopback and can share private worlds via HyperDHT + `fj1.` invites.** Guests join with a local tunnel port; Java Edition always uses localhost. --- @@ -16,14 +16,13 @@ | Area | Status | |------|--------| -| Architecture decisions (ADRs 0001–0013) | Documented & accepted | -| Bare app scaffold (`bin.mjs`, `app.js`, worker stub) | Working | -| CLI: `create` / `list` / `start` / `status` | Working | -| Flying Squid on **Bare** (`bare-runtime` + bare-node-runtime + shims) | **Verified** listen 127.0.0.1 | -| Flying Squid on Node (dev aid only) | Verified | -| `fj-bind-guard` plugin | Logs OK loopback | -| Unit tests (bind-guard, worlds) | Passing | -| Docs trees + agent rules | Present | +| Bare app CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working | +| Flying Squid on Bare + Node (dev) | Loopback listen verified | +| `fj-bind-guard` | OK | +| Unit tests (bind-guard, worlds, invite, tunnel) | **10/10 pass** | +| HyperDHT world tunnel host↔client | Integration test + CLI smoke | +| `fj1.` invite mint/parse | Working | +| Graceful shutdown (SIGINT re-entrancy guard) | Improved | --- @@ -31,42 +30,32 @@ | Area | Status | |------|--------| -| `bare-build --standalone` release artifact | Not run yet | -| HyperDHT world tunnel / invites | Phase 2 | -| Mesh registry / Autobase | Phase 4 | -| GUI | Not started (CLI first) | -| Pear OTA worker with real upgrade key | Stub only | -| Full Java client playtest matrix | Manual TBD | +| `bare-build --standalone` release artifact | In progress / unproven | +| Presence / chat Protomux channels | Phase 3 | +| Mesh enrollment / borders | Phase 4 | +| GUI | Not started | +| Production Pear OTA key | Placeholder | +| Full Java multiplayer playtest on two machines | Manual | --- -## Active risks (top) +## How to try (two terminals) -1. Bare/Prismarine interop shims (events, readline, engines) need maintenance (Q15 ongoing). -2. Gameplay parity vs vanilla (Q1). -3. P2P latency for tunnelled multiplayer (Q2). -4. Packaging size / native prebuilds for bare-build (Q7). +```sh +BARE=./node_modules/bare-runtime/bin/bare +$BARE bin.mjs --storage /tmp/fj-host create demo +$BARE bin.mjs --storage /tmp/fj-host host demo --port 25565 +# copy fj1.… invite -See [OPEN_QUESTIONS.md](./OPEN_QUESTIONS.md). +$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25566 +# Java → 127.0.0.1:25565 (host) or :25566 (guest) +``` --- ## Next actions -1. Manual Java Edition join to local Bare Squid. -2. `npm run make` / bare-build standalone smoke (no Node on PATH). -3. Phase 2: HyperDHT world tunnel + `fj1.` invites. -4. Harden postinstall shims and document in SETUP. - ---- - -## How to navigate - -| Need | Go to | -|------|--------| -| Phases & acceptance | [ROADMAP.md](./ROADMAP.md) | -| System diagram | [ARCHITECTURE_OVERVIEW.md](./ARCHITECTURE_OVERVIEW.md) | -| Runtime decision | [../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md) | -| Deep tech | [../developer_docs/](../developer_docs/) | -| End users | [../user_docs/](../user_docs/) | -| Agent rules | [../agent/](../agent/) | +1. Manual Java Edition multiplayer through tunnel +2. Finish bare-build standalone CI path +3. Phase 3: presence + chat over Protomux +4. Hardening: tunnel connection limits UX, invite rotate diff --git a/living_docs/OPEN_QUESTIONS.md b/living_docs/OPEN_QUESTIONS.md index 3c3a953..889313f 100644 --- a/living_docs/OPEN_QUESTIONS.md +++ b/living_docs/OPEN_QUESTIONS.md @@ -15,7 +15,7 @@ | Q4 | Autobase writer admission: who may enroll regions into a mesh? | High | Capability-gated enrollment; mesh invite ≠ region announce | Open | | Q5 | Coordinate / seed continuity across independently generated regions | Medium | Shared seed + offset policy ADR in Phase 4 | Open | | Q6 | Inventory/XP integrity during migration (races, duplication) | High | Two-phase migrate commit; abort + kick if incomplete | Open | -| Q7 | Packaging Squid + natives for bare-build standalone (prebuilds, pack size) | Medium | peardock bare-standalone + bare-pack; prune multi-arch natives | Open | +| Q7 | Packaging Squid for bare-build standalone | High | bare-build fails on flying-squid esbuild-import-glob; need peardock-style `bare-pack` + imports map (build/squid-imports.json) | Open | | Q8 | Accidental AGPL dependency (e.g. holesail) | Medium | Dependency policy + CI license check (ADR-0012) | Open | | Q9 | Public mesh spam / content liability | High (policy) | Private-by-default; public mesh experimental only | Open | | Q10 | minecraft-protocol default listen on all interfaces | Critical | Wrapper forces `127.0.0.1`; bind-guard unit test in Phase 1 | Open | diff --git a/living_docs/PROGRESS.md b/living_docs/PROGRESS.md index de76321..27c7026 100644 --- a/living_docs/PROGRESS.md +++ b/living_docs/PROGRESS.md @@ -5,6 +5,25 @@ --- +## 2026-07-30 — Phase 2 core: HyperDHT tunnel + fj1. invites + +### Wins + +- `lib/world-tunnel.js` — host/client HyperDHT TCP pipes with capability header (FJ1C||cap) +- `lib/invite.js` — `fj1.` z32 JSON invites (ADR-0009) +- `lib/tunnel-keys.js` — per-world tunnel seed/cap on disk (mode 0600) +- CLI: **`host`** (Squid + share) and **`join`** (client tunnel) +- Tests: invite codec + tunnel integration (**10/10**) +- Bare CLI smoke: host prints invite; guest binds 127.0.0.1; both listen + +### Next + +- bare-build standalone +- Java Edition multiplayer playtest +- Phase 3 presence/chat + +--- + ## 2026-07-30 — Phase 1: Bare CLI + Flying Squid on loopback ### Wins diff --git a/living_docs/ROADMAP.md b/living_docs/ROADMAP.md index 9779a47..136f408 100644 --- a/living_docs/ROADMAP.md +++ b/living_docs/ROADMAP.md @@ -88,7 +88,7 @@ | Field | Value | |-------|-------| -| **Status** | Not Started | +| **Status** | In Progress | | **Owner** | TBD | | **Target** | TBD | | **ADRs** | 0006, 0009 | @@ -102,9 +102,11 @@ ### Acceptance -- [ ] Host generates invite; guest joins over P2P -- [ ] Two independent storage instances can play together -- [ ] Secrets never logged in plaintext +- [x] Host generates invite (`host` command); guest joins (`join`) over P2P tunnel +- [x] Two independent storage instances CLI smoke (Bare) +- [x] Integration test: HyperDHT pipe with cap header +- [ ] Secrets never logged in plaintext (review logs; avoid printing full invite in progress logs) +- [ ] Two-machine Java Edition playtest ### Documentation required to complete diff --git a/package-lock.json b/package-lock.json index cbd87f3..7d410e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "b4a": "^1.6.7", "bare-assert": "^1.1.0", "bare-buffer": "^3.3.1", "bare-console": "^6.0.1", @@ -40,11 +41,14 @@ "flying-squid": "^1.12.0", "framed-stream": "^1.0.1", "graceful-goodbye": "^1.3.3", + "hypercore-crypto": "^3.4.2", + "hyperdht": "^6.20.0", "hyperswarm": "^4.17.0", "paparam": "^1.10.1", "pear-runtime": "^1.2.0", "ready-resource": "^1.2.0", - "which-runtime": "^1.4.0" + "which-runtime": "^1.4.0", + "z32": "^1.1.0" }, "bin": { "flying-jib": "bin.mjs" diff --git a/package.json b/package.json index e9cbf0b..df3cbcc 100644 --- a/package.json +++ b/package.json @@ -169,14 +169,18 @@ "bare-zlib": "^1.3.0", "corestore": "^7.9.2", "events": "^3.3.0", + "b4a": "^1.6.7", "flying-squid": "^1.12.0", "framed-stream": "^1.0.1", "graceful-goodbye": "^1.3.3", + "hypercore-crypto": "^3.4.2", + "hyperdht": "^6.20.0", "hyperswarm": "^4.17.0", "paparam": "^1.10.1", "pear-runtime": "^1.2.0", "ready-resource": "^1.2.0", - "which-runtime": "^1.4.0" + "which-runtime": "^1.4.0", + "z32": "^1.1.0" }, "devDependencies": { "bare-build": "^1.0.2", diff --git a/scripts/generate-squid-imports.js b/scripts/generate-squid-imports.js index 0dfb6c2..f999684 100644 --- a/scripts/generate-squid-imports.js +++ b/scripts/generate-squid-imports.js @@ -21,6 +21,13 @@ map['node:events'] = { bare: eventsShim, default: 'events' } map.readline = { bare: readlineShim, default: 'readline' } map['node:readline'] = { bare: readlineShim, default: 'readline' } +// flying-squid plugins/index.js browser branch — not used on desktop Bare +const esbuildStub = pathToFileURL( + path.join(root, 'build/stubs/esbuild-import-glob.cjs') +).href +const esbuildSpec = 'esbuild-import-glob(path:.,skipFiles:index.js,external.js)' +map[esbuildSpec] = { bare: esbuildStub, default: esbuildStub } + const outDir = path.join(root, 'build') fs.mkdirSync(outDir, { recursive: true }) const out = path.join(outDir, 'squid-imports.json') diff --git a/test/invite.test.js b/test/invite.test.js new file mode 100644 index 0000000..36ff54d --- /dev/null +++ b/test/invite.test.js @@ -0,0 +1,33 @@ +'use strict' + +const test = require('brittle') +const { encodeInvite, decodeInvite, PREFIX } = require('../lib/invite') + +test('round-trip invite', (t) => { + const inv = encodeInvite({ + worldKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + cap: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + name: 'Demo', + mcVersion: '1.21.1', + portHint: 25565 + }) + t.ok(inv.startsWith(PREFIX)) + const body = decodeInvite(inv) + t.is(body.name, 'Demo') + t.is(body.mcVersion, '1.21.1') + t.is(body.type, 'private-world') + t.is(body.v, 1) +}) + +test('rejects bad prefix', (t) => { + t.exception(() => decodeInvite('pd1.nope')) +}) + +test('rejects expired', (t) => { + const inv = encodeInvite({ + worldKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + cap: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + expires: Date.now() - 1000 + }) + t.exception(() => decodeInvite(inv)) +}) diff --git a/test/tunnel-local.test.js b/test/tunnel-local.test.js new file mode 100644 index 0000000..69f0380 --- /dev/null +++ b/test/tunnel-local.test.js @@ -0,0 +1,58 @@ +'use strict' + +/** + * Local HyperDHT tunnel smoke: host pipes to a tiny TCP echo server on loopback; + * client binds another loopback port and we send a byte through. + */ + +const test = require('brittle') +const net = require('net') +const { WorldTunnelHost, WorldTunnelClient } = require('../lib/world-tunnel') +const crypto = require('hypercore-crypto') + +test('host/client tunnel pipes TCP over HyperDHT', { timeout: 60000 }, async (t) => { + // Echo server as stand-in for Squid + const echo = net.createServer((c) => { + c.pipe(c) + }) + await new Promise((resolve) => echo.listen(0, '127.0.0.1', resolve)) + const echoPort = echo.address().port + + const seed = crypto.randomBytes(32) + const cap = crypto.randomBytes(32) + + const host = new WorldTunnelHost({ localPort: echoPort, seed, cap }) + await host.ready() + t.ok(host.publicKey) + + const client = new WorldTunnelClient({ + publicKey: host.publicKey, + cap, + localPort: 0 + }) + await client.ready() + const localPort = client.status.localPort + t.ok(localPort > 0) + + const reply = await new Promise((resolve, reject) => { + const s = net.connect({ host: '127.0.0.1', port: localPort }, () => { + s.write('ping-fj') + }) + const timer = setTimeout(() => { + s.destroy() + reject(new Error('tunnel timeout')) + }, 20000) + s.on('data', (d) => { + clearTimeout(timer) + resolve(d.toString()) + s.end() + }) + s.on('error', reject) + }) + + t.is(reply, 'ping-fj') + + await client.close() + await host.close() + await new Promise((resolve) => echo.close(resolve)) +}) diff --git a/user_docs/GETTING_STARTED.md b/user_docs/GETTING_STARTED.md index fa115c2..1edb8c1 100644 --- a/user_docs/GETTING_STARTED.md +++ b/user_docs/GETTING_STARTED.md @@ -28,14 +28,16 @@ Until then, developers build from source — see [../developer_docs/SETUP.md](.. 5. Open Minecraft Java Edition → Multiplayer → Add server → address `127.0.0.1` or `localhost` and that port. 6. Join and play solo. -## Invite a friend (**Planned**) +## Invite a friend (CLI) -1. With the world running, open **Invite** and copy the capability string (or QR). -2. Send it through a private channel (chat, password manager, etc.). -3. Friend opens Flying Jib → **Join** → pastes invite. -4. Friend’s app opens a local port; they connect Minecraft to **their** localhost. +1. Host: `flying-jib host ` — copy the `fj1.…` invite (secret). +2. Send it through a private channel. +3. Friend: `flying-jib join 'fj1.…'` +4. Friend connects Java Edition to **their** localhost port shown by the app. 5. You both appear in the same world hosted on the inviter’s machine. +GUI invite/QR is still **Planned**. + ## Safety basics - Anyone with your invite can try to join — treat it like a password. diff --git a/user_docs/INVITES_AND_CAPABILITIES.md b/user_docs/INVITES_AND_CAPABILITIES.md index 523e4d9..0290b53 100644 --- a/user_docs/INVITES_AND_CAPABILITIES.md +++ b/user_docs/INVITES_AND_CAPABILITIES.md @@ -1,6 +1,6 @@ # Invites and Capabilities -**Status:** Spec ready; UI Planned (Phase 2) +**Status:** CLI mint/parse working (`host` / `join`). UI Planned. ## What is an invite? diff --git a/user_docs/PRIVATE_WORLDS.md b/user_docs/PRIVATE_WORLDS.md index b866d12..8aefc15 100644 --- a/user_docs/PRIVATE_WORLDS.md +++ b/user_docs/PRIVATE_WORLDS.md @@ -1,6 +1,6 @@ # Private Worlds -**Status:** Planned (Phase 2) +**Status:** Core available in CLI (Phase 2 in progress). GUI later. ## What is a private world? @@ -10,10 +10,26 @@ A world that lives on **one host peer’s** computer. Only people who have a val The person who created/started the world runs the actual Minecraft simulation (Flying Squid inside Flying Jib). If the host closes the app or goes offline, guests disconnect. There is no automatic “failover server.” -## How guests connect +## How guests connect (CLI) -1. Guest pastes the invite into Flying Jib. -2. Flying Jib opens an encrypted peer-to-peer tunnel to the host. +**Host:** + +```sh +flying-jib create myworld +flying-jib host myworld --port 25565 +# Copy the fj1.… invite (keep it secret) +# Java Edition → 127.0.0.1:25565 +``` + +**Guest:** + +```sh +flying-jib join 'fj1.…' --port 25565 +# Java Edition → 127.0.0.1:25565 (guest’s local port) +``` + +1. Guest pastes the invite into Flying Jib (`join`). +2. Flying Jib opens an encrypted HyperDHT tunnel to the host. 3. On the guest machine, Minecraft still connects to `localhost`. 4. Guest never has to know the host’s IP address.