diff --git a/README.md b/README.md index 0c862ef..9864952 100644 --- a/README.md +++ b/README.md @@ -31,21 +31,31 @@ npx patch-package 运行下面命令启动: ```sh -node index.mjs profile@host:port +node index.mjs profile@hostname[:port] ``` +参数含义: + + + `profile`:表示连接到服务器使用的角色名 + + `hostname`:服务器地址 + + `port`:服务器端口(可选,默认值为 25565) + 命令行选项: + `--offline`:使用离线模式启动 - + `--credentials-lib `:指定登录凭据文件(默认值:`credentials.json`) - + `--no-repl`:禁用终端内 REPL - + `--protocal`:指定服务器版本(不指定时自动检测) + + `--credentials-lib `:指定登录凭据文件(可选,默认值:`credentials.json`) + + `--no-local-repl`:禁用终端内 REPL + + `--protocal`:指定服务器版本(可选,不指定时自动检测) + `--owner`: 指定 REPL 上下文内 `owner()` 函数返回的玩家 + + `--enable-tcp-repl`:启用基于 TCP 连接的远程 REPL + * `--tcp-repl-port`:TCP 远程 REPL 的服务端口(可选,默认值 2121) + + `--remote-repl-passcode-length`:远程 REPL 的服务口令强度(可选,默认值 8) ## REPL REPL 上下文内预先定义了如下变量/函数/类型: + + `PI`:常数,等于 `Math.PI` + `bot`:机器人对象 + `Vec3`:3 维向量类型 + `debug`:调试日志模块 @@ -57,4 +67,5 @@ REPL 上下文内预先定义了如下变量/函数/类型: + `sc.debug_mfc()`:缩写,等价于 `debug.enable('mineflayer-control')` + `sc.debug_mff()`:缩写,等价于 `debug.enable('mineflayer-fly-control')` + `sc.sleep`:缩写,等价于 `lib.utils.asyncSleep` - + `sc.tossHeld`:缩写,等价于 `bot.tossStack(bot.heldItem)` + + `sc.tossHeld()`:缩写,等价于 `bot.tossStack(bot.heldItem)` + + `bb`:所有 REPL 实例的共享数据 diff --git a/index.mjs b/index.mjs index a1c7286..ade2df5 100644 --- a/index.mjs +++ b/index.mjs @@ -5,6 +5,8 @@ import { asyncSleep, parseLogin, waitEvent } from 'compass-utils'; import repl from 'node:repl'; import 'enhanced-vec3'; import debug from 'debug'; +import { createLocalRepl, createTcpReplServer } from './repl/index.mjs'; +import { randomBytes } from 'node:crypto'; async function main() { const args = yargs((await import('yargs/helpers')).hideBin(process.argv)) @@ -16,8 +18,8 @@ async function main() { description: 'Bot\'s owner name.', type: 'string', requiresArg: false, - }).option('no-repl', { - description: 'Disable bot REPL control.', + }).option('no-local-repl', { + description: 'Disable bot REPL control in current stdin/stdout.', type: "boolean", }).option('credentials-lib', { description: 'Credentials\' library file.', @@ -26,11 +28,22 @@ async function main() { }).option('offline', { description: 'Login without credentials.', type: 'boolean', - }).usage('Uasge: profile@host:port').help().alias('help', 'h').argv; + }).option('enable-tcp-repl', { + description: 'Enable bot REPL control as a TCP service.', + type: 'boolean', + }).option('tcp-repl-port', { + description: 'Telnet REPL service port.', + type: 'number', + default: 2121, + }).option('remote-repl-passcode-length', { + description: 'Length of remote REPL passcode in bytes', + type: 'number', + default: 8, + }).usage('Uasge: profile@hostname[:port]').help().alias('help', 'h').argv; let login_info = args._[0]; if (login_info == null) { return; } - const [name, host, port] = parseLogin(login_info); + const [name, hostname, port] = parseLogin(login_info); let session, endpoint = null; if (args.offline) { @@ -45,7 +58,7 @@ async function main() { } const bot = mineflayer.createBot({ - host, port, version: args.protocal, + host: hostname, port, version: args.protocal, ...session.mineflayer(endpoint) }); bot.on('error', console.error); @@ -61,6 +74,7 @@ async function main() { bot.loadPlugin((await import('mineflayer-fly-control')).default); await bot.waitEvent('spawn'); + const context_shared = {}; async function loadReplContextModules(context) { context.lib = { utils: await import('compass-utils'), @@ -75,7 +89,9 @@ async function main() { return bot.players[args.owner]; }; + context.PI = Math.PI; context.debug = debug; + context.bb = context_shared; context.sc = {}; context.sc.pos = () => bot.entity.position; context.sc.debug_mfc = () => debug.enable('mineflayer-control'); @@ -84,22 +100,20 @@ async function main() { context.sc.tossHeld = () => bot.tossStack(bot.heldItem); } - if (!args.noRepl) { - let r = repl.start({ - prompt: 'local > ', - input: process.stdin, - output: process.stdout, - color: true, - terminal: true, - ignoreUndefined: true, - }); + if (args.enableTcpRepl) { + const passcode = randomBytes(args.remoteReplPasscodeLength); + console.log('Remote REPL Passcode:', passcode.toString('hex')); + let server = createTcpReplServer(args.tcpReplPort, passcode, loadReplContextModules); + process.on('exit', () => server.close()); + } - r.on('exit', () => bot.quit()); - loadReplContextModules(r.context); + if (!args.noLocalRepl) { + let repl_local = createLocalRepl(loadReplContextModules); + repl_local.on('exit', () => bot.quit()); } } main().catch(err => { - console.error('Error: ', err); + console.error(`Error: ${err}`); process.exit(1); }); diff --git a/repl/index.mjs b/repl/index.mjs new file mode 100644 index 0000000..eb30448 --- /dev/null +++ b/repl/index.mjs @@ -0,0 +1,85 @@ +import repl from 'node:repl'; +import net from 'node:net'; +import { Buffer } from 'node:buffer'; +import { timingSafeEqual } from 'node:crypto'; +import { waitEvent } from 'compass-utils'; +import { RemoteTTY } from './remote-tty.mjs'; + +import debug from 'debug'; +const logger = debug('compass-repl'); + +export function createRepl(istream, ostream, prompt, contextLoader) { + let r = repl.start({ + prompt: `${prompt} > `, + input: istream, + output: ostream, + color: true, + terminal: true, + ignoreUndefined: true, + }); + r.on('SIGCONT', () => { }); + r.on('SIGTSTP', () => {}); + contextLoader(r.context); + return r; +} + +export function createLocalRepl(contextLoader) { + return createRepl(process.stdin, process.stdout, 'local', contextLoader); +} + +export function createTcpReplServer(port, passcode, contextLoader) { + const connect_repl_sockets = new Set(); + let server = net.createServer(socket => { + logger('New TCP connection'); + function verifyPasscode(data) { + let pdata = Buffer.from(data); + logger(`Got passcode: ${pdata.toString('hex')}`); + let flag = true; + if (pdata.length != passcode.length) { + flag = false; + pdata = passcode; + } + if (timingSafeEqual(passcode, pdata) && flag) { + logger('Passcode correct, 0x21!'); + socket.write(Buffer.from([0x21])); + let tty = new RemoteTTY({}, socket, socket); + tty.ttyReady().then(() => { + logger('Remote TTY is ready, starting REPL'); + let r = createRepl(tty, tty, 'tcp', contextLoader); + r.on('exit', () => { + connect_repl_sockets.delete(socket); + socket.destroy(); + }); + connect_repl_sockets.add(socket); + }).catch(err => { + logger(`TTY Ready Error: ${err}`); + socket.destroy(); + }); + } else { + logger('Passcode incorrect, 0x20!'); + socket.write(Buffer.from([0x20])); + socket.destroy(); + } + } + + try { + socket.once('data', (data) => verifyPasscode(data)); + + socket.on('close', () => { + logger('Disconnected'); + connect_repl_sockets.delete(socket); + }); + } catch(err) { + logger(`Remote Connect Error: ${err}`); + connect_repl_sockets.delete(socket); + socket.destroy(); + } + }); + server.listen(port); + process.on('exit', () => { + for (const socket of connect_repl_sockets) { + socket.destroy(); + } + }); + return server; +} diff --git a/repl/remote-tty.mjs b/repl/remote-tty.mjs new file mode 100644 index 0000000..1021f43 --- /dev/null +++ b/repl/remote-tty.mjs @@ -0,0 +1,197 @@ +import { waitEvent } from 'compass-utils'; +import { randomBytes } from 'node:crypto'; +import { Duplex } from 'node:stream'; +import { promisify } from 'node:util'; + +import debug from 'debug'; +const logger = debug('compass-repl:RemoteTTY'); + +const kSource = Symbol('kSource'); + +export class RemoteTTY extends Duplex { + #rows + #cols + #color_depths + #tty_ready + #tty_ready_resolve + #event_callbacks + constructor(options, input, output) { + super(options); + this[kSource] = { + input: input, + output: output, + }; + this.#tty_ready = new Promise(resolve => { + this.#tty_ready_resolve = resolve; + }); + this.#event_callbacks = new Map(); + this[kSource].input.on('close', () => this.push(null)); + this[kSource].input.on('data', (data) => { + try { + if (!(data instanceof Buffer)) { + data = Buffer.from(data); + } + + if (data[0] === 0x02) { + if (!this.push(data.slice(1))) { + this[kSource].input.pause(); + } + return; + } + + if (data[0] === 0x01) { + // Initialize data + this.#rows = data.readUInt16BE(1); + this.#cols = data.readUInt16BE(3); + this.#color_depths = data.readUInt8(5); + if (this.#tty_ready_resolve) { + this.#tty_ready_resolve(); + this.#tty_ready_resolve = null; + } + } else if (data[0] == 0x03) { + // Resize event + this.#rows = data.readUInt16BE(1); + this.#cols = data.readUInt16BE(3); + this.emit('resize'); + } else if (data[0] == 0x04) { + // Callback + let id = (data.readBigUInt64BE(1) << 64n) + data.readBigUInt64BE(9); + if (this.#event_callbacks.has(id)) { + logger(`Rescived callback ${data.toString('hex')}`); + this.#event_callbacks.get(id)(); + this.#event_callbacks.delete(id); + } + } + } catch (err) { + // + } + }); + } + + get rows() { + return this.#rows; + } + + get columns() { + return this.#cols; + } + + getWindowSize() { + return [this.#rows, this.#cols]; + } + + getColorDepth() { + return this.#color_depths; + } + + hasColors(count = 16) { + return (1 << this.#color_depths) >= count; + } + + #registerCallback(data, callback) { + let cbid = randomBytes(16); + let cbid_v = (cbid.readBigUInt64BE(0) << 64n) + cbid.readBigUInt64BE(8); + if (callback) { + logger(`Registered callback ${cbid.toString('hex')}`); + this.#event_callbacks.set(cbid_v, callback); + } + cbid.copy(data, 1); + } + + clearLine(dir, callback) { + logger('clearLine'); + let data = Buffer.alloc(18); + data[0] = 0x10; + data.writeInt8(dir, 17); + this.#registerCallback(data, callback); + this.#sendData(data); + return this.writableNeedDrain; + } + + clearScreenDown(callback) { + logger('clearScreenDown'); + let data = Buffer.alloc(17); + data[0] = 0x11; + this.#registerCallback(data, callback); + this.#sendData(data); + return this.writableNeedDrain; + } + + cursorTo(x, y, callback) { + logger('cursorTo'); + let data = Buffer.alloc(22); + if (typeof y !== 'number') { + callback = y; + y = 0; + data[17] = 0x01; + } else { + data[17] = 0x00; + } + data[0] = 0x12; + data.writeUInt16BE(x, 18); + data.writeUInt16BE(y, 20); + + this.#registerCallback(data, callback); + this.#sendData(data); + return this.writableNeedDrain; + } + + moveCursor(dx, dy, callback) { + logger('moveCursor'); + let data = Buffer.alloc(21); + data[0] = 0x13; + data.writeUInt16BE(dx, 17); + data.writeUInt16BE(dy, 19); + + this.#registerCallback(data, callback); + this.#sendData(data); + return this.writableNeedDrain; + } + + async #sendData(data) { + if (this[kSource].output.writableNeedDrain) { + await waitEvent(this[kSource].output, 'darin'); + } + const writePromise = promisify((data, callback) => + this[kSource].output.write(data, 'utf-8', callback)); + await writePromise(data); + } + + _construct(callback) { + callback(); + } + + _write(chunk, encoding, callback) { + if (!(chunk instanceof Buffer)) { + chunk = Buffer.from(chunk, encoding); + } + chunk = Buffer.concat([Buffer.from([0x02]), chunk]); + this.#sendData(chunk).then(() => callback(null)).catch(err => callback(err)) + } + + _destroy(err, callback) { + this[kSource].input.destory(err); + this[kSource].output.destory(err); + callback(); + } + + _read(_size) { + this[kSource].input.resume(); + } + + setRawMode() { + return this; + } + + get isRaw() { + return true; + } + + get isTTY() { + return true; + } + + ttyReady() { + return this.#tty_ready; + } +}; diff --git a/scripts/tcp-repl-client.mjs b/scripts/tcp-repl-client.mjs new file mode 100644 index 0000000..ae5ef55 --- /dev/null +++ b/scripts/tcp-repl-client.mjs @@ -0,0 +1,161 @@ +import { Readable, Writable } from 'node:stream'; +import { promisify } from 'node:util'; +import net from 'node:net'; +import { waitEvent } from 'compass-utils'; +import readline from 'node:readline'; +import yargs from 'yargs'; + +/** + * @param {Readable} input + * @param {Writable} output + */ +function ttyServer(input, output) { + const stdin = process.stdin; + const stdout = process.stdout; + stdin.setRawMode(true); + + const writePromise = promisify((data, callback) => output.write(data, 'utf-8', callback)); + async function sendData(data) { + if (!output.writable) { + return; + } + if (output.writableNeedDrain) { + await waitEvent(output, 'darin'); + } + await writePromise(data); + } + + stdin.on('data', async (chunk) => { + if (chunk.length === 1 && chunk[0] === 4) { + stdin.emit('end'); + } + chunk = Buffer.concat([Buffer.from([0x02]), chunk]); + await sendData(chunk); + }); + + stdin.on('close', () => socket.destroy()); + + let init_data = Buffer.alloc(6); + init_data[0] = 0x01; + init_data.writeUInt16BE(stdout.rows, 1); + init_data.writeUInt16BE(stdout.columns, 3); + init_data.writeUInt8(stdout.getColorDepth(), 5); + sendData(init_data); + + stdout.on('resize', () => { + let resize_data = Buffer.alloc(5); + resize_data[0] = 0x03; + resize_data.writeUInt16BE(stdout.rows, 1); + resize_data.writeUInt16BE(stdout.columns, 3); + sendData(resize_data); + }); + + stdout.on('drain', () => input.resume()); + input.on('end', () => { + console.log('\nDisconnected (Server).'); + process.exit(0); + }); + stdin.on('end', () => { + input.destroy(); + output.destroy(); + console.log('\nDisconnected (Client).'); + process.exit(0); + }); + input.on('data', (data) => { + if (!(data instanceof Buffer)) { + data = Buffer.from(data); + } + + if (data[0] === 0x02) { + if (!stdout.write(data)) { input.pause(); } + return; + } + + if (data[0] == 0x10) { + // clearLine + let res = Buffer.alloc(17); + data.copy(res, 1, 1, 17); + res[0] = 0x04; + let dir = data.readInt8(17); + stdout.clearLine(dir, () => { + sendData(res); + }); + } else if (data[0] == 0x11) { + // clearScreenDown + let res = Buffer.alloc(17); + data.copy(res, 1, 1, 17); + res[0] = 0x04; + stdout.clearScreenDown(() => { + sendData(res); + }); + } else if (data[0] == 0x12) { + // cursorTo + let res = Buffer.alloc(17); + data.copy(res, 1, 1, 17); + res[0] = 0x04; + let flag = data[17]; + let x = data.readUInt16BE(18); + let y = data.readUInt16BE(20); + if (flag) { + stdout.cursorTo(x, () => { + sendData(res); + }); + } else { + stdout.cursorTo(x, y, () => { + sendData(res); + }); + } + } else if (data[0] == 0x13) { + // moveCursor + let res = Buffer.alloc(17); + data.copy(res, 1, 1, 17); + res[0] = 0x04; + let dx = data.readUInt16BE(17); + let dy = data.readUInt16BE(19); + stdout.moveCursor(dx, dy, () => { + sendData(res); + }); + } + }); +} + +async function main() { + const args = yargs((await import('yargs/helpers')).hideBin(process.argv)) + .option('port', { + description: 'Remote TCP REPL service port.', + default: 2121, + }).usage('Uasge: hostname').help().alias('help', 'h').argv; + + let socket = net.connect(args.port, args._[0]); + await waitEvent(socket, 'connect'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = promisify(rl.question).bind(rl); + const passcode_string = (await question('Passcode: ')).trim(); + const passcode = Buffer.from(passcode_string, 'hex'); + if (passcode.length == 0) { + console.log('A passcode is required.'); + process.exit(1); + } + rl.close(); + + const sendPasscode = promisify((cb) => socket.write(passcode, cb)); + await sendPasscode(); + let res = await waitEvent(socket, 'data'); + if (res[0] == 0x20) { + console.log('Passcode incorrect.'); + process.exit(1); + } + console.log('Connected.'); + process.stdin.resume(); + ttyServer(socket, socket); + console.log(process.stdin.isRaw); +} + +main().catch(err => { + console.error(`Error: ${err}`); + process.exit(1); +}); diff --git a/utils/misc.mjs b/utils/misc.mjs index 97d3f8d..f0b36f8 100644 --- a/utils/misc.mjs +++ b/utils/misc.mjs @@ -30,15 +30,23 @@ export function waitEvent(em, event) { }); } -export function asyncSleep(t) { +/** + * @param {number} timeout + * @returns {Promise} + */ +export function asyncSleep(timeout) { return new Promise((resolve, _reject) => { - setTimeout(resolve, t); + setTimeout(resolve, timeout); }); } -export function asyncTimeout(t) { +/** + * @param {number} timeout + * @returns {Promise} + */ +export function asyncTimeout(timeout) { return new Promise((_resolve, reject) => { - setTimeout(reject, t); + setTimeout(reject, timeout); }); }