[repl] remote tcp repl

This commit is contained in:
方而静 2023-11-05 21:49:15 +08:00
parent 8a18be2d6a
commit 6ca0e1166c
Signed by: szTom
GPG Key ID: 072D999D60C6473C
6 changed files with 502 additions and 26 deletions

View File

@ -31,21 +31,31 @@ npx patch-package
运行下面命令启动: 运行下面命令启动:
```sh ```sh
node index.mjs profile@host:port node index.mjs profile@hostname[:port]
``` ```
参数含义:
+ `profile`:表示连接到服务器使用的角色名
+ `hostname`:服务器地址
+ `port`:服务器端口(可选,默认值为 25565
命令行选项: 命令行选项:
+ `--offline`:使用离线模式启动 + `--offline`:使用离线模式启动
+ `--credentials-lib <file>`:指定登录凭据文件(默认值:`credentials.json` + `--credentials-lib <file>`:指定登录凭据文件(可选,默认值:`credentials.json`
+ `--no-repl`:禁用终端内 REPL + `--no-local-repl`:禁用终端内 REPL
+ `--protocal`:指定服务器版本(不指定时自动检测) + `--protocal`:指定服务器版本(可选,不指定时自动检测)
+ `--owner`: 指定 REPL 上下文内 `owner()` 函数返回的玩家 + `--owner`: 指定 REPL 上下文内 `owner()` 函数返回的玩家
+ `--enable-tcp-repl`:启用基于 TCP 连接的远程 REPL
* `--tcp-repl-port`TCP 远程 REPL 的服务端口(可选,默认值 2121
+ `--remote-repl-passcode-length`:远程 REPL 的服务口令强度(可选,默认值 8
## REPL ## REPL
REPL 上下文内预先定义了如下变量/函数/类型: REPL 上下文内预先定义了如下变量/函数/类型:
+ `PI`:常数,等于 `Math.PI`
+ `bot`:机器人对象 + `bot`:机器人对象
+ `Vec3`3 维向量类型 + `Vec3`3 维向量类型
+ `debug`:调试日志模块 + `debug`:调试日志模块
@ -57,4 +67,5 @@ REPL 上下文内预先定义了如下变量/函数/类型:
+ `sc.debug_mfc()`:缩写,等价于 `debug.enable('mineflayer-control')` + `sc.debug_mfc()`:缩写,等价于 `debug.enable('mineflayer-control')`
+ `sc.debug_mff()`:缩写,等价于 `debug.enable('mineflayer-fly-control')` + `sc.debug_mff()`:缩写,等价于 `debug.enable('mineflayer-fly-control')`
+ `sc.sleep`:缩写,等价于 `lib.utils.asyncSleep` + `sc.sleep`:缩写,等价于 `lib.utils.asyncSleep`
+ `sc.tossHeld`:缩写,等价于 `bot.tossStack(bot.heldItem)` + `sc.tossHeld()`:缩写,等价于 `bot.tossStack(bot.heldItem)`
+ `bb`:所有 REPL 实例的共享数据

View File

@ -5,6 +5,8 @@ import { asyncSleep, parseLogin, waitEvent } from 'compass-utils';
import repl from 'node:repl'; import repl from 'node:repl';
import 'enhanced-vec3'; import 'enhanced-vec3';
import debug from 'debug'; import debug from 'debug';
import { createLocalRepl, createTcpReplServer } from './repl/index.mjs';
import { randomBytes } from 'node:crypto';
async function main() { async function main() {
const args = yargs((await import('yargs/helpers')).hideBin(process.argv)) const args = yargs((await import('yargs/helpers')).hideBin(process.argv))
@ -16,8 +18,8 @@ async function main() {
description: 'Bot\'s owner name.', description: 'Bot\'s owner name.',
type: 'string', type: 'string',
requiresArg: false, requiresArg: false,
}).option('no-repl', { }).option('no-local-repl', {
description: 'Disable bot REPL control.', description: 'Disable bot REPL control in current stdin/stdout.',
type: "boolean", type: "boolean",
}).option('credentials-lib', { }).option('credentials-lib', {
description: 'Credentials\' library file.', description: 'Credentials\' library file.',
@ -26,11 +28,22 @@ async function main() {
}).option('offline', { }).option('offline', {
description: 'Login without credentials.', description: 'Login without credentials.',
type: 'boolean', 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]; let login_info = args._[0];
if (login_info == null) { return; } if (login_info == null) { return; }
const [name, host, port] = parseLogin(login_info); const [name, hostname, port] = parseLogin(login_info);
let session, endpoint = null; let session, endpoint = null;
if (args.offline) { if (args.offline) {
@ -45,7 +58,7 @@ async function main() {
} }
const bot = mineflayer.createBot({ const bot = mineflayer.createBot({
host, port, version: args.protocal, host: hostname, port, version: args.protocal,
...session.mineflayer(endpoint) ...session.mineflayer(endpoint)
}); });
bot.on('error', console.error); bot.on('error', console.error);
@ -61,6 +74,7 @@ async function main() {
bot.loadPlugin((await import('mineflayer-fly-control')).default); bot.loadPlugin((await import('mineflayer-fly-control')).default);
await bot.waitEvent('spawn'); await bot.waitEvent('spawn');
const context_shared = {};
async function loadReplContextModules(context) { async function loadReplContextModules(context) {
context.lib = { context.lib = {
utils: await import('compass-utils'), utils: await import('compass-utils'),
@ -75,7 +89,9 @@ async function main() {
return bot.players[args.owner]; return bot.players[args.owner];
}; };
context.PI = Math.PI;
context.debug = debug; context.debug = debug;
context.bb = context_shared;
context.sc = {}; context.sc = {};
context.sc.pos = () => bot.entity.position; context.sc.pos = () => bot.entity.position;
context.sc.debug_mfc = () => debug.enable('mineflayer-control'); context.sc.debug_mfc = () => debug.enable('mineflayer-control');
@ -84,22 +100,20 @@ async function main() {
context.sc.tossHeld = () => bot.tossStack(bot.heldItem); context.sc.tossHeld = () => bot.tossStack(bot.heldItem);
} }
if (!args.noRepl) { if (args.enableTcpRepl) {
let r = repl.start({ const passcode = randomBytes(args.remoteReplPasscodeLength);
prompt: 'local > ', console.log('Remote REPL Passcode:', passcode.toString('hex'));
input: process.stdin, let server = createTcpReplServer(args.tcpReplPort, passcode, loadReplContextModules);
output: process.stdout, process.on('exit', () => server.close());
color: true, }
terminal: true,
ignoreUndefined: true,
});
r.on('exit', () => bot.quit()); if (!args.noLocalRepl) {
loadReplContextModules(r.context); let repl_local = createLocalRepl(loadReplContextModules);
repl_local.on('exit', () => bot.quit());
} }
} }
main().catch(err => { main().catch(err => {
console.error('Error: ', err); console.error(`Error: ${err}`);
process.exit(1); process.exit(1);
}); });

85
repl/index.mjs Normal file
View File

@ -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;
}

197
repl/remote-tty.mjs Normal file
View File

@ -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;
}
};

161
scripts/tcp-repl-client.mjs Normal file
View File

@ -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);
});

View File

@ -30,15 +30,23 @@ export function waitEvent(em, event) {
}); });
} }
export function asyncSleep(t) { /**
* @param {number} timeout
* @returns {Promise<void>}
*/
export function asyncSleep(timeout) {
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
setTimeout(resolve, t); setTimeout(resolve, timeout);
}); });
} }
export function asyncTimeout(t) { /**
* @param {number} timeout
* @returns {Promise<void>}
*/
export function asyncTimeout(timeout) {
return new Promise((_resolve, reject) => { return new Promise((_resolve, reject) => {
setTimeout(reject, t); setTimeout(reject, timeout);
}); });
} }