diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92478887e..4027806ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3153,25 +3153,25 @@ importers: version: 3.102.3 mineflayer: specifier: ^4.33.0 - version: 4.33.0(encoding@0.1.13) + version: 4.33.0 mineflayer-armor-manager: specifier: ^2.0.1 - version: 2.0.1(mineflayer@4.33.0(encoding@0.1.13)) + version: 2.0.1(mineflayer@4.33.0) mineflayer-auto-eat: specifier: ^5.0.3 - version: 5.0.3(encoding@0.1.13) + version: 5.0.3 mineflayer-collectblock: specifier: ^1.6.0 - version: 1.6.0(encoding@0.1.13) + version: 1.6.0(prismarine-registry@1.11.0) mineflayer-pathfinder: specifier: ^2.4.5 - version: 2.4.5 + version: 2.4.5(prismarine-registry@1.11.0) mineflayer-pvp: specifier: ^1.3.2 - version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0) + version: 1.3.2(prismarine-registry@1.11.0) mineflayer-tool: specifier: ^1.2.0 - version: 1.2.0(encoding@0.1.13) + version: 1.2.0(prismarine-registry@1.11.0) nanoid: specifier: 'catalog:' version: 5.1.6 @@ -3180,7 +3180,7 @@ importers: version: 0.2.1(zod-to-json-schema@3.25.1(zod@4.3.5))(zod@4.3.5) prismarine-block: specifier: ^1.22.0 - version: 1.22.0 + version: 1.22.0(prismarine-registry@1.11.0) prismarine-entity: specifier: ^2.5.0 version: 2.5.0 @@ -3192,7 +3192,7 @@ importers: version: 1.3.1(prismarine-registry@1.11.0) prismarine-viewer: specifier: ^1.33.0 - version: 1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + version: 1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10) prismarine-windows: specifier: ^2.9.0 version: 2.9.0 @@ -3219,6 +3219,18 @@ importers: specifier: 'catalog:' version: 8.18.1 + services/minecraft-hub: + dependencies: + '@guiiai/logg': + specifier: 'catalog:' + version: 1.2.11 + minecraft-protocol: + specifier: ^1.62.0 + version: 1.62.0(encoding@0.1.13) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + services/satori-bot: dependencies: '@guiiai/logg': @@ -27903,61 +27915,65 @@ snapshots: - encoding - supports-color - mineflayer-armor-manager@2.0.1(mineflayer@4.33.0(encoding@0.1.13)): + mineflayer-armor-manager@2.0.1(mineflayer@4.33.0): dependencies: minecraft-data: 3.102.3 - mineflayer: 4.33.0(encoding@0.1.13) + mineflayer: 4.33.0 - mineflayer-auto-eat@5.0.3(encoding@0.1.13): + mineflayer-auto-eat@5.0.3: dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 - mineflayer: 4.33.0(encoding@0.1.13) + mineflayer: 4.33.0 transitivePeerDependencies: - encoding - supports-color - mineflayer-collectblock@1.6.0(encoding@0.1.13): + mineflayer-collectblock@1.6.0(prismarine-registry@1.11.0): dependencies: - mineflayer: 4.33.0(encoding@0.1.13) - mineflayer-pathfinder: 2.4.5 - mineflayer-tool: 1.2.0(encoding@0.1.13) - transitivePeerDependencies: - - encoding - - supports-color - - mineflayer-pathfinder@2.4.5: - dependencies: - minecraft-data: 3.102.3 - prismarine-block: 1.22.0 - prismarine-entity: 2.5.0 - prismarine-item: 1.17.0 - prismarine-nbt: 2.8.0 - prismarine-physics: 1.10.0 - vec3: 0.1.10 - - mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0): - dependencies: - mineflayer: 4.33.0(encoding@0.1.13) - mineflayer-pathfinder: 2.4.5 - mineflayer-utils: 0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0) + mineflayer: 4.33.0 + mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0) + mineflayer-tool: 1.2.0(prismarine-registry@1.11.0) transitivePeerDependencies: - encoding - prismarine-registry - supports-color - mineflayer-tool@1.2.0(encoding@0.1.13): + mineflayer-pathfinder@2.4.5(prismarine-registry@1.11.0): dependencies: - mineflayer: 4.33.0(encoding@0.1.13) - mineflayer-pathfinder: 2.4.5 + minecraft-data: 3.102.3 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) + prismarine-entity: 2.5.0 + prismarine-item: 1.17.0 + prismarine-nbt: 2.8.0 + prismarine-physics: 1.10.0 + vec3: 0.1.10 + transitivePeerDependencies: + - prismarine-registry + + mineflayer-pvp@1.3.2(prismarine-registry@1.11.0): + dependencies: + mineflayer: 4.33.0 + mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0) + mineflayer-utils: 0.1.4(prismarine-registry@1.11.0) + transitivePeerDependencies: + - encoding + - prismarine-registry + - supports-color + + mineflayer-tool@1.2.0(prismarine-registry@1.11.0): + dependencies: + mineflayer: 4.33.0 + mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0) prismarine-nbt: 2.8.0 transitivePeerDependencies: - encoding + - prismarine-registry - supports-color - mineflayer-utils@0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0): + mineflayer-utils@0.1.4(prismarine-registry@1.11.0): dependencies: '@types/node': 14.18.63 - mineflayer: 2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0) + mineflayer: 2.41.0(prismarine-registry@1.11.0) prismarine-entity: 1.2.0 require-self: 0.2.3 typescript: 3.9.10 @@ -27966,12 +27982,12 @@ snapshots: - prismarine-registry - supports-color - mineflayer@2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0): + mineflayer@2.41.0(prismarine-registry@1.11.0): dependencies: minecraft-data: 2.221.0 minecraft-protocol: 1.62.0(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0) - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-chat: 1.12.0 prismarine-chunk: 1.39.0(minecraft-data@2.221.0) prismarine-entity: 1.2.0 @@ -27988,12 +28004,12 @@ snapshots: - prismarine-registry - supports-color - mineflayer@4.33.0(encoding@0.1.13): + mineflayer@4.33.0: dependencies: minecraft-data: 3.102.3 minecraft-protocol: 1.62.0(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-chat: 1.12.0 prismarine-chunk: 1.39.0(minecraft-data@3.102.3) prismarine-entity: 2.5.0 @@ -29055,7 +29071,7 @@ snapshots: minecraft-data: 3.102.3 prismarine-registry: 1.11.0 - prismarine-block@1.22.0: + prismarine-block@1.22.0(prismarine-registry@1.11.0): dependencies: minecraft-data: 3.102.3 prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) @@ -29063,6 +29079,8 @@ snapshots: prismarine-item: 1.17.0 prismarine-nbt: 2.8.0 prismarine-registry: 1.11.0 + transitivePeerDependencies: + - prismarine-registry prismarine-chat@1.12.0: dependencies: @@ -29073,7 +29091,7 @@ snapshots: prismarine-chunk@1.39.0(minecraft-data@2.221.0): dependencies: prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0) - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-nbt: 2.8.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -29086,7 +29104,7 @@ snapshots: prismarine-chunk@1.39.0(minecraft-data@3.102.3): dependencies: prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-nbt: 2.8.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -29137,16 +29155,16 @@ snapshots: prismarine-registry@1.11.0: dependencies: minecraft-data: 3.102.3 - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-nbt: 2.8.0 - prismarine-viewer@1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + prismarine-viewer@1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10): dependencies: '@tweenjs/tween.js': 23.1.3 compression: 1.8.1 express: 4.22.1 minecraft-data: 3.102.3 - prismarine-block: 1.22.0 + prismarine-block: 1.22.0(prismarine-registry@1.11.0) prismarine-chunk: 1.39.0(minecraft-data@3.102.3) prismarine-world: 3.6.3 socket.io: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -29156,6 +29174,7 @@ snapshots: vec3: 0.1.10 transitivePeerDependencies: - bufferutil + - prismarine-registry - supports-color - utf-8-validate diff --git a/services/minecraft-hub/.env b/services/minecraft-hub/.env new file mode 100644 index 000000000..1b16a123b --- /dev/null +++ b/services/minecraft-hub/.env @@ -0,0 +1,33 @@ +# Hub owns the single upstream session +HUB_SINGLE_TARGET=true +HUB_VERSION=1.21.8 +HUB_MOTD=AIRI Minecraft Hub + +# Viewer listener (online-mode recommended for skins) +HUB_VIEWER_LISTEN_HOST=0.0.0.0 +HUB_VIEWER_LISTEN_PORT=25566 +HUB_VIEWER_ONLINE_MODE=true +HUB_VIEWER_USERNAME=airi-bot + +# Bot listener (offline) +HUB_BOT_LISTEN_HOST=0.0.0.0 +HUB_BOT_LISTEN_PORT=25567 +HUB_BOT_ONLINE_MODE=false +HUB_BOT_USERNAME=airi-bot-mineflayer + +# Target server (real Minecraft server) +HUB_UPSTREAM_HOST=localhost +HUB_UPSTREAM_PORT=25565 +HUB_UPSTREAM_AUTH=microsoft +HUB_UPSTREAM_USERNAME=airi-bot + +HUB_REWRITE_IDENTITY=false +HUB_DEBUG_PACKETS=false +HUB_DUMP_PACKETS=true +HUB_DUMP_DIR=./packet-dumps + +# Mirroring and input filters +HUB_MIRROR_MOVEMENT=true +HUB_MIRROR_ACTIONS=false +HUB_BLOCK_VIEWER_MOVEMENT=true +HUB_BLOCK_VIEWER_ACTIONS=true diff --git a/services/minecraft-hub/README.md b/services/minecraft-hub/README.md new file mode 100644 index 000000000..421e9df99 --- /dev/null +++ b/services/minecraft-hub/README.md @@ -0,0 +1,47 @@ +# Minecraft Hub (Protocol Relay) + +Relay service that accepts Minecraft clients (bot + viewer) and proxies them to a target Minecraft server. The hub owns a single upstream authenticated session, and both clients are downstream-only. + +## Usage + +```bash +pnpm -F @proj-airi/minecraft-hub dev +``` + +## Environment Variables + +Core: +- `HUB_VERSION` (default: `1.20`) +- `HUB_MOTD` (default: `AIRI Minecraft Hub`) + +Viewer listener (debug client): +- `HUB_VIEWER_LISTEN_HOST` (default: `0.0.0.0`) +- `HUB_VIEWER_LISTEN_PORT` (default: `25566`) +- `HUB_VIEWER_ONLINE_MODE` (default: `true`) +- `HUB_VIEWER_USERNAME` (optional, defaults to `HUB_UPSTREAM_USERNAME`) + +Bot listener (mineflayer): +- `HUB_BOT_LISTEN_HOST` (default: `0.0.0.0`) +- `HUB_BOT_LISTEN_PORT` (default: `25567`) +- `HUB_BOT_ONLINE_MODE` (default: `false`) +- `HUB_BOT_USERNAME` (default: `airi-bot-mineflayer`) + +Target server (real Minecraft server): +- `HUB_UPSTREAM_HOST` (default: `localhost`) +- `HUB_UPSTREAM_PORT` (default: `25565`) +- `HUB_UPSTREAM_AUTH` (default: `offline`) — `offline` | `mojang` | `microsoft` +- `HUB_UPSTREAM_USERNAME` (default: `airi-bot`) +- `HUB_REWRITE_IDENTITY` (default: `false`) — when enabled, rewrites player identity packets for downstream clients +- `HUB_DEBUG_PACKETS` (default: `false`) — log raw/decoded packet flow for debugging +- `HUB_DUMP_PACKETS` (default: `false`) — write raw packet hex dumps to disk +- `HUB_DUMP_DIR` (default: `./packet-dumps`) — dump directory + +Mirroring / input filters: +- `HUB_MIRROR_MOVEMENT` (default: `true`) +- `HUB_MIRROR_ACTIONS` (default: `false`) + +## Notes + +- The hub connects to the target server once and broadcasts server packets to both downstream clients. +- The viewer is input-blocked and acts as a debug view while the bot controls the upstream session. +- The hub rewrites UUIDs in key packets (player info/spawn) so downstream clients can keep their own local identities while the hub owns the upstream account. diff --git a/services/minecraft-hub/package.json b/services/minecraft-hub/package.json new file mode 100644 index 000000000..440fe934c --- /dev/null +++ b/services/minecraft-hub/package.json @@ -0,0 +1,27 @@ +{ + "name": "@proj-airi/minecraft-hub", + "type": "module", + "private": true, + "description": "Minecraft protocol relay hub for AIRI", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "services/minecraft-hub" + }, + "scripts": { + "start": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "dev": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@guiiai/logg": "catalog:", + "minecraft-protocol": "^1.62.0", + "tsx": "^4.21.0" + } +} diff --git a/services/minecraft-hub/src/config.ts b/services/minecraft-hub/src/config.ts new file mode 100644 index 000000000..2508a487c --- /dev/null +++ b/services/minecraft-hub/src/config.ts @@ -0,0 +1,76 @@ +import { env } from 'node:process' + +export type AuthType = 'offline' | 'mojang' | 'microsoft' + +export interface HubConfig { + version: string + motd: string + + viewerListenHost: string + viewerListenPort: number + viewerOnlineMode: boolean + viewerUsername?: string + + botListenHost: string + botListenPort: number + botOnlineMode: boolean + botUsername: string + + upstreamHost: string + upstreamPort: number + upstreamAuth: AuthType + upstreamUsername: string + + rewriteIdentity: boolean + debugPackets: boolean + dumpPackets: boolean + dumpDir: string + + mirrorMovement: boolean + mirrorActions: boolean +} + +function getEnvVar(key: string, fallback: V): V { + return (env[key] || fallback) as V +} + +function getEnvNumber(key: string, fallback: number): number { + return Number.parseInt(env[key] || String(fallback)) +} + +function getEnvBool(key: string, fallback: boolean): boolean { + const value = env[key] + if (value === undefined) + return fallback + return value.toLowerCase() === 'true' || value === '1' +} + +export function loadConfig(): HubConfig { + return { + version: getEnvVar('HUB_VERSION', '1.20'), + motd: getEnvVar('HUB_MOTD', 'AIRI Minecraft Hub'), + + viewerListenHost: getEnvVar('HUB_VIEWER_LISTEN_HOST', '0.0.0.0'), + viewerListenPort: getEnvNumber('HUB_VIEWER_LISTEN_PORT', 25566), + viewerOnlineMode: getEnvBool('HUB_VIEWER_ONLINE_MODE', true), + viewerUsername: env.HUB_VIEWER_USERNAME, + + botListenHost: getEnvVar('HUB_BOT_LISTEN_HOST', '0.0.0.0'), + botListenPort: getEnvNumber('HUB_BOT_LISTEN_PORT', 25567), + botOnlineMode: getEnvBool('HUB_BOT_ONLINE_MODE', false), + botUsername: getEnvVar('HUB_BOT_USERNAME', 'airi-bot-mineflayer'), + + upstreamHost: getEnvVar('HUB_UPSTREAM_HOST', 'localhost'), + upstreamPort: getEnvNumber('HUB_UPSTREAM_PORT', 25565), + upstreamAuth: getEnvVar('HUB_UPSTREAM_AUTH', 'offline') as AuthType, + upstreamUsername: getEnvVar('HUB_UPSTREAM_USERNAME', 'airi-bot'), + + rewriteIdentity: getEnvBool('HUB_REWRITE_IDENTITY', false), + debugPackets: getEnvBool('HUB_DEBUG_PACKETS', false), + dumpPackets: getEnvBool('HUB_DUMP_PACKETS', false), + dumpDir: getEnvVar('HUB_DUMP_DIR', './packet-dumps'), + + mirrorMovement: getEnvBool('HUB_MIRROR_MOVEMENT', true), + mirrorActions: getEnvBool('HUB_MIRROR_ACTIONS', false), + } +} diff --git a/services/minecraft-hub/src/hub.ts b/services/minecraft-hub/src/hub.ts new file mode 100644 index 000000000..cf37cd637 --- /dev/null +++ b/services/minecraft-hub/src/hub.ts @@ -0,0 +1,414 @@ +import type { Buffer } from 'node:buffer' + +import type { HubConfig } from './config' + +import { createWriteStream, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +import { createClient, createServer } from 'minecraft-protocol' + +import { useLogger } from './logger' + +type ProtocolClient = any + +type HubRole = 'bot' | 'viewer' + +interface HubSession { + role: HubRole + username: string + downstream: ProtocolClient + readyForPlay: boolean +} + +const MOVEMENT_PACKETS = new Set([ + 'position', + 'position_look', + 'look', + 'move', + 'vehicle_move', +]) + +const ACTION_PACKETS = new Set([ + 'arm_animation', + 'block_dig', + 'block_place', + 'use_item', + 'use_entity', + 'held_item_slot', + 'player_command', + 'entity_action', +]) + +export interface HubHandle { + close: () => Promise +} + +function rewriteUuidArray(uuids: string[], targetUuid: string, replacementUuid: string) { + return uuids.map(uuid => (uuid === targetUuid ? replacementUuid : uuid)) +} + +function rewritePacketForSession(name: string, data: any, session: HubSession, targetUsername?: string, targetUuid?: string) { + if (!targetUuid || !session.downstream?.uuid) + return data + + const replacementUuid = session.downstream.uuid + const replacementName = session.username + + if (targetUuid === replacementUuid && (!targetUsername || targetUsername === replacementName)) + return data + + if (name === 'player_info' && Array.isArray(data.data)) { + return { + ...data, + data: data.data.map((entry: any) => { + if (entry.uuid !== targetUuid) + return entry + return { + ...entry, + uuid: replacementUuid, + name: targetUsername && entry.name === targetUsername ? replacementName : entry.name, + } + }), + } + } + + if (name === 'player_info_update' && Array.isArray(data.data)) { + return { + ...data, + data: data.data.map((entry: any) => { + if (entry.uuid !== targetUuid) + return entry + return { + ...entry, + uuid: replacementUuid, + name: targetUsername && entry.name === targetUsername ? replacementName : entry.name, + } + }), + } + } + + if (name === 'player_remove') { + if (Array.isArray(data.players)) + return { ...data, players: rewriteUuidArray(data.players, targetUuid, replacementUuid) } + if (Array.isArray(data.uuids)) + return { ...data, uuids: rewriteUuidArray(data.uuids, targetUuid, replacementUuid) } + } + + if ((name === 'named_entity_spawn' || name === 'spawn_player') && data.playerUUID) { + if (data.playerUUID !== targetUuid) + return data + return { ...data, playerUUID: replacementUuid } + } + + if (typeof data === 'object' && data !== null && data.uuid === targetUuid) + return { ...data, uuid: replacementUuid } + + return data +} + +export async function startHub(config: HubConfig): Promise { + const logger = useLogger('minecraft-hub') + + const sessions = new Set() + const configPacketBuffer: Array<{ buffer: Buffer, name: string, compressionThreshold?: number }> = [] + const playPacketBuffer: Array<{ name: string, data: any }> = [] + let configCompleted = false + let configCompressionThreshold: number | null = null + let initialDispatchDone = false + let rawDumpStream: NodeJS.WritableStream | null = null + + if (config.dumpPackets) { + mkdirSync(config.dumpDir, { recursive: true }) + const filename = `hub-${new Date().toISOString().replace(/[:.]/g, '-')}.log` + const path = join(config.dumpDir, filename) + rawDumpStream = createWriteStream(path, { flags: 'a' }) + logger.withFields({ path }).log('Packet dump enabled') + } + + const dumpRaw = (direction: string, meta: { name: string, state: string }, buffer: Buffer) => { + if (!rawDumpStream) + return + const line = JSON.stringify({ + ts: new Date().toISOString(), + direction, + state: meta.state, + name: meta.name, + size: buffer.length, + hex: buffer.toString('hex'), + }) + rawDumpStream.write(`${line}\n`) + } + + const mirrorToViewers = (name: string, data: unknown) => { + for (const session of sessions) { + if (session.role !== 'viewer') + continue + if (!session.readyForPlay) + continue + try { + session.downstream.write(name, data as never) + } + catch (error) { + logger.withFields({ packet: name, viewer: session.username }).errorWithError('Failed to mirror packet to viewer', error as Error) + } + } + } + + const hasRole = (role: HubRole) => { + for (const session of sessions) { + if (session.role === role) + return true + } + return false + } + + const canDispatchInitialPackets = () => configCompleted && hasRole('viewer') && hasRole('bot') && !initialDispatchDone + + const dispatchInitialPackets = () => { + if (!canDispatchInitialPackets()) + return + + for (const session of sessions) { + session.downstream.state = 'configuration' + for (const packet of configPacketBuffer) { + session.downstream.writeRaw(packet.buffer) + if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number') + session.downstream.compressionThreshold = packet.compressionThreshold + if (config.dumpPackets) + dumpRaw(`hub->downstream:${session.role}`, { name: packet.name, state: 'configuration' }, packet.buffer) + } + session.downstream.state = 'play' + session.readyForPlay = true + } + + for (const packet of playPacketBuffer) { + for (const session of sessions) { + if (!session.readyForPlay) + continue + try { + const payload = config.rewriteIdentity + ? rewritePacketForSession(packet.name, packet.data, session, targetIdentity.username, targetIdentity.uuid) + : packet.data + session.downstream.write(packet.name, payload) + } + catch (error) { + logger.withFields({ packet: packet.name, role: session.role }).errorWithError('Failed to replay packet to downstream', error as Error) + } + } + } + + playPacketBuffer.length = 0 + initialDispatchDone = true + } + + const target = createClient({ + host: config.upstreamHost, + port: config.upstreamPort, + username: config.upstreamUsername, + auth: config.upstreamAuth === 'offline' ? undefined : config.upstreamAuth, + version: config.version, + }) + + const targetIdentity = { + username: config.upstreamUsername, + uuid: '', + } + + target.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => { + if (config.debugPackets) + logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name, size: buffer.length }).log('Raw packet') + if (config.dumpPackets) + dumpRaw('target->hub', meta, buffer) + if (meta.state !== 'configuration' && (meta.state !== 'play' || config.rewriteIdentity)) + return + if (meta.state === 'configuration') { + configPacketBuffer.push({ buffer, name: meta.name, compressionThreshold: configCompressionThreshold ?? undefined }) + } + }) + + target.on('packet', (data: any, meta: { name: string, state: string }) => { + if (config.debugPackets) + logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name }).log('Decoded packet') + if (meta.state === 'configuration' && meta.name === 'set_compression' && typeof data.threshold === 'number') + configCompressionThreshold = data.threshold + + if (meta.name === 'player_info' && Array.isArray(data.data)) { + const hubEntry = data.data.find((entry: any) => entry.name === config.upstreamUsername) + if (hubEntry?.uuid) + targetIdentity.uuid = hubEntry.uuid + } + + if (!configCompleted && meta.state === 'configuration' && meta.name === 'finish_configuration') { + configCompleted = true + dispatchInitialPackets() + } + + if (meta.state === 'play') { + if (!initialDispatchDone) { + playPacketBuffer.push({ name: meta.name, data }) + return + } + + for (const session of sessions) { + if (!session.readyForPlay) + continue + try { + if (config.debugPackets) + logger.withFields({ direction: 'hub->downstream', state: meta.state, name: meta.name, role: session.role }).log('Decoded forward') + const payload = config.rewriteIdentity + ? rewritePacketForSession(meta.name, data, session, targetIdentity.username, targetIdentity.uuid) + : data + session.downstream.write(meta.name, payload) + } + catch (error) { + logger.withFields({ packet: meta.name, role: session.role }).errorWithError('Failed to forward packet to downstream', error as Error) + } + } + } + }) + + target.on('end', () => { + for (const session of sessions) { + if (!session.downstream.ended) + session.downstream.end('target_end') + } + }) + + target.on('error', error => logger.errorWithError('Target server error', error)) + + const createListener = (role: HubRole, host: string, port: number, onlineMode: boolean) => { + const server = createServer({ + host, + port, + 'version': config.version, + 'motd': config.motd, + 'maxPlayers': 10, + 'online-mode': onlineMode, + }) + + server.on('login', (downstream: ProtocolClient) => { + const isViewer = role === 'viewer' + const expectedViewer = config.viewerUsername || config.upstreamUsername + + if (isViewer && expectedViewer && downstream.username !== expectedViewer) { + logger.withFields({ username: downstream.username }).error('Rejected viewer: username mismatch') + downstream.end('viewer_username_mismatch') + return + } + + if (!isViewer && downstream.username !== config.botUsername) { + logger.withFields({ username: downstream.username }).error('Rejected bot: username mismatch') + downstream.end('bot_username_mismatch') + return + } + + const session: HubSession = { role, username: downstream.username, downstream, readyForPlay: false } + sessions.add(session) + + logger.withFields({ role, username: downstream.username }).log('Hub client connected') + + downstream.state = 'configuration' + if (configCompleted && initialDispatchDone) { + for (const packet of configPacketBuffer) { + downstream.writeRaw(packet.buffer) + if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number') + downstream.compressionThreshold = packet.compressionThreshold + } + downstream.state = 'play' + session.readyForPlay = true + } + + dispatchInitialPackets() + + const relayDownstreamToTarget = (data: any, meta: { name: string, state: string }) => { + if (meta.state !== 'play') + return + + if (role === 'viewer') + return + + try { + if (role === 'bot' && (target as any).state === 'play') + target.write(meta.name, data) + if (config.debugPackets) + logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role }).log('Decoded upstream send') + } + catch (error) { + logger.withFields({ packet: meta.name, role }).errorWithError('Failed to forward packet to target server', error as Error) + } + + if (role === 'bot' && config.mirrorMovement && MOVEMENT_PACKETS.has(meta.name)) + mirrorToViewers(meta.name, data) + + if (role === 'bot' && config.mirrorActions && ACTION_PACKETS.has(meta.name)) + mirrorToViewers(meta.name, data) + } + + if (!config.rewriteIdentity && role === 'bot') { + downstream.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => { + if (meta.state !== 'play') + return + if ((target as any).state === 'play') + target.writeRaw(buffer) + if (config.debugPackets) + logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role, size: buffer.length }).log('Raw upstream send') + if (config.dumpPackets) + dumpRaw('downstream->hub', meta, buffer) + }) + } + else { + downstream.on('packet', relayDownstreamToTarget) + } + + const cleanup = (reason: string) => { + sessions.delete(session) + logger.withFields({ role, username: downstream.username, reason }).log('Closing hub session') + if (!downstream.ended) + downstream.end(reason) + } + + downstream.on('end', () => cleanup('downstream_end')) + downstream.on('error', error => logger.errorWithError('Downstream error', error)) + }) + + server.on('error', error => logger.errorWithError('Hub server error', error as Error)) + + return server + } + + const viewerServer = createListener('viewer', config.viewerListenHost, config.viewerListenPort, config.viewerOnlineMode) + const botServer = createListener('bot', config.botListenHost, config.botListenPort, config.botOnlineMode) + + logger.withFields({ + version: config.version, + viewerListenHost: config.viewerListenHost, + viewerListenPort: config.viewerListenPort, + viewerOnlineMode: config.viewerOnlineMode, + botListenHost: config.botListenHost, + botListenPort: config.botListenPort, + upstreamHost: config.upstreamHost, + upstreamPort: config.upstreamPort, + }).log('Minecraft hub started') + + return { + close: async () => { + for (const session of sessions) { + if (!session.downstream.ended) + session.downstream.end('hub_shutdown') + if (session.target && !session.target.ended) + session.target.end('hub_shutdown') + } + + if (!target.ended) + target.end('hub_shutdown') + + viewerServer.close() + botServer.close() + + if (rawDumpStream) + rawDumpStream.end() + + logger.log('Minecraft hub stopped') + }, + } +} diff --git a/services/minecraft-hub/src/index.ts b/services/minecraft-hub/src/index.ts new file mode 100644 index 000000000..8547e20a0 --- /dev/null +++ b/services/minecraft-hub/src/index.ts @@ -0,0 +1,24 @@ +import process, { exit } from 'node:process' + +import { loadConfig } from './config' +import { startHub } from './hub' +import { initLogger, useLogger } from './logger' + +async function main() { + initLogger() + const logger = useLogger('minecraft-hub') + const config = loadConfig() + + const hub = await startHub(config) + + process.on('SIGINT', () => { + logger.log('Received SIGINT, shutting down...') + void hub.close() + exit(0) + }) +} + +main().catch((error: Error) => { + useLogger('minecraft-hub').errorWithError('Fatal error', error) + exit(1) +}) diff --git a/services/minecraft-hub/src/logger.ts b/services/minecraft-hub/src/logger.ts new file mode 100644 index 000000000..24ce22e1a --- /dev/null +++ b/services/minecraft-hub/src/logger.ts @@ -0,0 +1,17 @@ +import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' + +let initialized = false + +export function initLogger() { + if (initialized) + return + + setGlobalLogLevel(LogLevel.Debug) + setGlobalFormat(Format.Pretty) + useLogg('minecraft-hub').useGlobalConfig().log('Logger initialized') + initialized = true +} + +export function useLogger(scope = 'minecraft-hub') { + return useLogg(scope).useGlobalConfig() +} diff --git a/services/minecraft-hub/tsconfig.json b/services/minecraft-hub/tsconfig.json new file mode 100644 index 000000000..00dcfd807 --- /dev/null +++ b/services/minecraft-hub/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +}