diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eeb7a79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,121 @@ +name: CI + +on: + push: + branches: [master, main, dev] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Pins the toolchain to go.mod's declared minimum, so accidental use of a + # newer language feature is caught. Linux only, see the note on the unit job. + vet: + name: vet + race + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: gofmt + run: | + unformatted="$(gofmt -l ./cmd ./internal ./test)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go test -race + run: go test -race ./... + + # Unit tests over the cross-version corpus. Pure Go, no network, no Node, ~1s. + # The OS matrix is nearly free here and catches path, endianness and + # line-ending surprises that only ever show up off Linux. + unit: + name: unit (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + # A current toolchain, NOT go.mod's minimum, because macos-latest is + # now macos-26 and its dyld refuses binaries without an LC_UUID load + # command. Go's internal linker only started emitting one in 1.26 + # (golang/go#69987, golang/go#78012), so building this repo with Go + # 1.21 on macOS 26 fails before any phantom code runs - including on + # pre-existing tests like internal/util. + # + # go.mod's directive stays at the true LANGUAGE minimum and is still + # exercised by the `vet` job on Linux, where LC_UUID is irrelevant. + go-version: 'stable' + cache: true + + - name: Corpus + rewrite tests + run: go test ./internal/... + + - name: Fuzz the pong parser (short) + if: matrix.os == 'ubuntu-latest' + run: go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=30s + + # End-to-end tests against a real phantom subprocess. + # + # Sharded for ISOLATION as much as for speed: phantom binds UDP :19132 + # unconditionally with SO_REUSEPORT, so two concurrent instances on one host + # silently load-balance each other's datagrams. One shard per runner gives + # each its own network stack; cases run serially within a shard. + e2e: + name: e2e (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3] + env: + PHANTOM_SHARD: ${{ matrix.shard }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: test/compat/node/package-lock.json + + - name: Install bedrock-protocol + working-directory: test/compat/node + run: npm ci + + - name: End-to-end + run: go test -tags='e2e slow node' ./test/compat/... -timeout=20m -v + + cross-compile: + name: cross-compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build every release target + run: make build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..92b39bc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,74 @@ +name: Nightly compatibility + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Runs the live tier against EVERY supported version rather than the sampled + # subset CI uses, by clearing PHANTOM_SHARD. + full-sweep: + name: Full version sweep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: test/compat/node/package-lock.json + + - name: Install bedrock-protocol + working-directory: test/compat/node + run: npm ci + + - name: Every version, unsharded + run: go test -tags='e2e slow node' ./test/compat/... -timeout=45m -v + + extended-fuzz: + name: Extended fuzzing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Fuzz the pong parser (10m) + run: go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=10m + + - name: Upload any crashers + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-corpus + path: internal/proto/testdata/fuzz/ + + # The mechanism that keeps "N versions" honest over time: when Mojang ships a + # version and bedrock-protocol adds support, this goes red and tells you + # exactly which file to regenerate. + matrix-drift: + name: Version matrix drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install generator deps + working-directory: internal/corpus/gen + run: npm install --no-audit --no-fund + + - name: Check for new Minecraft versions + working-directory: internal/corpus/gen + run: node drift.js diff --git a/.gitignore b/.gitignore index 00b41c6..d36d319 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ bin/ .DS_Store + +# Compatibility harness: Node deps are installed by `npm ci` from the committed +# lockfiles, never committed themselves. +node_modules/ diff --git a/Makefile b/Makefile index 9343b14..ad5f39b 100644 --- a/Makefile +++ b/Makefile @@ -1,61 +1,103 @@ SHELL=/bin/bash -.PHONY: prep +.PHONY: prep build build-target clean test test-e2e test-all fuzz corpus drift -OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 +# ARM note: +# arm5/arm6/arm7 = 32-bit (GOARCH=arm + GOARM) +# arm8/arm64 = 64-bit aarch64 (GOARCH=arm64). "arm8" does NOT mean +# "any ARMv8 Raspberry Pi" — most Pi OS installs are still +# 32-bit and need arm7 (or arm6 on Pi Zero / Pi 1). +# linux-x86 = GOARCH=386 (iSH on iOS is x86 userspace; ARM/amd64 builds fail there) +OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-x86 bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 bin/phantom-linux-arm64 CMDSRC=phantom.go build: prep ${OUT} bin/phantom-windows.exe: pushd cmd && \ - GOOS=windows GOARCH=amd64 go build -o ../bin/phantom-windows.exe ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o ../bin/phantom-windows.exe ${CMDSRC} && \ popd bin/phantom-windows-32bit.exe: pushd cmd && \ - GOOS=windows GOARCH=386 go build -o ../bin/phantom-windows-32bit.exe ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -o ../bin/phantom-windows-32bit.exe ${CMDSRC} && \ popd bin/phantom-macos: pushd cmd && \ - GOOS=darwin GOARCH=amd64 go build -o ../bin/phantom-macos ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o ../bin/phantom-macos ${CMDSRC} && \ popd bin/phantom-macos-arm8: pushd cmd && \ - GOOS=darwin GOARCH=arm64 go build -o ../bin/phantom-macos-arm8 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ../bin/phantom-macos-arm8 ${CMDSRC} && \ popd bin/phantom-linux: pushd cmd && \ - GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ + popd + +# Termux on Android uses these linux/arm* targets (not GOOS=android). +# 32-bit x86 for iSH (iOS) and other linux/386 environments. +bin/phantom-linux-x86: + pushd cmd && \ + CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o ../bin/phantom-linux-x86 ${CMDSRC} && \ popd bin/phantom-linux-arm5: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ popd bin/phantom-linux-arm6: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=6 go build -o ../bin/phantom-linux-arm6 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o ../bin/phantom-linux-arm6 ${CMDSRC} && \ popd bin/phantom-linux-arm7: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=7 go build -o ../bin/phantom-linux-arm7 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o ../bin/phantom-linux-arm7 ${CMDSRC} && \ popd +# 64-bit ARM / aarch64 only (64-bit Raspberry Pi OS, etc.) +# GOARM64=v8.0 keeps the baseline compatible with Pi 3/4 (no LSE requirement). bin/phantom-linux-arm8: pushd cmd && \ - GOOS=linux GOARCH=arm64 go build -o ../bin/phantom-linux-arm8 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOARM64=v8.0 go build -o ../bin/phantom-linux-arm8 ${CMDSRC} && \ popd +# Clearer alias for arm8 (same aarch64 binary). +bin/phantom-linux-arm64: bin/phantom-linux-arm8 + cp -f bin/phantom-linux-arm8 bin/phantom-linux-arm64 + prep: mkdir -p bin clean: rm -rf bin +# Unit tests over the cross-version corpus. Fast, no deps. test: go test ./... + +# End-to-end tests against a real phantom subprocess. +# Needs `npm ci` in test/compat/node first for the `node` tag to do anything; +# without it those cases skip rather than fail. +test-e2e: + go test -tags='e2e slow node' ./test/compat/... -timeout=20m + +test-all: test test-e2e + +fuzz: + go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=60s + +# Regenerates the captured pong corpus from real bedrock-protocol servers, one +# per supported Minecraft version. Dev-time only: CI replays the committed +# bytes and never runs this. Commit the diff. +corpus: + cd internal/corpus/gen && npm install --no-audit --no-fund && node capture.js + +# Reports Minecraft versions that bedrock-protocol supports but this repo does +# not yet test. +drift: + cd internal/corpus/gen && node drift.js diff --git a/README.md b/README.md index 3b2dcde..a9c9c74 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,63 @@ $ chmod u+x ./phantom- Just replace `` with macos, linux, etc. for the correct OS you're using. +**Raspberry Pi / ARM** + +Pick the binary from your *OS* architecture (`uname -m`), not the marketing +CPU name. A Pi 3/4/5 is "ARMv8" hardware, but a 32-bit Raspberry Pi OS still +needs the 32-bit build: + +| `uname -m` | Download | +|---|---| +| `aarch64` or `arm64` | `phantom-linux-arm8` (alias: `phantom-linux-arm64`) | +| `armv7l` | `phantom-linux-arm7` | +| `armv6l` (Pi Zero / Pi 1) | `phantom-linux-arm6` | + +`phantom-linux-arm8` is **64-bit only**. Using it on 32-bit Pi OS typically +fails with `Illegal instruction` or `Exec format error` — use `arm7` (or +`arm6`) instead. + +**Termux on Android** + +Termux can run the regular **Linux ARM** builds — you do **not** need a +`GOOS=android` binary (that requires the Android NDK). Pick by `uname -m`: + +| `uname -m` | Download | +|---|---| +| `aarch64` or `arm64` | `phantom-linux-arm8` | +| `armv7l` | `phantom-linux-arm7` | +| older / unsure | `phantom-linux-arm5` (widest compatibility) | + +Do **not** use plain `phantom-linux` (that is amd64). Copy the binary into +Termux home (`$HOME`) — shared storage under `/storage/...` is often mounted +`noexec`, which causes `Permission denied` even after `chmod`. Then: + +```bash +cd $HOME +chmod u+x ./phantom-linux-arm8 # or arm7 / arm5 +./phantom-linux-arm8 -server example.com:19132 +``` + +Run the binary as its own command; do not append `-server` to `cd`. + +**iSH on iOS** + +iSH provides a 32-bit x86 Linux userspace (`uname -m` is typically `i686` or +`x86_64` under emulation of 32-bit userspace — use the x86 build). Download or +build `phantom-linux-x86`, not the ARM or amd64 Linux binaries: + +```bash +chmod u+x ./phantom-linux-x86 +./phantom-linux-x86 -server example.com:19132 +``` + +Build it yourself with: + +```bash +make bin/phantom-linux-x86 +# or: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o bin/phantom-linux-x86 ./cmd +``` + ## Usage Open up a command prompt (Windows) or terminal (macOS & Linux) to the location @@ -35,7 +92,7 @@ you did something wrong. Or I did ;) Usage: ./phantom- [options] -server Options: - -6 Optional: Enables IPv6 support on port 19133 (experimental) + -6 Optional: Same as -ipv6 (legacy; broken in PowerShell — use -ipv6) -bind string Optional: IP address to listen on. Defaults to all interfaces. (default "0.0.0.0") -bind_port int @@ -43,6 +100,8 @@ Options: Note that phantom always binds to port 19132 as well, so both ports need to be open. -debug Optional: Enables debug logging + -ipv6 + Optional: Enables IPv6 support on port 19133 (experimental) -remove_ports Optional: Forces ports to be excluded from pong packets (experimental) -server string @@ -79,14 +138,22 @@ Same as above but bind the proxy server to local IP 10.0.0.5 and port 19133: ./phantom- -bind 10.0.0.5 -bind_port 19133 -server lax.mcbr.cubed.host:19132 ``` -**Running multiple instances** +**Running multiple servers** -If you have multiple Bedrock servers, you can run phantom multiple times on -the same device to allow all of your servers to show up on the LAN list. All -you have to do is start one instance of phantom for each server and set the -`-server` flag appropriately. You don't need to use `-bind` or change the port. -But you probably do need to make sure you have a firewall rule that allows -all UDP traffic for the phantom executable. +If you have multiple Bedrock servers, pass each one to a **single** phantom +process with repeated or comma-separated `-server` flags: + +```bash +./phantom- -server 192.168.1.13:19134 -server 192.168.1.13:19136 +# or +./phantom- -server 192.168.1.13:19134,192.168.1.13:19136 +``` + +Phantom binds LAN discovery (`:19132`) once and answers for every upstream +server, so they all appear in the Friends/LAN list at the same time. Running +multiple phantom *processes* cannot share `:19132` correctly — only one will +see traffic — so use multiple `-server` flags instead. You probably also need +a firewall rule that allows all UDP traffic for the phantom executable. **A note on `-bind`:** @@ -138,7 +205,8 @@ computer, a VM, or even with a Minecraft hosting service. ## Supported platforms - This tool should work on Windows, macOS, and Linux. -- ARM builds are available for Raspberry Pi and similar SOCs. +- A `phantom-linux-x86` (386) build is available for iSH on iOS. +- ARM builds are available for Raspberry Pi, Termux on Android, and similar SOCs (see Installing for which binary to use). - Minecraft for Windows 10, iOS/Android, Xbox One, and PS4 are currently supported. - **Nintendo Switch is not supported.** @@ -148,6 +216,35 @@ your Windows Firewall settings and open up all UDP ports for phantom. ## Troubleshooting +**`Illegal instruction` (or `Exec format error`) on a Raspberry Pi** + +You almost certainly downloaded the wrong ARM build. `phantom-linux-arm8` is +64-bit (`aarch64`). On 32-bit Raspberry Pi OS (`uname -m` shows `armv7l` or +`armv6l`), use `phantom-linux-arm7` or `phantom-linux-arm6` instead — even if +the board itself is ARMv8. + +**`Permission denied` or `cannot execute binary file` in Termux** + +Move the binary into `$HOME` (not `/storage/...`), `chmod u+x`, and use the +ARM Linux build that matches `uname -m` — not `phantom-linux` (amd64) and not +an Android/`GOOS=android` build. See Installing → Termux on Android. + +**`cd: too many arguments`** + +`cd` only changes directory. Run phantom separately, e.g. +`cd ~/phantom && ./phantom-linux-arm8 -server 1.2.3.4:19132`. + +**`syntax error` / `unexpected ")"` when starting phantom in iSH** + +You downloaded the wrong architecture. iSH needs `phantom-linux-x86` (linux/386). +ARM and amd64 Linux binaries look like garbage to the shell and produce syntax +errors. See Installing → iSH on iOS. + +**`listen udp4 :19132: invalid argument` on iSH** + +Older builds required SO_REUSEPORT, which iSH rejects. Current builds fall back +to a normal UDP listen when reuseport is unsupported. + **My server isn't showing up on the list but it's online and phantom is showing connections!** Make sure "Visible to LAN players" is turn **ON** in your server's settings: *(below shows setting OFF)* diff --git a/cmd/phantom.go b/cmd/phantom.go index 87e6450..c42a23a 100644 --- a/cmd/phantom.go +++ b/cmd/phantom.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "time" "github.com/jhead/phantom/internal/proxy" @@ -12,30 +13,51 @@ import ( "github.com/rs/zerolog/log" ) -var bindAddressString string -var serverAddressString string -var bindPortInt uint16 +// serverList accumulates repeated and/or comma-separated -server values. +type serverList []string + +func (s *serverList) String() string { + return strings.Join(*s, ",") +} + +func (s *serverList) Set(value string) error { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + *s = append(*s, part) + } + return nil +} func main() { - // Required - serverArg := flag.String("server", "", "Required: Bedrock/MCPE server IP address and port (ex: 1.2.3.4:19132)") + var servers serverList + + // Required (repeatable / comma-separated) + flag.Var(&servers, "server", "Required: Bedrock/MCPE server IP:port. Repeat or comma-separate for multiple servers on one process (ex: 1.2.3.4:19132)") // Optional bindArg := flag.String("bind", "0.0.0.0", "Optional: IP address to listen on. Defaults to all interfaces.") - bindPortArg := flag.Int("bind_port", 0, "Optional: Port to listen on. Defaults to 0, which selects a random port.\nNote that phantom always binds to port 19132 as well, so both ports need to be open.") + bindPortArg := flag.Int("bind_port", 0, "Optional: Port to listen on. Defaults to 0, which selects a random port.\nNote that phantom always binds to port 19132 as well, so both ports need to be open.\nOnly valid with a single -server.") timeoutArg := flag.Int("timeout", 60, "Optional: Seconds to wait before cleaning up a disconnected client") debugArg := flag.Bool("debug", false, "Optional: Enables debug logging") - ipv6Arg := flag.Bool("6", false, "Optional: Enables IPv6 support on port 19133 (experimental)") removePortsArg := flag.Bool("remove_ports", false, "Optional: Forces ports to be excluded from pong packets (experimental)") workersArg := flag.Uint("workers", 1, "Optional: Number of workers, useful for tweaking performance (experimental)") + // Prefer -ipv6: PowerShell treats bare -6 as a number, so the flag never reaches + // the process (see #124). Keep -6 as a legacy alias for cmd.exe / POSIX shells. + var enableIPv6 bool + flag.BoolVar(&enableIPv6, "ipv6", false, "Optional: Enables IPv6 support on port 19133 (experimental)") + flag.BoolVar(&enableIPv6, "6", false, "Optional: Same as -ipv6 (legacy; broken in PowerShell — use -ipv6)") + flag.Usage = usage flag.Parse() - if *serverArg == "" { + if len(servers) == 0 { // Maybe it only has the server IP? if len(os.Args) == 2 { - *serverArg = os.Args[1] + _ = servers.Set(os.Args[1]) } else { fmt.Println("Did you forget -server?") flag.Usage() @@ -43,54 +65,100 @@ func main() { } } - bindAddressString = *bindArg - serverAddressString = *serverArg + if len(servers) == 0 { + fmt.Println("Did you forget -server?") + flag.Usage() + return + } + + if *bindPortArg != 0 && len(servers) > 1 { + fmt.Println("-bind_port cannot be used with multiple -server values; omit it so each server gets a random data port") + return + } + idleTimeout := time.Duration(*timeoutArg) * time.Second - bindPortInt = uint16(*bindPortArg) + bindPortInt := uint16(*bindPortArg) logLevel := zerolog.InfoLevel if *debugArg { logLevel = zerolog.DebugLevel } - fmt.Printf("Starting up with remote server IP: %s\n", serverAddressString) + fmt.Printf("Starting up with remote server(s): %s\n", strings.Join(servers, ", ")) // Configure logging output log.Logger = log. Output(zerolog.ConsoleWriter{Out: os.Stdout}). Level(logLevel) - proxyServer, err := proxy.New(proxy.ProxyPrefs{ - bindAddressString, - bindPortInt, - serverAddressString, - idleTimeout, - *ipv6Arg, - *removePortsArg, - *workersArg, - }) + proxies := make([]*proxy.ProxyServer, 0, len(servers)) + for _, remote := range servers { + prefs := proxy.ProxyPrefs{ + BindAddress: *bindArg, + BindPort: bindPortInt, + RemoteServer: remote, + IdleTimeout: idleTimeout, + EnableIPv6: enableIPv6, + RemovePorts: *removePortsArg, + NumWorkers: *workersArg, + DisableDiscoveryListener: len(servers) > 1, + } + proxyServer, err := proxy.New(prefs) + if err != nil { + fmt.Printf("Failed to init server %s: %s\n", remote, err) + closeAll(proxies, nil) + return + } + proxies = append(proxies, proxyServer) + } + + // Single server: original blocking Start() (owns :19132 itself). + if len(proxies) == 1 { + watchForInterrupt(proxies, nil) + if err := proxies[0].Start(); err != nil { + fmt.Printf("Failed to start server: %s\n", err) + } + return + } + + // Multiple servers: bind data planes first, then one shared discovery hub. + for _, p := range proxies { + if err := p.StartAsync(); err != nil { + fmt.Printf("Failed to start server %s: %s\n", p.RemoteServer(), err) + closeAll(proxies, nil) + return + } + } + + hub, err := proxy.StartDiscoveryHub(proxies, enableIPv6) if err != nil { - fmt.Printf("Failed to init server: %s\n", err) + fmt.Printf("Failed to start discovery: %s\n", err) + closeAll(proxies, nil) return } - // Watch for CTRL + C - watchForInterrupt(proxyServer) + watchForInterrupt(proxies, hub) + select {} +} - if err := proxyServer.Start(); err != nil { - fmt.Printf("Failed to start server: %s\n", err) +func closeAll(proxies []*proxy.ProxyServer, hub *proxy.DiscoveryHub) { + if hub != nil { + hub.Close() + } + for _, p := range proxies { + p.Close() } } func usage() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] -server \n\nOptions:\n", os.Args[0]) + fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] -server [-server ...]\n\nOptions:\n", os.Args[0]) flag.PrintDefaults() } // Watches for CTRL + C signals and shuts down the server // A second CTRL + C will force it to exit immediately -func watchForInterrupt(proxyServer *proxy.ProxyServer) { +func watchForInterrupt(proxies []*proxy.ProxyServer, hub *proxy.DiscoveryHub) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, os.Interrupt) @@ -107,7 +175,7 @@ func watchForInterrupt(proxyServer *proxy.ProxyServer) { fmt.Println("\nPress CTRL + C again to force quit") once = true - proxyServer.Close() + closeAll(proxies, hub) } }() } diff --git a/go.mod b/go.mod index c803faa..f7d0ef4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jhead/phantom -go 1.12 +go 1.21 require ( github.com/libp2p/go-reuseport v0.0.1 @@ -8,3 +8,10 @@ require ( github.com/stretchr/testify v1.3.0 github.com/tevino/abool v1.2.0 ) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e // indirect +) diff --git a/internal/clientmap/clientmap.go b/internal/clientmap/clientmap.go index a6d5fc2..427d9a8 100644 --- a/internal/clientmap/clientmap.go +++ b/internal/clientmap/clientmap.go @@ -82,6 +82,13 @@ func (cm *ClientMap) idleCleanupLoop() { } } +// Len returns the number of active client connections. +func (cm *ClientMap) Len() int { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + return len(cm.clients) +} + func (cm *ClientMap) Delete(clientAddr net.Addr) { key := clientAddr.String() @@ -95,6 +102,34 @@ func (cm *ClientMap) Delete(clientAddr net.Addr) { cm.mutex.Unlock() } +// IsOwnAddress reports whether addr is the local address of an outbound +// server connection tracked by this map. +// +// This matters when the remote server shares a host/port with phantom's own +// UDP listeners (commonly :19132 with SO_REUSEADDR): outbound datagrams can +// be delivered back to our ping listener. If those echoes are treated as new +// clients, each one DialUDP()s again and the process can hit EMFILE +// ("too many open files"). +func (cm *ClientMap) IsOwnAddress(addr net.Addr) bool { + if addr == nil { + return false + } + + key := addr.String() + + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + for _, client := range cm.clients { + if client.conn.LocalAddr().String() == key { + return true + } + } + + return false +} + + // Get gets or creates a new UDP connection to the remote server and stores it // in a map, matching clients to remote server connections. This way, we keep one // UDP connection open to the server for each client. The handler parameter is diff --git a/internal/clientmap/clientmap_test.go b/internal/clientmap/clientmap_test.go new file mode 100644 index 0000000..1ce28f0 --- /dev/null +++ b/internal/clientmap/clientmap_test.go @@ -0,0 +1,110 @@ +package clientmap + +import ( + "net" + "testing" + "time" +) + +func startUDPServer(t *testing.T) (*net.UDPConn, *net.UDPAddr) { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn, conn.LocalAddr().(*net.UDPAddr) +} + +func TestIsOwnAddress(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34567") + if err != nil { + t.Fatal(err) + } + + serverConn, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if !cm.IsOwnAddress(serverConn.LocalAddr()) { + t.Fatalf("expected outbound local addr %s to be recognized as own", serverConn.LocalAddr()) + } + + other, err := net.ResolveUDPAddr("udp", "127.0.0.1:34568") + if err != nil { + t.Fatal(err) + } + if cm.IsOwnAddress(other) { + t.Fatalf("did not expect unrelated addr %s to be own", other) + } + if cm.IsOwnAddress(nil) { + t.Fatal("nil addr should not be own") + } +} + +// Documents the failure mode behind issue #184: if an echoed outbound packet is +// keyed as a new client, Get opens another UDP socket. Repeating that (as the +// ping listener can when -server is this host:19132) exhausts file descriptors. +func TestGetOnOwnLocalAddrCreatesExtraConnection(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34569") + if err != nil { + t.Fatal(err) + } + + conn1, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if cm.Len() != 1 { + t.Fatalf("Len=%d, want 1", cm.Len()) + } + + conn2, err := cm.Get(conn1.LocalAddr(), remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get(own local addr): %v", err) + } + if cm.Len() != 2 { + t.Fatalf("Len=%d, want 2 after treating own local addr as a client", cm.Len()) + } + if conn1.LocalAddr().String() == conn2.LocalAddr().String() { + t.Fatal("expected a distinct outbound socket for the echoed addr") + } +} + +func TestSkippingOwnAddressPreventsConnectionStorm(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34570") + if err != nil { + t.Fatal(err) + } + + conn, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // Proxy fix: never call Get for IsOwnAddress sources. + for i := 0; i < 64; i++ { + echo := conn.LocalAddr() + if !cm.IsOwnAddress(echo) { + t.Fatalf("iteration %d: expected own address", i) + } + // skip Get — connection count must stay flat + } + + if cm.Len() != 1 { + t.Fatalf("Len=%d, want 1 after ignoring own-address echoes", cm.Len()) + } +} diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go new file mode 100644 index 0000000..7082344 --- /dev/null +++ b/internal/corpus/corpus.go @@ -0,0 +1,248 @@ +// Package corpus loads the cross-version compatibility test fixtures. +// +// It contains no phantom protocol logic, only fixture data, which is why the +// end-to-end tests may import it without learning anything about phantom's +// implementation. See test/compat/DESIGN.md. +package corpus + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + _ "embed" +) + +//go:embed data/captured.json +var capturedJSON []byte + +//go:embed data/synthetic.json +var syntheticJSON []byte + +// Magic is the 16-byte RakNet offline-message magic constant. +var Magic = []byte{ + 0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, + 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78, +} + +// PongID and PingID are the RakNet offline packet IDs phantom cares about. +const ( + PongID = 0x1c + PingID = 0x01 + PingOpenID = 0x02 +) + +// headerLen is the fixed part of an Unconnected Pong: ID(1) + pingTime(8) + +// serverGUID(8) + magic(16) + strLen(2). +const headerLen = 35 + +// CapturedEntry is one real pong captured from a bedrock-protocol server. +type CapturedEntry struct { + MC string `json:"mc"` + Protocol int `json:"protocol"` + PongHex string `json:"pongHex"` + EchoedPingTime string `json:"echoedPingTime"` + ServerGUID string `json:"serverGUID"` + MOTD string `json:"motd"` + Fields []string `json:"fields"` +} + +// Frame returns the raw pong datagram bytes. +func (e CapturedEntry) Frame() []byte { + b, err := hex.DecodeString(e.PongHex) + if err != nil { + panic(fmt.Sprintf("corpus: entry %s has invalid pongHex: %v", e.MC, err)) + } + return b +} + +// FieldCount is the number of real MOTD fields, excluding the empty element +// produced by the conventional trailing semicolon. +func (e CapturedEntry) FieldCount() int { + return realFieldCount(e.MOTD) +} + +type capturedDoc struct { + GeneratedBy string `json:"generatedBy"` + PingTime string `json:"pingTime"` + ClientGUID string `json:"clientGUID"` + Entries []CapturedEntry `json:"entries"` +} + +// SyntheticEntry is a hand-authored fixture: either a MOTD that gets wrapped in +// a well-formed frame, or verbatim (possibly malformed) bytes. +type SyntheticEntry struct { + ID string `json:"id"` + Desc string `json:"desc"` + MOTD *string `json:"motd"` + MOTDRepeat *motdRepeat `json:"motdRepeat"` + RawHex *string `json:"rawHex"` + FieldCount int `json:"fieldCount"` + WantParseError bool `json:"wantParseError"` + NoRoundTrip bool `json:"noRoundTrip"` + Tags []string `json:"tags"` + + // Frame surgery. These build an otherwise complete, well-formed pong with + // exactly one thing wrong, so a fixture tests the defect it names rather + // than tripping an unrelated length check first. + PacketIDOverride *int `json:"packetIDOverride"` + MagicOverride *string `json:"magicOverride"` + + frame []byte + motd string +} + +type motdRepeat struct { + Prefix string `json:"prefix"` + Unit string `json:"unit"` + Count int `json:"count"` + Suffix string `json:"suffix"` +} + +// Frame returns the datagram bytes for this fixture. +func (e SyntheticEntry) Frame() []byte { return e.frame } + +// IsRaw reports whether this fixture supplies verbatim bytes rather than a +// generated well-formed frame. +func (e SyntheticEntry) IsRaw() bool { return e.RawHex != nil } + +// MOTDString returns the MOTD this fixture carries. Meaningless for raw fixtures. +func (e SyntheticEntry) MOTDString() string { return e.motd } + +// HasTag reports whether the fixture carries the given tag. +func (e SyntheticEntry) HasTag(tag string) bool { + for _, t := range e.Tags { + if t == tag { + return true + } + } + return false +} + +type syntheticDoc struct { + PingTime string `json:"pingTime"` + ServerGUID string `json:"serverGUID"` + Entries []SyntheticEntry `json:"entries"` +} + +var ( + captured capturedDoc + synthetic syntheticDoc +) + +func init() { + mustUnmarshal(capturedJSON, &captured, "captured.json") + mustUnmarshal(syntheticJSON, &synthetic, "synthetic.json") + + pingTime := mustHex(synthetic.PingTime, "synthetic pingTime") + guid := mustHex(synthetic.ServerGUID, "synthetic serverGUID") + + for i := range synthetic.Entries { + e := &synthetic.Entries[i] + switch { + case e.RawHex != nil: + e.frame = mustHex(*e.RawHex, "entry "+e.ID) + case e.MOTDRepeat != nil: + r := e.MOTDRepeat + e.motd = r.Prefix + strings.Repeat(r.Unit, r.Count) + r.Suffix + e.frame = e.build(pingTime, guid) + case e.MOTD != nil: + e.motd = *e.MOTD + e.frame = e.build(pingTime, guid) + default: + panic(fmt.Sprintf("corpus: synthetic entry %q has none of motd/motdRepeat/rawHex", e.ID)) + } + } +} + +func mustUnmarshal(data []byte, v interface{}, name string) { + if err := json.Unmarshal(data, v); err != nil { + panic(fmt.Sprintf("corpus: parsing %s: %v", name, err)) + } +} + +func mustHex(s, what string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(fmt.Sprintf("corpus: %s is not valid hex: %v", what, err)) + } + return b +} + +// build assembles this fixture's frame, applying any single-field surgery. +func (e SyntheticEntry) build(pingTime, serverGUID []byte) []byte { + id := byte(PongID) + if e.PacketIDOverride != nil { + id = byte(*e.PacketIDOverride) + } + magic := Magic + if e.MagicOverride != nil { + magic = mustHex(*e.MagicOverride, "magicOverride for "+e.ID) + if len(magic) != len(Magic) { + panic(fmt.Sprintf("corpus: %s magicOverride must be %d bytes, got %d", + e.ID, len(Magic), len(magic))) + } + } + return buildPong(id, pingTime, serverGUID, magic, e.motd) +} + +// BuildPong assembles a well-formed Unconnected Pong datagram around a MOTD. +func BuildPong(pingTime, serverGUID []byte, motd string) []byte { + return buildPong(PongID, pingTime, serverGUID, Magic, motd) +} + +func buildPong(id byte, pingTime, serverGUID, magic []byte, motd string) []byte { + out := make([]byte, 0, headerLen+len(motd)) + out = append(out, id) + out = append(out, pingTime...) + out = append(out, serverGUID...) + out = append(out, magic...) + + var lenBuf [2]byte + binary.BigEndian.PutUint16(lenBuf[:], uint16(len(motd))) + out = append(out, lenBuf[:]...) + return append(out, motd...) +} + +// SplitMOTD extracts the MOTD string from a well-formed pong datagram. +func SplitMOTD(frame []byte) (string, error) { + if len(frame) < headerLen { + return "", fmt.Errorf("frame too short: %d bytes", len(frame)) + } + n := int(binary.BigEndian.Uint16(frame[33:35])) + if len(frame) < headerLen+n { + return "", fmt.Errorf("declared MOTD length %d exceeds %d remaining bytes", n, len(frame)-headerLen) + } + return string(frame[headerLen : headerLen+n]), nil +} + +// realFieldCount counts MOTD fields, ignoring the empty element produced by a +// conventional trailing semicolon. +func realFieldCount(motd string) int { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + return n - 1 + } + return len(parts) +} + +// RealFieldCount is the exported form of realFieldCount. +func RealFieldCount(motd string) int { return realFieldCount(motd) } + +// Captured returns every per-version captured pong, ascending by protocol number. +func Captured() []CapturedEntry { return captured.Entries } + +// CapturedPingTime is the ping time the generator sent; servers must echo it. +func CapturedPingTime() []byte { return mustHex(captured.PingTime, "captured pingTime") } + +// GeneratedBy identifies the tool and version that produced the captured corpus. +func GeneratedBy() string { return captured.GeneratedBy } + +// Synthetic returns every hand-authored fixture. +func Synthetic() []SyntheticEntry { return synthetic.Entries } + +// SyntheticPingTime and SyntheticGUID are the values baked into generated frames. +func SyntheticPingTime() []byte { return mustHex(synthetic.PingTime, "synthetic pingTime") } +func SyntheticGUID() []byte { return mustHex(synthetic.ServerGUID, "synthetic serverGUID") } diff --git a/internal/corpus/data/captured.json b/internal/corpus/data/captured.json new file mode 100644 index 0000000..48ea299 --- /dev/null +++ b/internal/corpus/data/captured.json @@ -0,0 +1,1241 @@ +{ + "_comment": [ + "GENERATED FILE - do not edit by hand. Regenerate with `make corpus`.", + "Real RakNet Unconnected Pong bytes captured from a bedrock-protocol server,", + "one entry per supported Minecraft version.", + "", + "CAVEAT: these are bedrock-protocol advertisements, not Mojang BDS ones.", + "The MOTD field COUNT is constant across every version here (bedrock-protocol", + "uses one serializer); only the protocol number and version string vary.", + "Real per-version shape diversity lives in synthetic.json." + ], + "generatedBy": "bedrock-protocol@3.57.0", + "pingTime": "0011223344556677", + "clientGUID": "8877665544332211", + "entries": [ + { + "mc": "1.16.201", + "protocol": 422, + "pongHex": "1c0011223344556677f3ccb98933ab0fc600ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3432323b312e31362e3230313b303b333b313738353030363435343933353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330303b31393330303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f3ccb98933ab0fc6", + "motd": "MCPE;Bedrock Protocol Server;422;1.16.201;0;3;1785006454935;bedrock-protocol;Creative;1;19300;19300;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "422", + "1.16.201", + "0", + "3", + "1785006454935", + "bedrock-protocol", + "Creative", + "1", + "19300", + "19300", + "0", + "" + ] + }, + { + "mc": "1.16.210", + "protocol": 428, + "pongHex": "1c001122334455667730b99ff633c357cc00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3432383b312e31362e3231303b303b333b313738353030363435363532363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330313b31393330313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "30b99ff633c357cc", + "motd": "MCPE;Bedrock Protocol Server;428;1.16.210;0;3;1785006456526;bedrock-protocol;Creative;1;19301;19301;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "428", + "1.16.210", + "0", + "3", + "1785006456526", + "bedrock-protocol", + "Creative", + "1", + "19301", + "19301", + "0", + "" + ] + }, + { + "mc": "1.16.220", + "protocol": 431, + "pongHex": "1c0011223344556677ac5082cc33dba9f300ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3433313b312e31362e3232303b303b333b313738353030363435383132303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330323b31393330323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ac5082cc33dba9f3", + "motd": "MCPE;Bedrock Protocol Server;431;1.16.220;0;3;1785006458120;bedrock-protocol;Creative;1;19302;19302;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "431", + "1.16.220", + "0", + "3", + "1785006458120", + "bedrock-protocol", + "Creative", + "1", + "19302", + "19302", + "0", + "" + ] + }, + { + "mc": "1.17.0", + "protocol": 440, + "pongHex": "1c0011223344556677e6fc66ac33f41f2900ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3434303b312e31372e303b303b333b313738353030363435393732333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330333b31393330333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "e6fc66ac33f41f29", + "motd": "MCPE;Bedrock Protocol Server;440;1.17.0;0;3;1785006459723;bedrock-protocol;Creative;1;19303;19303;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "440", + "1.17.0", + "0", + "3", + "1785006459723", + "bedrock-protocol", + "Creative", + "1", + "19303", + "19303", + "0", + "" + ] + }, + { + "mc": "1.17.10", + "protocol": 448, + "pongHex": "1c0011223344556677d74e0137340c8a5300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3434383b312e31372e31303b303b333b313738353030363436313332333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330343b31393330343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "d74e0137340c8a53", + "motd": "MCPE;Bedrock Protocol Server;448;1.17.10;0;3;1785006461323;bedrock-protocol;Creative;1;19304;19304;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "448", + "1.17.10", + "0", + "3", + "1785006461323", + "bedrock-protocol", + "Creative", + "1", + "19304", + "19304", + "0", + "" + ] + }, + { + "mc": "1.17.30", + "protocol": 465, + "pongHex": "1c0011223344556677b14356fc3424e0f900ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3436353b312e31372e33303b303b333b313738353030363436323931383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330353b31393330353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "b14356fc3424e0f9", + "motd": "MCPE;Bedrock Protocol Server;465;1.17.30;0;3;1785006462918;bedrock-protocol;Creative;1;19305;19305;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "465", + "1.17.30", + "0", + "3", + "1785006462918", + "bedrock-protocol", + "Creative", + "1", + "19305", + "19305", + "0", + "" + ] + }, + { + "mc": "1.17.40", + "protocol": 471, + "pongHex": "1c00112233445566778c81af73343d4ac100ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3437313b312e31372e34303b303b333b313738353030363436343531383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330363b31393330363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8c81af73343d4ac1", + "motd": "MCPE;Bedrock Protocol Server;471;1.17.40;0;3;1785006464518;bedrock-protocol;Creative;1;19306;19306;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "471", + "1.17.40", + "0", + "3", + "1785006464518", + "bedrock-protocol", + "Creative", + "1", + "19306", + "19306", + "0", + "" + ] + }, + { + "mc": "1.18.0", + "protocol": 475, + "pongHex": "1c00112233445566775c9f13183455d90400ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3437353b312e31382e303b303b333b313738353030363436363132383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330373b31393330373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "5c9f13183455d904", + "motd": "MCPE;Bedrock Protocol Server;475;1.18.0;0;3;1785006466128;bedrock-protocol;Creative;1;19307;19307;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "475", + "1.18.0", + "0", + "3", + "1785006466128", + "bedrock-protocol", + "Creative", + "1", + "19307", + "19307", + "0", + "" + ] + }, + { + "mc": "1.18.11", + "protocol": 486, + "pongHex": "1c001122334455667730be2871346e5ca800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3438363b312e31382e31313b303b333b313738353030363436373733343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330383b31393330383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "30be2871346e5ca8", + "motd": "MCPE;Bedrock Protocol Server;486;1.18.11;0;3;1785006467734;bedrock-protocol;Creative;1;19308;19308;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "486", + "1.18.11", + "0", + "3", + "1785006467734", + "bedrock-protocol", + "Creative", + "1", + "19308", + "19308", + "0", + "" + ] + }, + { + "mc": "1.18.30", + "protocol": 503, + "pongHex": "1c00112233445566775195059334871d8300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3530333b312e31382e33303b303b333b313738353030363436393335363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330393b31393330393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "5195059334871d83", + "motd": "MCPE;Bedrock Protocol Server;503;1.18.30;0;3;1785006469356;bedrock-protocol;Creative;1;19309;19309;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "503", + "1.18.30", + "0", + "3", + "1785006469356", + "bedrock-protocol", + "Creative", + "1", + "19309", + "19309", + "0", + "" + ] + }, + { + "mc": "1.19.1", + "protocol": 527, + "pongHex": "1c0011223344556677be47e5bd34a02af900ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3532373b312e31392e313b303b333b313738353030363437303939383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331303b31393331303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "be47e5bd34a02af9", + "motd": "MCPE;Bedrock Protocol Server;527;1.19.1;0;3;1785006470998;bedrock-protocol;Creative;1;19310;19310;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "527", + "1.19.1", + "0", + "3", + "1785006470998", + "bedrock-protocol", + "Creative", + "1", + "19310", + "19310", + "0", + "" + ] + }, + { + "mc": "1.19.10", + "protocol": 534, + "pongHex": "1c0011223344556677ed24ea8d34b8b42a00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3533343b312e31392e31303b303b333b313738353030363437323630363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331313b31393331313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ed24ea8d34b8b42a", + "motd": "MCPE;Bedrock Protocol Server;534;1.19.10;0;3;1785006472606;bedrock-protocol;Creative;1;19311;19311;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "534", + "1.19.10", + "0", + "3", + "1785006472606", + "bedrock-protocol", + "Creative", + "1", + "19311", + "19311", + "0", + "" + ] + }, + { + "mc": "1.19.20", + "protocol": 544, + "pongHex": "1c0011223344556677b9aee6a534d131d700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3534343b312e31392e32303b303b333b313738353030363437343231313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331323b31393331323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "b9aee6a534d131d7", + "motd": "MCPE;Bedrock Protocol Server;544;1.19.20;0;3;1785006474211;bedrock-protocol;Creative;1;19312;19312;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "544", + "1.19.20", + "0", + "3", + "1785006474211", + "bedrock-protocol", + "Creative", + "1", + "19312", + "19312", + "0", + "" + ] + }, + { + "mc": "1.19.21", + "protocol": 545, + "pongHex": "1c0011223344556677c672fd9e34e9af4c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3534353b312e31392e32313b303b333b313738353030363437353831363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331333b31393331333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c672fd9e34e9af4c", + "motd": "MCPE;Bedrock Protocol Server;545;1.19.21;0;3;1785006475816;bedrock-protocol;Creative;1;19313;19313;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "545", + "1.19.21", + "0", + "3", + "1785006475816", + "bedrock-protocol", + "Creative", + "1", + "19313", + "19313", + "0", + "" + ] + }, + { + "mc": "1.19.30", + "protocol": 554, + "pongHex": "1c00112233445566778c2c9cc23502297e00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3535343b312e31392e33303b303b333b313738353030363437373432303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331343b31393331343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8c2c9cc23502297e", + "motd": "MCPE;Bedrock Protocol Server;554;1.19.30;0;3;1785006477420;bedrock-protocol;Creative;1;19314;19314;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "554", + "1.19.30", + "0", + "3", + "1785006477420", + "bedrock-protocol", + "Creative", + "1", + "19314", + "19314", + "0", + "" + ] + }, + { + "mc": "1.19.40", + "protocol": 557, + "pongHex": "1c0011223344556677eedf5c50351aa2f000ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3535373b312e31392e34303b303b333b313738353030363437393032343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331353b31393331353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "eedf5c50351aa2f0", + "motd": "MCPE;Bedrock Protocol Server;557;1.19.40;0;3;1785006479024;bedrock-protocol;Creative;1;19315;19315;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "557", + "1.19.40", + "0", + "3", + "1785006479024", + "bedrock-protocol", + "Creative", + "1", + "19315", + "19315", + "0", + "" + ] + }, + { + "mc": "1.19.50", + "protocol": 560, + "pongHex": "1c001122334455667723ba69cf3533269e00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536303b312e31392e35303b303b333b313738353030363438303633313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331363b31393331363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "23ba69cf3533269e", + "motd": "MCPE;Bedrock Protocol Server;560;1.19.50;0;3;1785006480631;bedrock-protocol;Creative;1;19316;19316;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "560", + "1.19.50", + "0", + "3", + "1785006480631", + "bedrock-protocol", + "Creative", + "1", + "19316", + "19316", + "0", + "" + ] + }, + { + "mc": "1.19.62", + "protocol": 567, + "pongHex": "1c0011223344556677baf6e3ea354bc37c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536373b312e31392e36323b303b333b313738353030363438323234343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331373b31393331373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "baf6e3ea354bc37c", + "motd": "MCPE;Bedrock Protocol Server;567;1.19.62;0;3;1785006482244;bedrock-protocol;Creative;1;19317;19317;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "567", + "1.19.62", + "0", + "3", + "1785006482244", + "bedrock-protocol", + "Creative", + "1", + "19317", + "19317", + "0", + "" + ] + }, + { + "mc": "1.19.60", + "protocol": 567, + "pongHex": "1c001122334455667769ca7a8435643d4c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536373b312e31392e36303b303b333b313738353030363438333834383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331383b31393331383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "69ca7a8435643d4c", + "motd": "MCPE;Bedrock Protocol Server;567;1.19.60;0;3;1785006483848;bedrock-protocol;Creative;1;19318;19318;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "567", + "1.19.60", + "0", + "3", + "1785006483848", + "bedrock-protocol", + "Creative", + "1", + "19318", + "19318", + "0", + "" + ] + }, + { + "mc": "1.19.63", + "protocol": 568, + "pongHex": "1c0011223344556677364d999c357ca99b00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536383b312e31392e36333b303b333b313738353030363438353434393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331393b31393331393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "364d999c357ca99b", + "motd": "MCPE;Bedrock Protocol Server;568;1.19.63;0;3;1785006485449;bedrock-protocol;Creative;1;19319;19319;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "568", + "1.19.63", + "0", + "3", + "1785006485449", + "bedrock-protocol", + "Creative", + "1", + "19319", + "19319", + "0", + "" + ] + }, + { + "mc": "1.19.70", + "protocol": 575, + "pongHex": "1c0011223344556677c734e7e935952b3800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3537353b312e31392e37303b303b333b313738353030363438373035353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332303b31393332303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c734e7e935952b38", + "motd": "MCPE;Bedrock Protocol Server;575;1.19.70;0;3;1785006487055;bedrock-protocol;Creative;1;19320;19320;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "575", + "1.19.70", + "0", + "3", + "1785006487055", + "bedrock-protocol", + "Creative", + "1", + "19320", + "19320", + "0", + "" + ] + }, + { + "mc": "1.19.80", + "protocol": 582, + "pongHex": "1c0011223344556677e4a145dc35adf27f00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3538323b312e31392e38303b303b333b313738353030363438383637393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332313b31393332313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "e4a145dc35adf27f", + "motd": "MCPE;Bedrock Protocol Server;582;1.19.80;0;3;1785006488679;bedrock-protocol;Creative;1;19321;19321;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "582", + "1.19.80", + "0", + "3", + "1785006488679", + "bedrock-protocol", + "Creative", + "1", + "19321", + "19321", + "0", + "" + ] + }, + { + "mc": "1.20.0", + "protocol": 589, + "pongHex": "1c0011223344556677c0c4de6a35c6b65700ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3538393b312e32302e303b303b333b313738353030363439303330323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332323b31393332323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c0c4de6a35c6b657", + "motd": "MCPE;Bedrock Protocol Server;589;1.20.0;0;3;1785006490302;bedrock-protocol;Creative;1;19322;19322;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "589", + "1.20.0", + "0", + "3", + "1785006490302", + "bedrock-protocol", + "Creative", + "1", + "19322", + "19322", + "0", + "" + ] + }, + { + "mc": "1.20.15", + "protocol": 594, + "pongHex": "1c001122334455667738a3bfda35df6dff00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3539343b312e32302e31353b303b333b313738353030363439313932313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332333b31393332333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "38a3bfda35df6dff", + "motd": "MCPE;Bedrock Protocol Server;594;1.20.15;0;3;1785006491921;bedrock-protocol;Creative;1;19323;19323;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "594", + "1.20.15", + "0", + "3", + "1785006491921", + "bedrock-protocol", + "Creative", + "1", + "19323", + "19323", + "0", + "" + ] + }, + { + "mc": "1.20.10", + "protocol": 594, + "pongHex": "1c0011223344556677bb9a7d3535f7a91200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3539343b312e32302e31303b303b333b313738353030363439333530393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332343b31393332343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "bb9a7d3535f7a912", + "motd": "MCPE;Bedrock Protocol Server;594;1.20.10;0;3;1785006493509;bedrock-protocol;Creative;1;19324;19324;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "594", + "1.20.10", + "0", + "3", + "1785006493509", + "bedrock-protocol", + "Creative", + "1", + "19324", + "19324", + "0", + "" + ] + }, + { + "mc": "1.20.30", + "protocol": 618, + "pongHex": "1c001122334455667750cc01133610518700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3631383b312e32302e33303b303b333b313738353030363439353132353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332353b31393332353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "50cc011336105187", + "motd": "MCPE;Bedrock Protocol Server;618;1.20.30;0;3;1785006495125;bedrock-protocol;Creative;1;19325;19325;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "618", + "1.20.30", + "0", + "3", + "1785006495125", + "bedrock-protocol", + "Creative", + "1", + "19325", + "19325", + "0", + "" + ] + }, + { + "mc": "1.20.40", + "protocol": 622, + "pongHex": "1c0011223344556677a393ae0c36291fa200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3632323b312e32302e34303b303b333b313738353030363439363735313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332363b31393332363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a393ae0c36291fa2", + "motd": "MCPE;Bedrock Protocol Server;622;1.20.40;0;3;1785006496751;bedrock-protocol;Creative;1;19326;19326;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "622", + "1.20.40", + "0", + "3", + "1785006496751", + "bedrock-protocol", + "Creative", + "1", + "19326", + "19326", + "0", + "" + ] + }, + { + "mc": "1.20.50", + "protocol": 630, + "pongHex": "1c001122334455667784aade753641f5dd00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3633303b312e32302e35303b303b333b313738353030363439383337393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332373b31393332373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "84aade753641f5dd", + "motd": "MCPE;Bedrock Protocol Server;630;1.20.50;0;3;1785006498379;bedrock-protocol;Creative;1;19327;19327;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "630", + "1.20.50", + "0", + "3", + "1785006498379", + "bedrock-protocol", + "Creative", + "1", + "19327", + "19327", + "0", + "" + ] + }, + { + "mc": "1.20.61", + "protocol": 649, + "pongHex": "1c00112233445566776d025988365acd3200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3634393b312e32302e36313b303b333b313738353030363530303030373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332383b31393332383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "6d025988365acd32", + "motd": "MCPE;Bedrock Protocol Server;649;1.20.61;0;3;1785006500007;bedrock-protocol;Creative;1;19328;19328;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "649", + "1.20.61", + "0", + "3", + "1785006500007", + "bedrock-protocol", + "Creative", + "1", + "19328", + "19328", + "0", + "" + ] + }, + { + "mc": "1.20.71", + "protocol": 662, + "pongHex": "1c0011223344556677f97ba7013673ad8600ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3636323b312e32302e37313b303b333b313738353030363530313633373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332393b31393332393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f97ba7013673ad86", + "motd": "MCPE;Bedrock Protocol Server;662;1.20.71;0;3;1785006501637;bedrock-protocol;Creative;1;19329;19329;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "662", + "1.20.71", + "0", + "3", + "1785006501637", + "bedrock-protocol", + "Creative", + "1", + "19329", + "19329", + "0", + "" + ] + }, + { + "mc": "1.20.80", + "protocol": 671, + "pongHex": "1c0011223344556677ed9e5624368c3e7300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3637313b312e32302e38303b303b333b313738353030363530333234373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333303b31393333303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ed9e5624368c3e73", + "motd": "MCPE;Bedrock Protocol Server;671;1.20.80;0;3;1785006503247;bedrock-protocol;Creative;1;19330;19330;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "671", + "1.20.80", + "0", + "3", + "1785006503247", + "bedrock-protocol", + "Creative", + "1", + "19330", + "19330", + "0", + "" + ] + }, + { + "mc": "1.21.0", + "protocol": 685, + "pongHex": "1c00112233445566778b56431236a5135600ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3638353b312e32312e303b303b333b313738353030363530343837343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333313b31393333313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8b56431236a51356", + "motd": "MCPE;Bedrock Protocol Server;685;1.21.0;0;3;1785006504874;bedrock-protocol;Creative;1;19331;19331;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "685", + "1.21.0", + "0", + "3", + "1785006504874", + "bedrock-protocol", + "Creative", + "1", + "19331", + "19331", + "0", + "" + ] + }, + { + "mc": "1.21.2", + "protocol": 686, + "pongHex": "1c0011223344556677ab8d56fb36bda8a400ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3638363b312e32312e323b303b333b313738353030363530363438353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333323b31393333323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ab8d56fb36bda8a4", + "motd": "MCPE;Bedrock Protocol Server;686;1.21.2;0;3;1785006506485;bedrock-protocol;Creative;1;19332;19332;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "686", + "1.21.2", + "0", + "3", + "1785006506485", + "bedrock-protocol", + "Creative", + "1", + "19332", + "19332", + "0", + "" + ] + }, + { + "mc": "1.21.20", + "protocol": 712, + "pongHex": "1c001122334455667796a7789336d66d4600ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3731323b312e32312e32303b303b333b313738353030363530383130393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333333b31393333333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "96a7789336d66d46", + "motd": "MCPE;Bedrock Protocol Server;712;1.21.20;0;3;1785006508109;bedrock-protocol;Creative;1;19333;19333;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "712", + "1.21.20", + "0", + "3", + "1785006508109", + "bedrock-protocol", + "Creative", + "1", + "19333", + "19333", + "0", + "" + ] + }, + { + "mc": "1.21.30", + "protocol": 729, + "pongHex": "1c001122334455667799d4765a36ef3efa00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3732393b312e32312e33303b303b333b313738353030363530393733353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333343b31393333343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "99d4765a36ef3efa", + "motd": "MCPE;Bedrock Protocol Server;729;1.21.30;0;3;1785006509735;bedrock-protocol;Creative;1;19334;19334;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "729", + "1.21.30", + "0", + "3", + "1785006509735", + "bedrock-protocol", + "Creative", + "1", + "19334", + "19334", + "0", + "" + ] + }, + { + "mc": "1.21.42", + "protocol": 748, + "pongHex": "1c0011223344556677a3372ff33708170700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3734383b312e32312e34323b303b333b313738353030363531313336333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333353b31393333353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a3372ff337081707", + "motd": "MCPE;Bedrock Protocol Server;748;1.21.42;0;3;1785006511363;bedrock-protocol;Creative;1;19335;19335;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "748", + "1.21.42", + "0", + "3", + "1785006511363", + "bedrock-protocol", + "Creative", + "1", + "19335", + "19335", + "0", + "" + ] + }, + { + "mc": "1.21.50", + "protocol": 766, + "pongHex": "1c0011223344556677889e3e983720dd4800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3736363b312e32312e35303b303b333b313738353030363531323938373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333363b31393333363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "889e3e983720dd48", + "motd": "MCPE;Bedrock Protocol Server;766;1.21.50;0;3;1785006512987;bedrock-protocol;Creative;1;19336;19336;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "766", + "1.21.50", + "0", + "3", + "1785006512987", + "bedrock-protocol", + "Creative", + "1", + "19336", + "19336", + "0", + "" + ] + }, + { + "mc": "1.21.60", + "protocol": 776, + "pongHex": "1c0011223344556677376812d43739e43f00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3737363b312e32312e36303b303b333b313738353030363531343632373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333373b31393333373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "376812d43739e43f", + "motd": "MCPE;Bedrock Protocol Server;776;1.21.60;0;3;1785006514627;bedrock-protocol;Creative;1;19337;19337;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "776", + "1.21.60", + "0", + "3", + "1785006514627", + "bedrock-protocol", + "Creative", + "1", + "19337", + "19337", + "0", + "" + ] + }, + { + "mc": "1.21.70", + "protocol": 786, + "pongHex": "1c00112233445566773f207be33752d6c700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3738363b312e32312e37303b303b333b313738353030363531363236323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333383b31393333383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "3f207be33752d6c7", + "motd": "MCPE;Bedrock Protocol Server;786;1.21.70;0;3;1785006516262;bedrock-protocol;Creative;1;19338;19338;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "786", + "1.21.70", + "0", + "3", + "1785006516262", + "bedrock-protocol", + "Creative", + "1", + "19338", + "19338", + "0", + "" + ] + }, + { + "mc": "1.21.80", + "protocol": 800, + "pongHex": "1c001122334455667731d7803f376bcaf400ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3830303b312e32312e38303b303b333b313738353030363531373839373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333393b31393333393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "31d7803f376bcaf4", + "motd": "MCPE;Bedrock Protocol Server;800;1.21.80;0;3;1785006517897;bedrock-protocol;Creative;1;19339;19339;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "800", + "1.21.80", + "0", + "3", + "1785006517897", + "bedrock-protocol", + "Creative", + "1", + "19339", + "19339", + "0", + "" + ] + }, + { + "mc": "1.21.90", + "protocol": 818, + "pongHex": "1c00112233445566773c043d1237848c9300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3831383b312e32312e39303b303b333b313738353030363531393532303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334303b31393334303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "3c043d1237848c93", + "motd": "MCPE;Bedrock Protocol Server;818;1.21.90;0;3;1785006519520;bedrock-protocol;Creative;1;19340;19340;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "818", + "1.21.90", + "0", + "3", + "1785006519520", + "bedrock-protocol", + "Creative", + "1", + "19340", + "19340", + "0", + "" + ] + }, + { + "mc": "1.21.93", + "protocol": 819, + "pongHex": "1c0011223344556677a3040fa9379d3b6d00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3831393b312e32312e39333b303b333b313738353030363532313133373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334313b31393334313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a3040fa9379d3b6d", + "motd": "MCPE;Bedrock Protocol Server;819;1.21.93;0;3;1785006521137;bedrock-protocol;Creative;1;19341;19341;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "819", + "1.21.93", + "0", + "3", + "1785006521137", + "bedrock-protocol", + "Creative", + "1", + "19341", + "19341", + "0", + "" + ] + }, + { + "mc": "1.21.100", + "protocol": 827, + "pongHex": "1c0011223344556677729cb24037b62a2200ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3832373b312e32312e3130303b303b333b313738353030363532323737313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334323b31393334323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "729cb24037b62a22", + "motd": "MCPE;Bedrock Protocol Server;827;1.21.100;0;3;1785006522771;bedrock-protocol;Creative;1;19342;19342;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "827", + "1.21.100", + "0", + "3", + "1785006522771", + "bedrock-protocol", + "Creative", + "1", + "19342", + "19342", + "0", + "" + ] + }, + { + "mc": "1.21.111", + "protocol": 844, + "pongHex": "1c0011223344556677188f9d6c37cf1f6100ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3834343b312e32312e3131313b303b333b313738353030363532343430373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334333b31393334333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "188f9d6c37cf1f61", + "motd": "MCPE;Bedrock Protocol Server;844;1.21.111;0;3;1785006524407;bedrock-protocol;Creative;1;19343;19343;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "844", + "1.21.111", + "0", + "3", + "1785006524407", + "bedrock-protocol", + "Creative", + "1", + "19343", + "19343", + "0", + "" + ] + }, + { + "mc": "1.21.120", + "protocol": 859, + "pongHex": "1c0011223344556677a6233aa837e7d3e900ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3835393b312e32312e3132303b303b333b313738353030363532363032363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334343b31393334343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a6233aa837e7d3e9", + "motd": "MCPE;Bedrock Protocol Server;859;1.21.120;0;3;1785006526026;bedrock-protocol;Creative;1;19344;19344;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "859", + "1.21.120", + "0", + "3", + "1785006526026", + "bedrock-protocol", + "Creative", + "1", + "19344", + "19344", + "0", + "" + ] + }, + { + "mc": "1.21.124", + "protocol": 860, + "pongHex": "1c00112233445566778abac1963800536400ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3836303b312e32312e3132343b303b333b313738353030363532373633323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334353b31393334353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8abac19638005364", + "motd": "MCPE;Bedrock Protocol Server;860;1.21.124;0;3;1785006527632;bedrock-protocol;Creative;1;19345;19345;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "860", + "1.21.124", + "0", + "3", + "1785006527632", + "bedrock-protocol", + "Creative", + "1", + "19345", + "19345", + "0", + "" + ] + }, + { + "mc": "1.21.130", + "protocol": 898, + "pongHex": "1c0011223344556677dfe917253818f68a00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3839383b312e32312e3133303b303b333b313738353030363532393234363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334363b31393334363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "dfe917253818f68a", + "motd": "MCPE;Bedrock Protocol Server;898;1.21.130;0;3;1785006529246;bedrock-protocol;Creative;1;19346;19346;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "898", + "1.21.130", + "0", + "3", + "1785006529246", + "bedrock-protocol", + "Creative", + "1", + "19346", + "19346", + "0", + "" + ] + }, + { + "mc": "1.26.0", + "protocol": 924, + "pongHex": "1c0011223344556677f704ab7b38317aa100ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3932343b312e32362e303b303b333b313738353030363533303835333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334373b31393334373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f704ab7b38317aa1", + "motd": "MCPE;Bedrock Protocol Server;924;1.26.0;0;3;1785006530853;bedrock-protocol;Creative;1;19347;19347;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "924", + "1.26.0", + "0", + "3", + "1785006530853", + "bedrock-protocol", + "Creative", + "1", + "19347", + "19347", + "0", + "" + ] + }, + { + "mc": "1.26.10", + "protocol": 944, + "pongHex": "1c00112233445566779819b8c6384a368500ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3934343b312e32362e31303b303b333b313738353030363533323437343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334383b31393334383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "9819b8c6384a3685", + "motd": "MCPE;Bedrock Protocol Server;944;1.26.10;0;3;1785006532474;bedrock-protocol;Creative;1;19348;19348;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "944", + "1.26.10", + "0", + "3", + "1785006532474", + "bedrock-protocol", + "Creative", + "1", + "19348", + "19348", + "0", + "" + ] + }, + { + "mc": "1.26.20", + "protocol": 975, + "pongHex": "1c0011223344556677612290963862e21a00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3937353b312e32362e32303b303b333b313738353030363533343039313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334393b31393334393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "612290963862e21a", + "motd": "MCPE;Bedrock Protocol Server;975;1.26.20;0;3;1785006534091;bedrock-protocol;Creative;1;19349;19349;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "975", + "1.26.20", + "0", + "3", + "1785006534091", + "bedrock-protocol", + "Creative", + "1", + "19349", + "19349", + "0", + "" + ] + }, + { + "mc": "1.26.30", + "protocol": 1001, + "pongHex": "1c00112233445566778ef59e96387bbaaf00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b313030313b312e32362e33303b303b333b313738353030363533353731393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393335303b31393335303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8ef59e96387bbaaf", + "motd": "MCPE;Bedrock Protocol Server;1001;1.26.30;0;3;1785006535719;bedrock-protocol;Creative;1;19350;19350;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "1001", + "1.26.30", + "0", + "3", + "1785006535719", + "bedrock-protocol", + "Creative", + "1", + "19350", + "19350", + "0", + "" + ] + } + ] +} diff --git a/internal/corpus/data/synthetic.json b/internal/corpus/data/synthetic.json new file mode 100644 index 0000000..46a0300 --- /dev/null +++ b/internal/corpus/data/synthetic.json @@ -0,0 +1,161 @@ +{ + "_comment": [ + "HAND-AUTHORED fixtures. Unlike captured.json (which is real bedrock-protocol", + "wire bytes but a constant 13-field shape across every version), this file is", + "where actual pong SHAPE diversity lives: historical field counts, hypothetical", + "future field counts, hostile content, and malformed frames.", + "", + "Entries with 'motd' get a well-formed RakNet frame built around them by the Go", + "loader. Entries with 'rawHex' are used verbatim, for testing malformed frames.", + "", + "'wantParseError' means proto.ReadUnconnectedPing SHOULD reject the input.", + "Where phantom currently accepts garbage instead, the case is registered in", + "known_failures.json rather than deleted." + ], + "pingTime": "0011223344556677", + "serverGUID": "a1b2c3d4e5f60718", + "entries": [ + { + "id": "shape/legacy-10-field", + "desc": "Pre-port-advertisement MCPE pong: no Port4/Port6 fields at all.", + "motd": "MCPE;Legacy Server;390;1.14.60;0;10;12345678;Sub;Survival;1;", + "fieldCount": 10, + "tags": ["shape"] + }, + { + "id": "shape/classic-12-field", + "desc": "The shape phantom's PongData struct was written against.", + "motd": "MCPE;Classic Server;422;1.16.201;3;10;12345678;Sub;Survival;1;19132;19133;", + "fieldCount": 12, + "tags": ["shape"] + }, + { + "id": "shape/modern-13-field", + "desc": "Current real-world shape: a 13th field (editor-mode flag) after Port6. phantom's fixed 12-field struct drops it.", + "motd": "MCPE;Modern Server;800;1.21.80;5;20;12345678;Sub;Creative;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["shape", "forwards-compat"] + }, + { + "id": "shape/future-15-field", + "desc": "Forwards-compat guard: Mojang adds two more fields. Must survive the rewrite untouched.", + "motd": "MCPE;Future Server;9999;1.99.0;1;100;12345678;Sub;Creative;1;19132;19133;0;newFieldA;newFieldB;", + "fieldCount": 15, + "tags": ["shape", "forwards-compat"] + }, + { + "id": "shape/no-trailing-semicolon", + "desc": "Server omits the trailing separator. Field values must not shift.", + "motd": "MCPE;No Trailer;800;1.21.80;5;20;12345678;Sub;Creative;1;19132;19133", + "fieldCount": 12, + "tags": ["shape"] + }, + { + "id": "content/empty-motd-fields", + "desc": "Server advertises empty MOTD and SubMOTD. Empty must stay empty, not collapse.", + "motd": "MCPE;;800;1.21.80;0;10;12345678;;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/section-color-codes", + "desc": "MOTD with Bedrock section-sign colour codes, as phantom's own offline pong uses.", + "motd": "MCPE;§cRed §aGreen §9Blue;800;1.21.80;0;10;12345678;§eSub;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/utf8-multibyte", + "desc": "Multi-byte UTF-8 in the MOTD. Length prefix is BYTES, not runes - a rune-based length would corrupt this.", + "motd": "MCPE;サーバー 🎮 Ünïcödé;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/semicolon-in-motd", + "desc": "A server puts a raw ';' inside the MOTD. The protocol has no escaping, so every later field shifts by one. phantom must not corrupt or crash.", + "motd": "MCPE;Broken;MOTD;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;", + "fieldCount": 14, + "tags": ["content", "hostile"] + }, + { + "id": "content/long-motd", + "desc": "MOTD long enough to push the pong past a typical MTU. Guards the 1472-byte read buffer.", + "motdRepeat": { "prefix": "MCPE;", "unit": "A", "count": 1400, "suffix": ";800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;" }, + "tags": ["content", "mtu"] + }, + { + "id": "content/many-fields", + "desc": "30 fields. Absurd but well-formed; must not panic or truncate silently.", + "motdRepeat": { "prefix": "MCPE;Many;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133", "unit": ";x", "count": 18, "suffix": ";" }, + "tags": ["shape", "hostile"] + }, + { + "id": "content/empty-string", + "desc": "Zero-length MOTD string with a valid length prefix of 0. Degenerate: phantom rebuilds it as a lone ';'. That is harmless (clients ignore empty trailing fields), so field preservation is not asserted - only that it neither panics nor errors.", + "motd": "", + "fieldCount": 1, + "noRoundTrip": true, + "tags": ["content", "hostile"] + }, + + { + "id": "malformed/empty-packet", + "desc": "Zero bytes.", + "rawHex": "", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/id-only", + "desc": "Just the 0x1c packet ID and nothing else.", + "rawHex": "1c", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/truncated-in-header", + "desc": "Cut off partway through the ping-time field.", + "rawHex": "1c00112233", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/truncated-after-magic", + "desc": "Valid header and magic, then nothing - no length prefix at all.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd12345678", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/length-exceeds-body", + "desc": "Declares a 500-byte MOTD but supplies 4 bytes. bytes.Buffer.Read partial-fills without erroring, so this parses into a zero-padded struct instead of failing.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd1234567801f44d435045", + "wantParseError": true, + "tags": ["malformed", "short-read"] + }, + { + "id": "malformed/bad-magic", + "desc": "Otherwise complete, valid pong with the 16-byte RakNet magic zeroed. Reference implementations reject this; phantom reads the magic but never compares it. Built rather than hand-written so the ONLY defect is the magic - a truncated fixture would trip the length check first and prove nothing.", + "motd": "MCPE;Bad Magic;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;", + "magicOverride": "00000000000000000000000000000000", + "wantParseError": true, + "tags": ["malformed", "magic"] + }, + { + "id": "malformed/wrong-packet-id", + "desc": "A complete, well-formed frame whose leading byte is 0x01 (Unconnected PING) instead of 0x1c (PONG). ReadUnconnectedPing discards the first byte without checking it, so a ping parses cleanly as a pong.", + "motd": "MCPE;Wrong ID;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;", + "packetIDOverride": 1, + "wantParseError": true, + "tags": ["malformed", "packet-id"] + }, + { + "id": "malformed/zero-length-prefix", + "desc": "Well-formed frame declaring a 0-length MOTD.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd123456780000", + "wantParseError": false, + "tags": ["malformed", "boundary"] + } + ] +} diff --git a/internal/corpus/gen/capture.js b/internal/corpus/gen/capture.js new file mode 100644 index 0000000..029e556 --- /dev/null +++ b/internal/corpus/gen/capture.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// +// Captures real RakNet Unconnected Pong (0x1c) bytes from a bedrock-protocol +// server, one entry per supported Minecraft version, into ../data/captured.json. +// +// Run via `make corpus` from the repo root. This is a DEV-TIME task: CI never +// runs it, it only replays the committed bytes. Regenerating and diffing the +// result is how we detect that a new Minecraft version changed the pong shape. +// +// Usage: node capture.js [--out ] [--only [,...]] + +const dgram = require('dgram') +const fs = require('fs') +const path = require('path') +const bp = require('bedrock-protocol') + +const MAGIC = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex') + +// Fixed, arbitrary values so captures are reproducible and so Go tests can +// assert that the server echoed OUR ping time back (RakNet requires it). +const PING_TIME = '0011223344556677' +const CLIENT_GUID = '8877665544332211' + +const BASE_PORT = 19300 +const BOOT_MS = 1500 +const PING_TIMEOUT_MS = 5000 +const PING_RETRIES = 3 + +function versions () { + // bedrock-protocol keeps its version->protocol table here. + return require('bedrock-protocol/src/options').Versions +} + +function buildPing () { + return Buffer.concat([ + Buffer.from([0x01]), + Buffer.from(PING_TIME, 'hex'), + MAGIC, + Buffer.from(CLIENT_GUID, 'hex') + ]) +} + +// Sends an Unconnected Ping and returns the raw pong datagram. +function pingOnce (port) { + return new Promise((resolve, reject) => { + const sock = dgram.createSocket('udp4') + const timer = setTimeout(() => { + sock.close() + reject(new Error('ping timeout')) + }, PING_TIMEOUT_MS) + + sock.on('error', err => { + clearTimeout(timer) + sock.close() + reject(err) + }) + + sock.on('message', msg => { + clearTimeout(timer) + sock.close() + resolve(msg) + }) + + sock.send(buildPing(), port, '127.0.0.1', err => { + if (err) { + clearTimeout(timer) + sock.close() + reject(err) + } + }) + }) +} + +async function captureVersion (mcVersion, port) { + let server + try { + server = bp.createServer({ + host: '127.0.0.1', + port, + version: mcVersion, + offline: true + }) + } catch (err) { + throw new Error(`createServer failed: ${err.message}`) + } + + try { + await new Promise(r => setTimeout(r, BOOT_MS)) + + let pong + let lastErr + for (let attempt = 0; attempt < PING_RETRIES; attempt++) { + try { + pong = await pingOnce(port) + break + } catch (err) { + lastErr = err + } + } + if (!pong) throw lastErr + + if (pong[0] !== 0x1c) { + throw new Error(`expected pong id 0x1c, got 0x${pong[0].toString(16)}`) + } + if (pong.length < 35) { + throw new Error(`pong too short: ${pong.length} bytes`) + } + + const strLen = pong.readUInt16BE(33) + const motd = pong.slice(35, 35 + strLen).toString('utf8') + + return { + mc: mcVersion, + protocol: versions()[mcVersion], + pongHex: pong.toString('hex'), + // Echoed ping time, as the server sent it back. Should equal PING_TIME. + echoedPingTime: pong.slice(1, 9).toString('hex'), + serverGUID: pong.slice(9, 17).toString('hex'), + motd, + // Note the trailing ';' means the final split element is empty; the real + // field count is this array minus that empty tail. + fields: motd.split(';') + } + } finally { + try { server.close() } catch (_) { /* best effort */ } + } +} + +async function main () { + const args = process.argv.slice(2) + const outIdx = args.indexOf('--out') + const outPath = outIdx >= 0 + ? args[outIdx + 1] + : path.join(__dirname, '..', 'data', 'captured.json') + + const onlyIdx = args.indexOf('--only') + const only = onlyIdx >= 0 ? args[onlyIdx + 1].split(',') : null + + const table = versions() + let list = Object.keys(table) + if (only) list = list.filter(v => only.includes(v)) + + // Ascending by protocol number keeps the committed file stable and readable. + list.sort((a, b) => table[a] - table[b]) + + const entries = [] + const failures = [] + + for (let i = 0; i < list.length; i++) { + const mcVersion = list[i] + const port = BASE_PORT + (i % 150) + process.stderr.write(`[${i + 1}/${list.length}] ${mcVersion} (proto ${table[mcVersion]}) ... `) + try { + const entry = await captureVersion(mcVersion, port) + entries.push(entry) + process.stderr.write(`ok, ${entry.fields.length - 1} fields\n`) + } catch (err) { + failures.push({ mc: mcVersion, error: err.message }) + process.stderr.write(`FAILED: ${err.message}\n`) + } + } + + const pkg = require('./package.json') + const doc = { + _comment: [ + 'GENERATED FILE - do not edit by hand. Regenerate with `make corpus`.', + 'Real RakNet Unconnected Pong bytes captured from a bedrock-protocol server,', + 'one entry per supported Minecraft version.', + '', + 'CAVEAT: these are bedrock-protocol advertisements, not Mojang BDS ones.', + 'The MOTD field COUNT is constant across every version here (bedrock-protocol', + 'uses one serializer); only the protocol number and version string vary.', + 'Real per-version shape diversity lives in synthetic.json.' + ], + generatedBy: `bedrock-protocol@${pkg.dependencies['bedrock-protocol']}`, + pingTime: PING_TIME, + clientGUID: CLIENT_GUID, + entries + } + + fs.mkdirSync(path.dirname(outPath), { recursive: true }) + fs.writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n') + + process.stderr.write(`\nwrote ${entries.length} entries to ${outPath}\n`) + if (failures.length) { + process.stderr.write(`${failures.length} failures:\n`) + for (const f of failures) process.stderr.write(` ${f.mc}: ${f.error}\n`) + process.exit(1) + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/internal/corpus/gen/drift.js b/internal/corpus/gen/drift.js new file mode 100644 index 0000000..aed8cec --- /dev/null +++ b/internal/corpus/gen/drift.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// +// Matrix drift check. +// +// Compares the Minecraft versions bedrock-protocol supports against what this +// repo actually tests, and fails if we have fallen behind. This is what keeps +// "N versions" current without anyone having to remember: when Mojang ships a +// new version and bedrock-protocol adds it, the nightly job goes red. +// +// Usage: node drift.js +// Exit 0 = in sync, exit 1 = drift found. + +const fs = require('fs') +const path = require('path') + +const supported = require('bedrock-protocol/src/options').Versions + +const capturedPath = path.join(__dirname, '..', 'data', 'captured.json') +const matrixPath = path.join(__dirname, '..', '..', '..', 'test', 'compat', 'matrix.json') + +const captured = JSON.parse(fs.readFileSync(capturedPath, 'utf8')) +const matrix = JSON.parse(fs.readFileSync(matrixPath, 'utf8')) + +const capturedVersions = new Set(captured.entries.map(e => e.mc)) +const matrixVersions = new Set(matrix.versions.map(v => v.mc)) +const supportedVersions = Object.keys(supported) + +const missingFromCorpus = supportedVersions.filter(v => !capturedVersions.has(v)) + +// The e2e matrix is deliberately a SAMPLE, so a version missing from it is only +// notable when it is newer than everything we currently sample. +const newestSampled = Math.max(...[...matrixVersions].map(v => supported[v] || 0)) +const newerThanMatrix = supportedVersions.filter( + v => supported[v] > newestSampled && !matrixVersions.has(v) +) + +let drift = false + +if (missingFromCorpus.length) { + drift = true + console.error('unit-test corpus is missing versions bedrock-protocol now supports:') + for (const v of missingFromCorpus) { + console.error(` ${v} (protocol ${supported[v]})`) + } + console.error('\nFix: run `make corpus` and commit internal/corpus/data/captured.json.\n') +} + +if (newerThanMatrix.length) { + drift = true + console.error('e2e matrix does not sample any version this new:') + for (const v of newerThanMatrix) { + console.error(` ${v} (protocol ${supported[v]})`) + } + console.error('\nFix: add the newest to test/compat/matrix.json, keeping shards balanced.\n') +} + +if (!drift) { + console.log(`In sync: ${supportedVersions.length} versions supported, ` + + `${capturedVersions.size} in the unit-test corpus, ${matrixVersions.size} sampled by e2e.`) + process.exit(0) +} + +process.exit(1) diff --git a/internal/corpus/gen/package-lock.json b/internal/corpus/gen/package-lock.json new file mode 100644 index 0000000..6472bac --- /dev/null +++ b/internal/corpus/gen/package-lock.json @@ -0,0 +1,1565 @@ +{ + "name": "phantom-corpus-gen", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phantom-corpus-gen", + "version": "1.0.0", + "dependencies": { + "bedrock-protocol": "3.57.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@jsprismarine/jsbinaryutils": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@jsprismarine/jsbinaryutils/-/jsbinaryutils-2.1.4.tgz", + "integrity": "sha512-LFMbAJsHEiq5UJwPzrHYZbCkZ/LZEhV76SYXPDjcQLosqVwcf0yyyr+ICxT4j/d4xPH7vDSvWCqOuFRsSv9wYw==", + "license": "ISC" + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", + "integrity": "sha512-Zfw6bteqM9gQXZ1BIWOgM8xEwMrUGoyL8nW13+O+OOgNX3YhuDN1GDgg1NzdTlmm3j+9sHy7uBZ12r+z9lXnZQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.0 || ^1.1.13" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bedrock-protocol": { + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/bedrock-protocol/-/bedrock-protocol-3.57.0.tgz", + "integrity": "sha512-+4YyRvDD4Wf5g8ZInxjJRoVySDJBQKy0atHBGcQqmrwPJ4DQWrlj3DavQTt78cw3iPZMTQ3ZgKvWMGfnv+jilg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "jsonwebtoken": "^9.0.0", + "jsp-raknet": "^2.1.3", + "minecraft-data": "^3.0.0", + "minecraft-folder-path": "^1.2.0", + "prismarine-auth": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "prismarine-realms": "^1.6.0", + "protodef": "^1.14.0", + "raknet-native": "^1.0.3", + "uuid-1345": "^1.0.2" + }, + "optionalDependencies": { + "raknet-node": "^0.5.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g==", + "license": "MIT" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/cmake-js": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.2.tgz", + "integrity": "sha512-7MfiQ/ijzeE2kO+WFB9bv4QP5Dn2yVaAP2acFJr4NIFy2hT4w6O4EpOTLNcohR5IPX7M4wNf/5taIqMj7UA9ug==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "bluebird": "^3", + "debug": "^4", + "fs-extra": "^5.0.0", + "is-iojs": "^1.0.1", + "lodash": "^4", + "memory-stream": "0", + "npmlog": "^1.2.0", + "rc": "^1.2.7", + "semver": "^5.0.3", + "splitargs": "0", + "tar": "^4", + "unzipper": "^0.8.13", + "url-join": "0", + "which": "^1.0.9", + "yargs": "^3.6.0" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "node_modules/fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", + "dependencies": { + "minipass": "^2.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha512-fVbU2wRE91yDvKUnrIaQlHKAWKY5e08PmztCrwuH5YVQ+Z/p3d0ny2T48o6uvAAXHIUnfaQdHkmxYbQft1eHVA==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iojs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", + "integrity": "sha512-tLn1j3wYSL6DkvEI+V/j0pKohpa5jk+ER74v6S4SgCXnjS0WA+DoZbwZBrrhgwksMvtuwndyGeG5F8YMsoBzSA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsp-raknet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jsp-raknet/-/jsp-raknet-2.2.0.tgz", + "integrity": "sha512-Ji2wZmI84abcgzSnnbdtMSW02yZ/Ui3JI3N69BsyTcWHWsbeWMRHKpzN72cCQM8W9pvTIfLYml9sS9xaC2MJSA==", + "license": "ISC", + "dependencies": { + "@jsprismarine/jsbinaryutils": "2.1.4", + "debug": "^4.3.1", + "typescript": "^4.2.2" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", + "license": "MIT", + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==", + "license": "MIT" + }, + "node_modules/lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", + "license": "MIT" + }, + "node_modules/lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/memory-stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", + "integrity": "sha512-q0D3m846qY6ZkIt+19ZemU5vH56lpOZZwoJc3AICARKh/menBuayQUjAGPrqtHQQMUYERSdOrej92J9kz7LgYA==", + "license": "MMIT", + "dependencies": { + "readable-stream": "~1.0.26-2" + } + }, + "node_modules/memory-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/memory-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/memory-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", + "dependencies": { + "minipass": "^2.9.0" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npmlog": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", + "integrity": "sha512-1J5KqSRvESP6XbjPaXt2H6qDzgizLTM7x0y1cXIjP2PpvdCqyNC7TO3cPRKsuYlElbi/DwkzRRdG2zpmE0IktQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "~0.3.0", + "are-we-there-yet": "~1.0.0", + "gauge": "~1.2.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", + "license": "MIT", + "dependencies": { + "lcid": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raknet-native": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/raknet-native/-/raknet-native-1.2.3.tgz", + "integrity": "sha512-iXqQc/2298LJHQAyoYDdreZ9E4z+4d1y8lxp+EKno1r/ooIer83ShayCDsjpjwRHc9RiMdJz6fLbgBf2hLeTzw==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "cmake-js": "^6.1.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/raknet-node": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/raknet-node/-/raknet-node-0.5.0.tgz", + "integrity": "sha512-xvWsBwUu/UIlsMDN1sm5FX7iCIufuLakoCtlBer4+B4XeWLQFkEiRmwSaon0pKpBc8HKZcyU3S2KjvzYkGi0WA==", + "license": "ISC", + "optional": true, + "dependencies": { + "sha1": "^1.1.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/splitargs": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", + "integrity": "sha512-UUFYD2oWbNwULH6WoVtLUOw8ch586B+HUqcsAjjjeoBQAM1bD4wZRXu01koaxyd8UeYpybWqW4h+lO1Okv40Tg==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + }, + "engines": { + "node": ">=4.5" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", + "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "~1.0.10", + "listenercount": "~1.0.1", + "readable-stream": "~2.1.5", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha512-NkXT2AER7VKXeXtJNSaWLpWIhmtSE3K2PguaLEeWr4JILghcIKqoLt1A3wHrnpDC5+ekf8gfk1GKWkFXe4odMw==", + "license": "MIT", + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==", + "license": "MIT", + "bin": { + "window-size": "cli.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + } + } +} diff --git a/internal/corpus/gen/package.json b/internal/corpus/gen/package.json new file mode 100644 index 0000000..8215149 --- /dev/null +++ b/internal/corpus/gen/package.json @@ -0,0 +1,12 @@ +{ + "name": "phantom-corpus-gen", + "private": true, + "version": "1.0.0", + "description": "Captures real RakNet Unconnected Pong bytes per Minecraft protocol version", + "scripts": { + "capture": "node capture.js" + }, + "dependencies": { + "bedrock-protocol": "3.57.0" + } +} diff --git a/internal/proto/corpus_test.go b/internal/proto/corpus_test.go new file mode 100644 index 0000000..19087f4 --- /dev/null +++ b/internal/proto/corpus_test.go @@ -0,0 +1,199 @@ +package proto_test + +// Unit tests over the cross-version pong corpus. +// +// These replay real captured wire bytes (51 Minecraft versions) and +// hand-authored shape fixtures through the parser and rebuilder, asserting the +// field-preservation contract in test/compat/DESIGN.md. +// +// No network, no subprocess, no Node. Runs everywhere in well under a second. + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/internal/proto" +) + +// fieldsOf splits a MOTD, dropping the empty element produced by the +// conventional trailing semicolon. Comparing field *lists* rather than raw +// strings is deliberate: appending or omitting the trailing ';' is +// semantically meaningless to a client, so it must not count as a difference. +func fieldsOf(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// roundTrip parses a pong frame and rebuilds it, returning the resulting MOTD. +func roundTrip(frame []byte) (string, error) { + pkt, err := proto.ReadUnconnectedPing(frame) + if err != nil { + return "", err + } + rebuilt := pkt.Build() + return corpus.SplitMOTD(rebuilt.Bytes()) +} + +// assertFieldsPreserved is the core invariant: every MOTD field present on the +// wire must survive parse -> rebuild unchanged, including fields phantom has no +// struct member for. +func assertFieldsPreserved(in, out string) error { + want, got := fieldsOf(in), fieldsOf(out) + + if len(want) != len(got) { + if len(got) < len(want) { + return fmt.Errorf( + "field count dropped from %d to %d; lost trailing fields %q\n in: %q\n out: %q", + len(want), len(got), want[len(got):], in, out) + } + return fmt.Errorf( + "field count grew from %d to %d\n in: %q\n out: %q", + len(want), len(got), in, out) + } + + for i := range want { + if want[i] != got[i] { + return fmt.Errorf("field %d changed: %q -> %q\n in: %q\n out: %q", + i, want[i], got[i], in, out) + } + } + return nil +} + +// TestCapturedCorpusParses asserts every real captured pong parses cleanly and +// that the parser reads the version-identifying fields correctly. This is the +// broad sweep across all supported protocol versions. +func TestCapturedCorpusParses(t *testing.T) { + entries := corpus.Captured() + if len(entries) == 0 { + t.Fatal("captured corpus is empty; run `make corpus`") + } + t.Logf("replaying %d captured versions from %s", len(entries), corpus.GeneratedBy()) + + for _, e := range entries { + t.Run(e.MC, func(t *testing.T) { + pkt, err := proto.ReadUnconnectedPing(e.Frame()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + want := fieldsOf(e.MOTD) + if pkt.Pong.Edition != want[0] { + t.Errorf("Edition = %q, want %q", pkt.Pong.Edition, want[0]) + } + if pkt.Pong.ProtocolVersion != want[2] { + t.Errorf("ProtocolVersion = %q, want %q", pkt.Pong.ProtocolVersion, want[2]) + } + if pkt.Pong.Version != want[3] { + t.Errorf("Version = %q, want %q", pkt.Pong.Version, want[3]) + } + if !bytes.Equal(pkt.Magic, corpus.Magic) { + t.Errorf("Magic = %x, want %x", pkt.Magic, corpus.Magic) + } + }) + } +} + +// TestCapturedCorpusRoundTrip asserts field preservation across every captured +// version, measured against real captured bytes. +func TestCapturedCorpusRoundTrip(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + out, err := roundTrip(e.Frame()) + if err != nil { + t.Fatalf("round-trip failed: %v", err) + } + if err := assertFieldsPreserved(e.MOTD, out); err != nil { + t.Error(err) + } + }) + } +} + +// TestCapturedCorpusEchoesPingTime validates the corpus itself: every real +// server echoed the exact ping time we sent. This is the behaviour phantom's +// offline pong fails to reproduce (see e2e/offline-pong-echoes-ping-time), so +// pinning it here documents the requirement with evidence. +func TestCapturedCorpusEchoesPingTime(t *testing.T) { + sent := corpus.CapturedPingTime() + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + pkt, err := proto.ReadUnconnectedPing(e.Frame()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if !bytes.Equal(pkt.PingTime, sent) { + t.Errorf("server echoed ping time %x, want %x", pkt.PingTime, sent) + } + }) + } +} + +// TestSyntheticShapes covers pong shapes the captured corpus cannot reach: +// historical field counts, hypothetical future ones, and hostile content. +func TestSyntheticShapes(t *testing.T) { + for _, e := range corpus.Synthetic() { + if e.IsRaw() || e.WantParseError { + continue // rejection cases are covered by TestMalformedFrames + } + + t.Run(e.ID, func(t *testing.T) { + t.Log(e.Desc) + + out, err := roundTrip(e.Frame()) + if err != nil { + t.Fatalf("round-trip failed: %v", err) + } + if e.NoRoundTrip { + return + } + + if err := assertFieldsPreserved(e.MOTDString(), out); err != nil { + t.Error(err) + } + }) + } +} + +// TestMalformedFrames asserts the parser rejects input it cannot trust. +// Some currently parse into zero-padded garbage instead of erroring. +func TestMalformedFrames(t *testing.T) { + for _, e := range corpus.Synthetic() { + if !e.IsRaw() && !e.WantParseError { + continue + } + + t.Run(e.ID, func(t *testing.T) { + t.Log(e.Desc) + + // Must never panic, whatever the input. + var err error + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC on malformed input: %v", r) + } + }() + _, err = proto.ReadUnconnectedPing(e.Frame()) + }() + + if !e.WantParseError { + if err != nil { + t.Errorf("expected clean parse, got error: %v", err) + } + return + } + + if err == nil { + t.Error("parser accepted malformed input that it should reject") + } + }) + } +} diff --git a/internal/proto/fuzz_test.go b/internal/proto/fuzz_test.go new file mode 100644 index 0000000..52c908a --- /dev/null +++ b/internal/proto/fuzz_test.go @@ -0,0 +1,60 @@ +package proto_test + +// Fuzzing the pong parser. +// +// ReadUnconnectedPing parses untrusted bytes straight off a UDP socket with no +// length or magic validation, which makes it the highest-value fuzz target in +// the codebase. The corpus seeds it with real per-version pongs and every +// hand-authored malformed frame, so the fuzzer starts from realistic structure +// rather than having to discover the RakNet header from scratch. +// +// CI runs a short pass; the nightly workflow runs a long one. + +import ( + "testing" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/internal/proto" +) + +func FuzzReadUnconnectedPing(f *testing.F) { + for _, e := range corpus.Captured() { + f.Add(e.Frame()) + } + for _, e := range corpus.Synthetic() { + f.Add(e.Frame()) + } + // A few degenerate seeds the corpus does not contain. + f.Add([]byte{}) + f.Add([]byte{corpus.PongID}) + f.Add(make([]byte, 1472)) // all zeros, max MTU + + f.Fuzz(func(t *testing.T, data []byte) { + pkt, err := proto.ReadUnconnectedPing(data) + if err != nil { + return // rejecting input is always acceptable + } + if pkt == nil { + t.Fatal("nil packet returned with nil error") + } + + // Anything the parser accepted must also be rebuildable: phantom calls + // Build() on every pong it forwards, so a parse/build asymmetry here is + // a crash in the proxy's hot path. + out := pkt.Build() + + body := out.Bytes() + if len(body) == 0 { + t.Fatal("Build() produced an empty packet") + } + if body[0] != corpus.PongID { + t.Fatalf("Build() wrote packet id 0x%02x, want 0x%02x", body[0], corpus.PongID) + } + + // The rebuilt packet must itself be parseable. If it is not, phantom + // would be emitting pongs that no client can read. + if _, err := proto.ReadUnconnectedPing(body); err != nil { + t.Fatalf("Build() output is not re-parseable: %v", err) + } + }) +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 3fce0f1..4a06e72 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -48,6 +48,11 @@ var OfflinePong = UnconnectedPing{ MaxPlayers: "0", GameType: "Creative", NintendoLimited: "1", + // Placeholder ports so rewriteUnconnectedPong overwrites them with + // phantom's bind port. Empty ports make clients fall back to :19132, + // whose replies come from the data port and fail the join (#104). + Port4: "0", + Port6: "0", }, }.Build() diff --git a/internal/proxy/discovery.go b/internal/proxy/discovery.go new file mode 100644 index 0000000..5642af8 --- /dev/null +++ b/internal/proxy/discovery.go @@ -0,0 +1,103 @@ +package proxy + +import ( + "fmt" + "net" + + "github.com/jhead/phantom/internal/proto" + "github.com/rs/zerolog/log" + "github.com/tevino/abool" +) + +// DiscoveryHub owns the exclusive :19132 (and optional :19133) LAN discovery +// sockets and fans each Unconnected Ping out to every registered proxy. +// +// Multiple OS processes cannot reliably share :19132: SO_REUSEADDR/PORT either +// load-balances or delivers unicast to a single socket, so only one phantom +// instance sees traffic (GitHub #175). One process + fan-out is the supported +// way to advertise multiple Bedrock servers on the same host. +type DiscoveryHub struct { + ping4 net.PacketConn + ping6 net.PacketConn + proxies []*ProxyServer + dead *abool.AtomicBool +} + +// StartDiscoveryHub binds discovery ports exclusively and starts read loops. +func StartDiscoveryHub(proxies []*ProxyServer, enableIPv6 bool) (*DiscoveryHub, error) { + if len(proxies) == 0 { + return nil, fmt.Errorf("no proxies to register for discovery") + } + + hub := &DiscoveryHub{ + proxies: proxies, + dead: abool.New(), + } + + log.Info().Msgf("Binding shared ping server to port 19132") + ping4, err := net.ListenPacket("udp4", ":19132") + if err != nil { + return nil, fmt.Errorf("bind :19132: %w (only one phantom can own LAN discovery; pass multiple -server flags to one process instead of running multiple instances)", err) + } + hub.ping4 = ping4 + go hub.readLoop(ping4) + + if enableIPv6 { + log.Info().Msgf("Binding shared IPv6 ping server to port 19133") + ping6, err := net.ListenPacket("udp6", ":19133") + if err != nil { + log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) + } else { + hub.ping6 = ping6 + go hub.readLoop(ping6) + } + } + + return hub, nil +} + +// Close stops discovery listeners. +func (hub *DiscoveryHub) Close() { + hub.dead.Set() + if hub.ping4 != nil { + _ = hub.ping4.Close() + } + if hub.ping6 != nil { + _ = hub.ping6.Close() + } +} + +func (hub *DiscoveryHub) readLoop(listener net.PacketConn) { + log.Info().Msgf("Discovery listener starting up: %s", listener.LocalAddr()) + buf := make([]byte, udpRecvBufferSize) + + for !hub.dead.IsSet() { + n, from, err := listener.ReadFrom(buf) + if err != nil { + if hub.dead.IsSet() { + break + } + log.Warn().Msgf("Discovery read error: %v", err) + continue + } + if n < 1 || buf[0] != proto.UnconnectedPingID { + continue + } + + // Copy once for fan-out; each proxy may hold the slice until Write returns. + packet := append([]byte(nil), buf[:n]...) + log.Info().Msgf("Received LAN ping from client: %s (fanning out to %d servers)", from.String(), len(hub.proxies)) + + for _, p := range hub.proxies { + proxy := p + data := append([]byte(nil), packet...) + go func() { + if err := proxy.HandleUnconnectedPing(data, from); err != nil { + log.Warn().Msgf("Discovery fan-out to %s failed: %v", proxy.prefs.RemoteServer, err) + } + }() + } + } + + log.Info().Msgf("Discovery listener shut down: %s", listener.LocalAddr()) +} diff --git a/internal/proxy/discovery_ping_test.go b/internal/proxy/discovery_ping_test.go new file mode 100644 index 0000000..fb8e18a --- /dev/null +++ b/internal/proxy/discovery_ping_test.go @@ -0,0 +1,132 @@ +package proxy + +import ( + "bytes" + "net" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" + "github.com/stretchr/testify/require" +) + +// bareUnconnectedPing builds a minimal RakNet Unconnected Ping (0x01). +func bareUnconnectedPing(pingTime [8]byte) []byte { + magic := []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78} + out := make([]byte, 0, 1+8+8+16) + out = append(out, proto.UnconnectedPingID) + out = append(out, pingTime[:]...) + out = append(out, 0, 0, 0, 0, 0, 0, 0, 0) // client GUID + out = append(out, magic...) + return out +} + +func startFakeBedrockPongServer(t *testing.T) *net.UDPAddr { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + go func() { + buf := make([]byte, 2048) + for { + n, addr, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + if n < 9 || buf[0] != proto.UnconnectedPingID { + continue + } + pong := proto.UnconnectedPing{ + PingTime: append([]byte(nil), buf[1:9]...), + ID: []byte{1, 2, 3, 4, 5, 6, 7, 8}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "test", + ProtocolVersion: "390", + Version: "1.14.60", + Players: "0", + MaxPlayers: "10", + ServerID: "99", + SubMOTD: "", + GameType: "Survival", + NintendoLimited: "1", + Port4: "19132", + Port6: "19132", + }, + }.Build() + _, _ = conn.WriteToUDP(pong.Bytes(), addr) + } + }() + + return conn.LocalAddr().(*net.UDPAddr) +} + +func TestDiscoveryPingDoesNotReuseStaleSession(t *testing.T) { + remoteAddr := startFakeBedrockPongServer(t) + + // Stale "gameplay" peer: accepts datagrams but never replies with a pong. + staleRemote, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = staleRemote.Close() }) + + proxyServer, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: remoteAddr.String(), + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + require.NoError(t, err) + + dataConn, err := net.ListenUDP("udp", proxyServer.bindAddress) + require.NoError(t, err) + proxyServer.server = dataConn + t.Cleanup(func() { + proxyServer.dead.Set() + _ = dataConn.Close() + proxyServer.clientMap.Close() + }) + + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = clientConn.Close() }) + clientAddr := clientConn.LocalAddr().(*net.UDPAddr) + + // Plant a stale gameplay session for this console address. Pre-fix code + // would reuse this DialUDP for LAN pings and hang without a pong. + _, err = proxyServer.clientMap.Get(clientAddr, staleRemote.LocalAddr().(*net.UDPAddr), func(*net.UDPConn) {}) + require.NoError(t, err) + require.Equal(t, 1, proxyServer.clientMap.Len()) + + pingTime := [8]byte{9, 8, 7, 6, 5, 4, 3, 2} + require.NoError(t, proxyServer.handleDiscoveryPing(clientAddr, bareUnconnectedPing(pingTime))) + + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 2048) + n, _, err := clientConn.ReadFromUDP(buf) + require.NoError(t, err) + require.True(t, n >= 1) + require.Equal(t, proto.UnconnectedPongID, buf[0]) + require.True(t, bytes.Equal(buf[1:9], pingTime[:]), "pong must echo ping time") + + // Discovery must not consume/replace the gameplay session entry. + require.Equal(t, 1, proxyServer.clientMap.Len()) +} + +func TestBuildOfflinePongEchoesPingTime(t *testing.T) { + proxyServer, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 19000, + RemoteServer: "127.0.0.1:19001", + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + require.NoError(t, err) + + pingTime := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + pong := proxyServer.buildOfflinePong(bareUnconnectedPing(pingTime)) + require.Equal(t, proto.UnconnectedPongID, pong[0]) + require.True(t, bytes.Equal(pong[1:9], pingTime[:])) +} diff --git a/internal/proxy/discovery_test.go b/internal/proxy/discovery_test.go new file mode 100644 index 0000000..4884845 --- /dev/null +++ b/internal/proxy/discovery_test.go @@ -0,0 +1,135 @@ +package proxy + +import ( + "encoding/binary" + "fmt" + "net" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" +) + +func TestHandleUnconnectedPingOfflineReply(t *testing.T) { + client, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + // Closed port: discovery probe fails quickly and offline pong is advertised. + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:1", + IdleTimeout: time.Minute, + NumWorkers: 1, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + if err := p.StartAsync(); err != nil { + t.Fatal(err) + } + defer p.Close() + + prev := discoveryPingTimeout + discoveryPingTimeout = 200 * time.Millisecond + defer func() { discoveryPingTimeout = prev }() + + ping := []byte{proto.UnconnectedPingID, 1, 2, 3, 4, 5, 6, 7, 8} + if err := p.HandleUnconnectedPing(ping, client.LocalAddr()); err != nil { + t.Fatalf("HandleUnconnectedPing: %v", err) + } + + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 2048) + n, from, err := client.ReadFrom(buf) + if err != nil { + t.Fatalf("client ReadFrom: %v", err) + } + if n < 1 || buf[0] != proto.UnconnectedPongID { + t.Fatalf("expected UnconnectedPong, got n=%d id=%v", n, buf[:n]) + } + fromUDP, ok := from.(*net.UDPAddr) + if !ok { + t.Fatalf("unexpected from type %T", from) + } + if uint16(fromUDP.Port) != p.boundPort { + t.Fatalf("pong from port %d, want boundPort %d", fromUDP.Port, p.boundPort) + } + if !p.serverOffline { + t.Fatal("expected serverOffline after failed discovery probe") + } +} + +func TestHandleUnconnectedPingRejectsBadInput(t *testing.T) { + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19132", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + defer p.Close() + + client := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9} + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPingID}, client); err == nil { + t.Fatal("expected error when proxy not started") + } + + if err := p.StartAsync(); err != nil { + t.Fatal(err) + } + + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPongID}, client); err == nil { + t.Fatal("expected error for non-ping packet") + } + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPingID}, nil); err == nil { + t.Fatal("expected error for nil from") + } +} + +func TestPerProxyServerIDsDistinct(t *testing.T) { + a, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19132", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + b, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19133", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + if a.serverID == b.serverID { + t.Fatalf("expected distinct serverIDs, both %d", a.serverID) + } + + rewritten := a.rewriteUnconnectedPong(proto.OfflinePong.Bytes()) + parsed, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatal(err) + } + gotGUID := binary.BigEndian.Uint64(parsed.ID) + if gotGUID != uint64(a.serverID) { + t.Fatalf("binary GUID %d, want %d", gotGUID, uint64(a.serverID)) + } + want := fmt.Sprintf("%d", a.serverID) + if parsed.Pong.ServerID != want { + t.Fatalf("MOTD ServerID %q, want %q", parsed.Pong.ServerID, want) + } +} diff --git a/internal/proxy/offline_detection_test.go b/internal/proxy/offline_detection_test.go new file mode 100644 index 0000000..22b7783 --- /dev/null +++ b/internal/proxy/offline_detection_test.go @@ -0,0 +1,148 @@ +package proxy + +import ( + "fmt" + "net" + "syscall" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" +) + +func TestIsTimeoutError(t *testing.T) { + t.Parallel() + + if !isTimeoutError(timeoutError{}) { + t.Fatal("expected net.Error timeout") + } + if !isTimeoutError(fmt.Errorf("read udp: i/o timeout")) { + t.Fatal("expected legacy timeout string") + } + if isTimeoutError(syscall.ECONNREFUSED) { + t.Fatal("connection refused is not a timeout") + } + if isTimeoutError(nil) { + t.Fatal("nil should not be a timeout") + } +} + +func TestIsConnRefusedError(t *testing.T) { + t.Parallel() + + if !isConnRefusedError(syscall.ECONNREFUSED) { + t.Fatal("expected syscall.ECONNREFUSED") + } + if !isConnRefusedError(fmt.Errorf("write udp: %w", syscall.ECONNREFUSED)) { + t.Fatal("expected wrapped ECONNREFUSED") + } + if !isConnRefusedError(fmt.Errorf("write: connection refused")) { + t.Fatal("expected legacy connection refused string") + } + if isConnRefusedError(timeoutError{}) { + t.Fatal("timeout is not connection refused") + } +} + +func TestSingleTimeoutDoesNotMarkOffline(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(fmt.Errorf("read udp: i/o timeout")) + if p.serverOffline { + t.Fatal("a single timeout must not advertise OfflinePong (#104)") + } + if p.offlineTimeouts != 1 { + t.Fatalf("offlineTimeouts = %d, want 1", p.offlineTimeouts) + } +} + +func TestSustainedTimeoutsMarkOffline(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + for i := 0; i < offlineTimeoutThreshold-1; i++ { + p.noteUpstreamReadError(timeoutError{}) + if p.serverOffline { + t.Fatalf("marked offline after %d timeouts", i+1) + } + } + p.noteUpstreamReadError(timeoutError{}) + if !p.serverOffline { + t.Fatalf("expected offline after %d timeouts", offlineTimeoutThreshold) + } +} + +func TestConnRefusedMarksOfflineImmediately(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(syscall.ECONNREFUSED) + if !p.serverOffline { + t.Fatal("expected connection refused to mark offline") + } +} + +func TestReachableClearsTimeoutStreak(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(timeoutError{}) + p.noteUpstreamReadError(timeoutError{}) + p.noteUpstreamReachable() + if p.offlineTimeouts != 0 { + t.Fatalf("offlineTimeouts = %d, want 0 after success", p.offlineTimeouts) + } + p.noteUpstreamReadError(timeoutError{}) + if p.serverOffline { + t.Fatal("streak should have reset; one timeout after success must not mark offline") + } +} + +func TestOfflinePongRewriteInjectsBindPort(t *testing.T) { + t.Parallel() + + p := &ProxyServer{boundPort: 54321} + rewritten := p.rewriteUnconnectedPong(proto.OfflinePong.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse offline pong: %v", err) + } + if packet.Pong.Port4 != "54321" { + t.Fatalf("Port4 = %q, want 54321 (clients must not fall back to :19132)", packet.Pong.Port4) + } + if packet.Pong.Port6 != "54321" { + t.Fatalf("Port6 = %q, want 54321", packet.Pong.Port6) + } +} + +func TestReadDeadlineClassifiesAsTimeout(t *testing.T) { + t.Parallel() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond)) + buf := make([]byte, 16) + _, _, err = conn.ReadFrom(buf) + if err == nil { + t.Fatal("expected read deadline error") + } + if !isTimeoutError(err) { + t.Fatalf("expected deadline error to be timeout, got %v", err) + } + if isConnRefusedError(err) { + t.Fatal("deadline error should not be connection refused") + } +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +var _ net.Error = timeoutError{} diff --git a/internal/proxy/offline_error_test.go b/internal/proxy/offline_error_test.go new file mode 100644 index 0000000..27e8bce --- /dev/null +++ b/internal/proxy/offline_error_test.go @@ -0,0 +1,61 @@ +package proxy + +import ( + "fmt" + "net" + "syscall" + "testing" + "time" +) + +func TestIsOfflineError(t *testing.T) { + t.Parallel() + + if !isOfflineError(syscall.ECONNREFUSED) { + t.Fatal("expected syscall.ECONNREFUSED to be offline") + } + if !isOfflineError(fmt.Errorf("write udp: %w", syscall.ECONNREFUSED)) { + t.Fatal("expected wrapped ECONNREFUSED to be offline") + } + if !isOfflineError(&net.OpError{Op: "write", Err: syscall.ECONNREFUSED}) { + t.Fatal("expected net.OpError ECONNREFUSED to be offline") + } + if !isOfflineError(timeoutError{}) { + t.Fatal("expected net.Error timeout to be offline") + } + if !isOfflineError(fmt.Errorf("read udp: i/o timeout")) { + t.Fatal("expected legacy timeout string to be offline") + } + if !isOfflineError(fmt.Errorf("write: connection refused")) { + t.Fatal("expected legacy connection refused string to be offline") + } + if isOfflineError(nil) { + t.Fatal("nil should not be offline") + } + if isOfflineError(fmt.Errorf("something else")) { + t.Fatal("unrelated error should not be offline") + } +} + + +// Keep the idle deadline behavior documented: a timeout longer than the +// per-write read deadline should still classify as offline. +func TestIsOfflineErrorDeadline(t *testing.T) { + t.Parallel() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond)) + buf := make([]byte, 16) + _, _, err = conn.ReadFrom(buf) + if err == nil { + t.Fatal("expected read deadline error") + } + if !isOfflineError(err) { + t.Fatalf("expected deadline error to be offline, got %v", err) + } +} diff --git a/internal/proxy/pong_rewrite_test.go b/internal/proxy/pong_rewrite_test.go new file mode 100644 index 0000000..4034396 --- /dev/null +++ b/internal/proxy/pong_rewrite_test.go @@ -0,0 +1,122 @@ +package proxy + +import ( + "testing" + + "github.com/jhead/phantom/internal/proto" +) + +// geyser-style MOTD: 10 fields, no Port4/Port6 (see GeyserMC advertisement format). +func geyserStylePong() []byte { + pong := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "Geyser", + ProtocolVersion: "557", + Version: "1.19.40", + Players: "0", + MaxPlayers: "20", + ServerID: "12345", + SubMOTD: "Geyser", + GameType: "Survival", + NintendoLimited: "1", + // Port4/Port6 intentionally empty — matches Geyser + }, + } + buf := pong.Build() + return buf.Bytes() +} + +func TestRewriteUnconnectedPongInjectsPortsWhenUpstreamOmitsThem(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 55997, + prefs: ProxyPrefs{}, + } + + rewritten := p.rewriteUnconnectedPong(geyserStylePong()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "55997" { + t.Fatalf("Port4 = %q, want 55997", packet.Pong.Port4) + } + if packet.Pong.Port6 != "55997" { + t.Fatalf("Port6 = %q, want 55997", packet.Pong.Port6) + } +} + +func TestRewriteUnconnectedPongOverwritesExistingPorts(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 19133, + prefs: ProxyPrefs{}, + } + + src := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "BDS", + ProtocolVersion: "557", + Version: "1.19.40", + Players: "0", + MaxPlayers: "10", + ServerID: "99", + SubMOTD: "world", + GameType: "Survival", + NintendoLimited: "0", + Port4: "19132", + Port6: "19132", + }, + } + + buf := src.Build() + rewritten := p.rewriteUnconnectedPong(buf.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "19133" || packet.Pong.Port6 != "19133" { + t.Fatalf("ports = %q/%q, want 19133/19133", packet.Pong.Port4, packet.Pong.Port6) + } +} + +func TestRewriteUnconnectedPongRemovePorts(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 55997, + prefs: ProxyPrefs{RemovePorts: true}, + } + + src := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "x", + Port4: "19132", + Port6: "19132", + }, + } + + buf := src.Build() + rewritten := p.rewriteUnconnectedPong(buf.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "" || packet.Pong.Port6 != "" { + t.Fatalf("expected empty ports, got %q/%q", packet.Pong.Port4, packet.Pong.Port6) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..43dd47b 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,10 +1,14 @@ package proxy import ( + "encoding/binary" + "errors" "fmt" "math/rand" "net" "regexp" + "strings" + "syscall" "time" "github.com/jhead/phantom/internal/clientmap" @@ -15,9 +19,23 @@ import ( reuse "github.com/libp2p/go-reuseport" ) -const maxMTU = 1472 +// udpRecvBufferSize is the per-read buffer for proxied UDP datagrams. +// +// RakNet advertises MTU up to 1492 including IP(20)+UDP(8) headers, so a +// well-formed max UDP payload is 1464. Some Bedrock stacks still emit slightly +// larger datagrams (observed ≥1474), and a too-small ReadFrom buffer silently +// truncates on several platforms — including macOS, where the error is nil. +// Resource-pack transfer is mostly full-MTU traffic, so truncation surfaces as +// clients stuck on "Loading resources...". Use the full UDP datagram limit. +const udpRecvBufferSize = 65535 + +// offlineTimeoutThreshold is how many consecutive upstream read timeouts are +// required before advertising OfflinePong on LAN discovery. A single flaky +// timeout must not flip the whole proxy "offline" (#104). +const offlineTimeoutThreshold = 3 var idleCheckInterval = 5 * time.Second +var discoveryPingTimeout = 5 * time.Second type ProxyServer struct { bindAddress *net.UDPAddr @@ -30,6 +48,8 @@ type ProxyServer struct { prefs ProxyPrefs dead *abool.AtomicBool serverOffline bool + offlineTimeouts int + serverID int64 } type ProxyPrefs struct { @@ -39,13 +59,33 @@ type ProxyPrefs struct { IdleTimeout time.Duration EnableIPv6 bool RemovePorts bool - NumWorkers uint + NumWorkers uint + // DisableDiscoveryListener skips binding :19132/:19133. Used when a + // DiscoveryHub owns discovery and fans pings into HandleUnconnectedPing. + DisableDiscoveryListener bool } var randSource = rand.NewSource(time.Now().UnixNano()) -var serverID = randSource.Int63() var offlineErrorRegex = regexp.MustCompile("(timeout)|(connection refused)") +// isOfflineError reports whether err indicates the remote Bedrock server is +// unreachable. Connected UDP sockets surface ICMP port-unreachable as +// ECONNREFUSED on a later Read/Write — normal UDP behavior, not a proxy bug. +func isOfflineError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + // Fallback for platforms / wrappers that only expose the message text. + return offlineErrorRegex.MatchString(err.Error()) +} + func New(prefs ProxyPrefs) (*ProxyServer, error) { bindPort := prefs.BindPort @@ -78,76 +118,147 @@ func New(prefs ProxyPrefs) (*ProxyServer, error) { prefs, abool.New(), false, + 0, + randSource.Int63(), }, nil } func (proxy *ProxyServer) Start() error { - // Bind to 19132 on all addresses to receive broadcasted pings - // Sets SO_REUSEADDR et al to support multiple instances of phantom - log.Info().Msgf("Binding ping server to port 19132") - if pingServer, err := reuse.ListenPacket("udp4", ":19132"); err == nil { - proxy.pingServer = pingServer + if err := proxy.listen(); err != nil { + return err + } - // Start proxying ping packets from the broadcast listener - go proxy.readLoop(proxy.pingServer) - } else { - // Bind failed + // Start processing everything else using the proxy listener + proxy.startWorkers(proxy.server) + + return nil +} + +// StartAsync is like Start but runs all workers in goroutines and returns once +// listening. Used when one process hosts multiple proxies behind a DiscoveryHub. +func (proxy *ProxyServer) StartAsync() error { + if err := proxy.listen(); err != nil { return err } - // Minecraft automatically broadcasts on port 19133 to the local IPv6 network - if proxy.prefs.EnableIPv6 { - log.Info().Msgf("Binding IPv6 ping server to port 19133") - if pingServerV6, err := reuse.ListenPacket("udp6", ":19133"); err == nil { - proxy.pingServerV6 = pingServerV6 + log.Info().Msgf("Starting %d workers", proxy.prefs.NumWorkers) + for i := uint(0); i < proxy.prefs.NumWorkers; i++ { + go proxy.readLoop(proxy.server) + } + return nil +} - // Start proxying ping packets from the broadcast listener - go proxy.readLoop(proxy.pingServerV6) - } else { - // IPv6 Bind failed - log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) +func (proxy *ProxyServer) listen() error { + if !proxy.prefs.DisableDiscoveryListener { + // Exclusive bind: SO_REUSEPORT/ADDR cannot correctly share discovery + // across processes (see DiscoveryHub / #175). + log.Info().Msgf("Binding ping server to port 19132") + pingServer, err := net.ListenPacket("udp4", ":19132") + if err != nil { + return fmt.Errorf("bind :19132: %w (only one phantom can own LAN discovery; pass multiple -server flags to one process instead of running multiple instances)", err) } - } + proxy.pingServer = pingServer + go proxy.readLoop(proxy.pingServer) - network := "udp4" - if proxy.prefs.EnableIPv6 { - network = "udp" + // Minecraft automatically broadcasts on port 19133 to the local IPv6 network + if proxy.prefs.EnableIPv6 { + log.Info().Msgf("Binding IPv6 ping server to port 19133") + if pingServerV6, err := net.ListenPacket("udp6", ":19133"); err == nil { + proxy.pingServerV6 = pingServerV6 + go proxy.readLoop(proxy.pingServerV6) + } else { + log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) + } + } } - // Bind to specified UDP addr and port to receive data from Minecraft clients log.Info().Msgf("Binding proxy server to: %v", proxy.bindAddress) - if server, err := reuse.ListenPacket(network, proxy.bindAddress.String()); err == nil { - // a safe cast, I promise - proxy.server = server.(*net.UDPConn) - } else { + proxyServer, err := net.ListenUDP("udp", proxy.bindAddress) + if err != nil { return err } + proxy.server = proxyServer - log.Info().Msgf("Proxy server listening!") - log.Info().Msgf("Once your console pings phantom, you should see replies below.") + return nil +} - // Start processing everything else using the proxy listener - proxy.startWorkers(proxy.server) +// listenPacket prefers SO_REUSEPORT via libp2p/reuseport, but some platforms +// (notably iSH on iOS) return EINVAL for that option. Fall back to the stdlib +// listener only for that unsupported-option case so real bind conflicts still +// surface as errors. +func listenPacket(network, address string) (net.PacketConn, error) { + conn, err := reuse.ListenPacket(network, address) + if err == nil { + return conn, nil + } + if !isReuseportUnsupported(err) { + return nil, err + } + log.Warn().Msgf("reuseport listen %s %s unsupported (%v); falling back to net.ListenPacket", network, address, err) + return net.ListenPacket(network, address) +} - return nil +func isReuseportUnsupported(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.EINVAL) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Err != nil { + if errors.Is(opErr.Err, syscall.EINVAL) { + return true + } + err = opErr.Err + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "invalid argument") } func (proxy *ProxyServer) Close() { log.Info().Msgf("Stopping proxy server") - // Stop UDP listeners - proxy.server.Close() - proxy.pingServer.Close() + // Stop loops before closing sockets so readLoop does not busy-spin on + // "use of closed network connection" while dead is still unset. + proxy.dead.Set() + // Stop UDP listeners (ping listeners may be nil when a DiscoveryHub owns them) + if proxy.server != nil { + _ = proxy.server.Close() + } + if proxy.pingServer != nil { + _ = proxy.pingServer.Close() + } if proxy.pingServerV6 != nil { - proxy.pingServerV6.Close() + _ = proxy.pingServerV6.Close() } // Close all connections - proxy.clientMap.Close() + if proxy.clientMap != nil { + proxy.clientMap.Close() + } +} - // Stop loops - proxy.dead.Set() + +// RemoteServer returns the upstream address this proxy forwards to. +func (proxy *ProxyServer) RemoteServer() string { + return proxy.prefs.RemoteServer +} + +// HandleUnconnectedPing processes a discovery ping fanned out from a +// DiscoveryHub. Uses the dedicated discovery probe path (#117). +func (proxy *ProxyServer) HandleUnconnectedPing(data []byte, from net.Addr) error { + if proxy.dead.IsSet() || proxy.server == nil { + return fmt.Errorf("proxy not running") + } + if from == nil { + return fmt.Errorf("nil client address") + } + if len(data) < 1 || data[0] != proto.UnconnectedPingID { + return fmt.Errorf("not an unconnected ping") + } + return proxy.handleDiscoveryPing(from, data) } func (proxy *ProxyServer) startWorkers(listener net.PacketConn) { @@ -167,11 +278,14 @@ func (proxy *ProxyServer) startWorkers(listener net.PacketConn) { func (proxy *ProxyServer) readLoop(listener net.PacketConn) { log.Info().Msgf("Listener starting up: %s", listener.LocalAddr()) - packetBuffer := make([]byte, maxMTU) + packetBuffer := make([]byte, udpRecvBufferSize) for !proxy.dead.IsSet() { err := proxy.processDataFromClients(listener, packetBuffer) if err != nil { + if proxy.dead.IsSet() { + break + } log.Warn().Msgf("Error while processing client data: %s", err) } } @@ -187,14 +301,40 @@ func (proxy *ProxyServer) readLoop(listener net.PacketConn) { // data from the server and send it back to the client. func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packetBuffer []byte) error { // Read the next packet from the client - read, client, _ := listener.ReadFrom(packetBuffer) + read, client, err := listener.ReadFrom(packetBuffer) + if err != nil { + return err + } if read <= 0 { return nil } + // A full buffer often means the kernel truncated a larger datagram + // (and on some platforms ReadFrom still returns err == nil). + if read == len(packetBuffer) { + return fmt.Errorf("UDP datagram filled receive buffer (%d bytes); possible truncation", read) + } data := packetBuffer[:read] log.Trace().Msgf("client recv: %v", data) + // Drop echoes of our own DialUDP traffic. When -server points at this same + // host's :19132 (typical LAN setup), SO_REUSEADDR can deliver our outbound + // packets back to the ping listener. Proxying those again would open a new + // UDP socket per echo until dial fails with "too many open files". + if proxy.clientMap.IsOwnAddress(client) { + return nil + } + + // LAN discovery must not share the per-client DialUDP session used for + // gameplay. After a console disconnects, that socket is often a stale + // RakNet association: the remote ignores further Unconnected Pings, while + // console re-pings keep refreshing SetReadDeadline + idle lastActive, so + // the session never expires and the server vanishes from LAN until restart + // (GitHub #117). + if data[0] == proto.UnconnectedPingID { + return proxy.handleDiscoveryPing(client, data) + } + // Handler triggered when a new client connects and we create a new connetion to the remote server onNewConnection := func(newServerConn *net.UDPConn) { log.Info().Msgf("New connection from client %s -> %s", client.String(), listener.LocalAddr()) @@ -214,29 +354,158 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet // Wait 5 seconds for the server to respond to whatever we sent, or else timeout _ = serverConn.SetReadDeadline(time.Now().Add(time.Second * 5)) - if packetID := data[0]; packetID == proto.UnconnectedPingID { - log.Info().Msgf("Received LAN ping from client: %s", client.String()) + // Write packet from client to server + _, err = serverConn.Write(data) + if err == nil { + return nil + } + + // Write often consumes the pending ICMP error before processDataFromServer's + // ReadFrom sees it. Meanwhile LAN pings keep refreshing SetReadDeadline and + // ClientMap lastActive, so the session never times out and never marks the + // server offline — producing a spam of "connection refused" warnings (#79). + if isOfflineError(err) { + proxy.markServerOffline() + proxy.clientMap.Delete(client) + return nil + } - if proxy.serverOffline { - replyBuffer := proto.OfflinePong - replyBytes := proxy.rewriteUnconnectedPong(replyBuffer.Bytes()) + return err +} - proxy.server.WriteTo(replyBytes, client) - log.Info().Msgf("Sent server offline pong to client: %v", client.String()) +func isTimeoutError(err error) bool { + if err == nil { + return false + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + return strings.Contains(err.Error(), "timeout") +} + +func isConnRefusedError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "connection refused") +} + +// noteUpstreamReadError updates offline state from a failed server read. +// Connection refused marks offline immediately. Timeouts only do so after +// offlineTimeoutThreshold consecutive failures so one flaky UDP deadline +// does not broadcast OfflinePong to every LAN client (#104). +func (proxy *ProxyServer) noteUpstreamReadError(err error) { + log.Warn().Msgf("%v", err) + + if isConnRefusedError(err) { + proxy.offlineTimeouts = 0 + proxy.markServerOffline() + return + } + if isTimeoutError(err) { + proxy.offlineTimeouts++ + if proxy.offlineTimeouts >= offlineTimeoutThreshold { + proxy.markServerOffline() } + return + } +} + +// handleDiscoveryPing answers a console/LAN Unconnected Ping using a fresh +// probe to the remote server, independent of any gameplay session. +func (proxy *ProxyServer) handleDiscoveryPing(client net.Addr, ping []byte) error { + log.Info().Msgf("Received LAN ping from client: %s", client.String()) - // Pass ping through to server even if it's offline + pong, err := proxy.probeRemoteUnconnectedPong(ping) + if err != nil { + proxy.markServerOffline() + if _, werr := proxy.server.WriteTo(proxy.buildOfflinePong(ping), client); werr != nil { + return werr + } + log.Info().Msgf("Sent server offline pong to client: %v", client.String()) + return nil } - // Write packet from client to server - _, err = serverConn.Write(data) - return err + proxy.noteUpstreamReachable() + + pong = proxy.rewriteUnconnectedPong(pong) + if _, err := proxy.server.WriteTo(pong, client); err != nil { + return err + } + log.Info().Msgf("Sent LAN pong to client: %v", client.String()) + return nil +} + +// probeRemoteUnconnectedPong dials a one-shot UDP socket to the remote and +// waits for an Unconnected Pong. A fresh local port avoids stale RakNet state. +func (proxy *ProxyServer) probeRemoteUnconnectedPong(ping []byte) ([]byte, error) { + conn, err := net.DialUDP("udp", nil, proxy.remoteServerAddress) + if err != nil { + return nil, err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(discoveryPingTimeout)) + if _, err := conn.Write(ping); err != nil { + return nil, err + } + + buf := make([]byte, udpRecvBufferSize) + for { + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + if n < 1 { + continue + } + if n == len(buf) { + continue + } + if buf[0] != proto.UnconnectedPongID { + continue + } + out := make([]byte, n) + copy(out, buf[:n]) + return out, nil + } +} + +func (proxy *ProxyServer) markServerOffline() { + if proxy.serverOffline { + return + } + log.Warn().Msgf("Server seems to be offline :(") + log.Warn().Msgf("We'll keep trying to connect...") + proxy.serverOffline = true +} + +func (proxy *ProxyServer) noteUpstreamReachable() { + proxy.offlineTimeouts = 0 + if proxy.serverOffline { + log.Info().Msgf("Server is back online!") + proxy.serverOffline = false + } +} + +// buildOfflinePong returns the canned offline advertisement, echoing the +// client's ping time so consoles can match the response to their request. +func (proxy *ProxyServer) buildOfflinePong(ping []byte) []byte { + reply := append([]byte(nil), proto.OfflinePong.Bytes()...) + if len(ping) >= 9 && len(reply) >= 9 { + copy(reply[1:9], ping[1:9]) + } + return proxy.rewriteUnconnectedPong(reply) } // Proxies packets sent by the server to us for a specific Minecraft client back to // that client's UDP connection. func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client net.Addr) { - buffer := make([]byte, maxMTU) + buffer := make([]byte, udpRecvBufferSize) for !proxy.dead.IsSet() { // Read the next packet from the server @@ -247,16 +516,20 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client // Read error if err != nil { - log.Warn().Msgf("%v", err) - - offlineError := offlineErrorRegex.MatchString(err.Error()) - - if offlineError && !proxy.serverOffline { - log.Warn().Msgf("Server seems to be offline :(") - log.Warn().Msgf("We'll keep trying to connect...") - proxy.serverOffline = true + // Conn closed by idle cleanup / offline write-path Delete — expected. + if errors.Is(err, net.ErrClosed) { + break } + // Game-session read errors (including the 5s deadline after a + // console disconnect) are not a reliable "server offline" signal. + // LAN discovery uses a dedicated probe (#117). Connection refused + // on an active session still marks offline (#79/#104). + if isConnRefusedError(err) { + proxy.noteUpstreamReadError(err) + } else if !offlineErrorRegex.MatchString(err.Error()) { + log.Warn().Msgf("%v", err) + } break } @@ -265,21 +538,17 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client continue } - if proxy.serverOffline { - log.Info().Msgf("Server is back online!") - proxy.serverOffline = false + if read == len(buffer) { + log.Warn().Msgf("UDP datagram from server filled receive buffer (%d bytes); dropping possibly truncated packet", read) + continue } + proxy.noteUpstreamReachable() + // Resize data to byte count from 'read' data := buffer[:read] log.Trace().Msgf("server recv: %v", data) - // Rewrite Unconnected Pong packets - if packetID := data[0]; packetID == proto.UnconnectedPongID { - data = proxy.rewriteUnconnectedPong(data) - log.Info().Msgf("Sent LAN pong to client: %v", client.String()) - } - proxy.server.WriteTo(data, client) } @@ -290,17 +559,23 @@ func (proxy *ProxyServer) rewriteUnconnectedPong(data []byte) []byte { log.Debug().Msgf("Received Unconnected Pong from server: %v", data) if packet, err := proto.ReadUnconnectedPing(data); err == nil { - // Overwrite the server ID with one unique to this phantom instance. - // If we don't do this, the client will get confused if you restart phantom. - packet.Pong.ServerID = fmt.Sprintf("%d", serverID) - - // Overwrite port numbers sent back from server (if any) - if packet.Pong.Port4 != "" && !proxy.prefs.RemovePorts { - packet.Pong.Port4 = fmt.Sprintf("%d", proxy.boundPort) - packet.Pong.Port6 = packet.Pong.Port4 - } else if proxy.prefs.RemovePorts { + // Overwrite the server ID with one unique to this proxy. + // If we don't do this, the client will get confused if you restart phantom, + // and multiple -server backends in one process would look identical. + id := make([]byte, 8) + binary.BigEndian.PutUint64(id, uint64(proxy.serverID)) + packet.ID = id + packet.Pong.ServerID = fmt.Sprintf("%d", proxy.serverID) + + // Always advertise phantom's bind port. Upstream MOTDs (notably Geyser) + // often omit Port4/Port6; leaving them empty makes consoles fall back to + // 19132 or skip the LAN/Friends entry entirely. + if proxy.prefs.RemovePorts { packet.Pong.Port4 = "" packet.Pong.Port6 = "" + } else { + packet.Pong.Port4 = fmt.Sprintf("%d", proxy.boundPort) + packet.Pong.Port6 = packet.Pong.Port4 } packetBuffer := packet.Build() diff --git a/internal/proxy/reuseport_fallback_test.go b/internal/proxy/reuseport_fallback_test.go new file mode 100644 index 0000000..1b0506b --- /dev/null +++ b/internal/proxy/reuseport_fallback_test.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "errors" + "fmt" + "net" + "syscall" + "testing" +) + +func TestIsReuseportUnsupported(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "EINVAL", err: syscall.EINVAL, want: true}, + {name: "wrapped EINVAL", err: fmt.Errorf("bind: %w", syscall.EINVAL), want: true}, + {name: "OpError EINVAL", err: &net.OpError{Op: "listen", Net: "udp4", Err: syscall.EINVAL}, want: true}, + {name: "string invalid argument", err: errors.New("listen udp4 :19132: invalid argument"), want: true}, + {name: "address in use", err: syscall.EADDRINUSE, want: false}, + {name: "OpError address in use", err: &net.OpError{Op: "listen", Net: "udp4", Err: syscall.EADDRINUSE}, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isReuseportUnsupported(tc.err); got != tc.want { + t.Fatalf("isReuseportUnsupported(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/proxy/rewrite_test.go b/internal/proxy/rewrite_test.go new file mode 100644 index 0000000..3b53ca4 --- /dev/null +++ b/internal/proxy/rewrite_test.go @@ -0,0 +1,253 @@ +package proxy + +// Unit tests for the pong rewrite. +// +// This file lives in `package proxy` rather than in test/compat because +// rewriteUnconnectedPong is an unexported method; Go offers no way to reach it +// from an external test package. That is what splits the unit tests across +// internal/proto and internal/proxy. See test/compat/DESIGN.md. +// +// The rewrite contract (see DESIGN.md): phantom replaces the server ID +// and the advertised ports, and must leave every other field exactly as the +// upstream server sent it. + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/jhead/phantom/internal/corpus" +) + +const testBoundPort = 54321 + +// MOTD field indices, in PongData declaration order. +const ( + idxEdition = iota + idxMOTD + idxProtocolVersion + idxVersion + idxPlayers + idxMaxPlayers + idxServerID + idxSubMOTD + idxGameType + idxGameMode // struct calls this NintendoLimited; it is really a numeric game mode + idxPort4 + idxPort6 +) + +// testServerID is the per-instance identity the rewrite should stamp into both +// the ServerID string field and the binary GUID. +const testServerID int64 = 0x0123456789ABCDEF + +// newTestProxy builds the minimal ProxyServer the rewrite path actually touches. +// No sockets are opened - this is a pure-function test of packet surgery. +func newTestProxy(removePorts bool) *ProxyServer { + return &ProxyServer{ + boundPort: testBoundPort, + serverID: testServerID, + prefs: ProxyPrefs{RemovePorts: removePorts}, + } +} + +func fields(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// rewriteFields runs the rewrite and returns the incoming and outgoing MOTD fields. +func rewriteFields(t *testing.T, p *ProxyServer, frame []byte) (in, out []string, outFrame []byte) { + t.Helper() + + inMOTD, err := corpus.SplitMOTD(frame) + if err != nil { + t.Fatalf("test fixture is not a well-formed pong: %v", err) + } + + outFrame = p.rewriteUnconnectedPong(frame) + + outMOTD, err := corpus.SplitMOTD(outFrame) + if err != nil { + t.Fatalf("rewrite produced an unparseable pong: %v", err) + } + return fields(inMOTD), fields(outMOTD), outFrame +} + +// TestRewritePreservesUpstreamFields is invariant 1: everything except the +// server ID and the two port fields must survive the rewrite untouched, across +// every captured protocol version. +func TestRewritePreservesUpstreamFields(t *testing.T) { + rewritten := map[int]bool{idxServerID: true, idxPort4: true, idxPort6: true} + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + for i := range in { + if rewritten[i] || i >= len(out) { + continue + } + if in[i] != out[i] { + t.Errorf("field %d must pass through unchanged: %q -> %q", i, in[i], out[i]) + } + } + }) + } +} + +// TestRewriteReplacesServerID is invariant 4a. +func TestRewriteReplacesServerID(t *testing.T) { + want := fmt.Sprintf("%d", testServerID) + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + if out[idxServerID] != want { + t.Errorf("ServerID = %q, want phantom instance id %q", out[idxServerID], want) + } + if in[idxServerID] == out[idxServerID] { + t.Errorf("ServerID was not rewritten (upstream and output both %q); "+ + "restarting phantom would confuse clients", in[idxServerID]) + } + }) + } +} + +// TestRewriteRewritesPorts is invariant 5. phantom always advertises its own +// bind port, including when the upstream omitted Port4 and Port6 entirely. +// Geyser commonly omits them, and an empty port field makes consoles fall back +// to 19132 or skip the entry, so injecting them is deliberate. +func TestRewriteRewritesPorts(t *testing.T) { + wantPort := fmt.Sprintf("%d", testBoundPort) + + t.Run("ports-present", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Ports;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;") + + _, out, _ := rewriteFields(t, newTestProxy(false), frame) + + if out[idxPort4] != wantPort { + t.Errorf("Port4 = %q, want %q", out[idxPort4], wantPort) + } + if out[idxPort6] != wantPort { + t.Errorf("Port6 = %q, want %q", out[idxPort6], wantPort) + } + }) + + t.Run("remove-ports-flag", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Ports;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;") + + _, out, _ := rewriteFields(t, newTestProxy(true), frame) + + if len(out) > idxPort4 && out[idxPort4] != "" { + t.Errorf("Port4 = %q, want empty under -remove_ports", out[idxPort4]) + } + if len(out) > idxPort6 && out[idxPort6] != "" { + t.Errorf("Port6 = %q, want empty under -remove_ports", out[idxPort6]) + } + }) + + t.Run("ports-injected-when-upstream-omits-them", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Legacy;390;1.14.60;0;10;12345678;Sub;Survival;1;") + + _, out, _ := rewriteFields(t, newTestProxy(false), frame) + + if len(out) <= idxPort6 { + t.Fatalf("rewrite emitted only %d fields, want the port fields appended", len(out)) + } + if out[idxPort4] != wantPort { + t.Errorf("Port4 = %q, want %q injected", out[idxPort4], wantPort) + } + if out[idxPort6] != wantPort { + t.Errorf("Port6 = %q, want %q injected", out[idxPort6], wantPort) + } + }) +} + +// TestRewritePreservesTrailingFields is invariant 2, exercised against real +// captured bytes. +func TestRewritePreservesTrailingFields(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + if len(out) < len(in) { + t.Errorf( + "rewrite dropped %d trailing field(s) %q (upstream sent %d, phantom emitted %d)", + len(in)-len(out), in[len(out):], len(in), len(out)) + } + }) + } +} + +// TestRewritePreservesHeader is invariant 3, plus the ping-time half of +// invariant 6: the rewrite must not disturb the RakNet header it passes through. +func TestRewritePreservesHeader(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + frame := e.Frame() + out := newTestProxy(false).rewriteUnconnectedPong(frame) + + if len(out) < 33 { + t.Fatalf("rewritten packet is only %d bytes", len(out)) + } + if !bytes.Equal(out[1:9], frame[1:9]) { + t.Errorf("ping time changed: %x -> %x", frame[1:9], out[1:9]) + } + if !bytes.Equal(out[17:33], corpus.Magic) { + t.Errorf("RakNet magic corrupted: %x", out[17:33]) + } + }) + } +} + +// TestRewriteRewritesBinaryGUID is invariant 7. RakNet identifies a server by +// the 8-byte GUID at bytes 9-16, not by the MOTD string field, so rewriting only +// the string would make two instances proxying one upstream look identical. +func TestRewriteRewritesBinaryGUID(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + frame := e.Frame() + out := newTestProxy(false).rewriteUnconnectedPong(frame) + + if bytes.Equal(out[9:17], frame[9:17]) { + t.Errorf( + "binary ServerGUID passed through unchanged (%x); "+ + "it must carry phantom's per-instance id, like the string ServerID does", + out[9:17]) + } + }) + } +} + +// TestRewriteSurvivesMalformedInput: the rewrite runs on whatever arrives from +// the network. It must never panic, and on unparseable input it must fall back +// to forwarding the original bytes rather than dropping the packet. +func TestRewriteSurvivesMalformedInput(t *testing.T) { + p := newTestProxy(false) + + for _, e := range corpus.Synthetic() { + t.Run(e.ID, func(t *testing.T) { + frame := e.Frame() + + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC rewriting %q: %v", e.ID, r) + } + }() + + out := p.rewriteUnconnectedPong(frame) + if out == nil { + t.Error("rewrite returned nil; the packet would be silently dropped") + } + }) + } +} diff --git a/internal/proxy/udp_recv_buffer_test.go b/internal/proxy/udp_recv_buffer_test.go new file mode 100644 index 0000000..c603d76 --- /dev/null +++ b/internal/proxy/udp_recv_buffer_test.go @@ -0,0 +1,141 @@ +package proxy + +import ( + "bytes" + "net" + "testing" + "time" +) + +func TestUDPRecvBufferCoversOversizedRakNetDatagrams(t *testing.T) { + // Historical phantom buffer was 1472. go-raknet reads with 1492; some + // servers have been observed sending ≥1474-byte UDP payloads. + if udpRecvBufferSize < 1492 { + t.Fatalf("udpRecvBufferSize=%d; want at least 1492 to avoid truncating RakNet MTU probes", udpRecvBufferSize) + } + + pc, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer pc.Close() + + client, err := net.DialUDP("udp4", nil, pc.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + for _, size := range []int{1464, 1472, 1474, 1492, 2048, 4096} { + payload := make([]byte, size) + for i := range payload { + payload[i] = byte(i % 251) + } + payload[0] = 0xFE + payload[size-1] = 0xAA + + if _, err := client.Write(payload); err != nil { + t.Fatalf("write %d: %v", size, err) + } + + _ = pc.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, udpRecvBufferSize) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("read %d: %v", size, err) + } + if n != size { + t.Fatalf("size %d: got n=%d (truncated)", size, n) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("size %d: payload mismatch", size) + } + } +} + +func TestProxyForwardsLargeDatagramsBothWays(t *testing.T) { + remote, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer remote.Close() + + // Exclusive :19132 bind in Start() — skip cleanly if another phantom owns it. + probe, err := net.ListenPacket("udp4", "127.0.0.1:19132") + if err != nil { + t.Skipf("port 19132 unavailable: %v", err) + } + probe.Close() + + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: remote.LocalAddr().String(), + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { done <- p.Start() }() + defer p.Close() + + // Wait until data plane is listening. + deadline := time.Now().Add(2 * time.Second) + for p.server == nil && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if p.server == nil { + select { + case err := <-done: + t.Fatalf("Start failed: %v", err) + default: + t.Fatal("proxy server not listening") + } + } + + client, err := net.DialUDP("udp4", nil, p.bindAddress) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + sizes := []int{1400, 1474, 1492, 2048} + for _, size := range sizes { + up := make([]byte, size) + up[0] = 0x8C // non-pong game/control byte + up[size-1] = 0x11 + if _, err := client.Write(up); err != nil { + t.Fatalf("client write %d: %v", size, err) + } + + _ = remote.SetReadDeadline(time.Now().Add(time.Second)) + rbuf := make([]byte, udpRecvBufferSize) + rn, from, err := remote.ReadFrom(rbuf) + if err != nil { + t.Fatalf("remote read %d: %v", size, err) + } + if rn != size || !bytes.Equal(rbuf[:rn], up) { + t.Fatalf("upstream got n=%d want %d (truncated or corrupt)", rn, size) + } + + down := make([]byte, size) + down[0] = 0x8D + down[size-1] = 0x22 + if _, err := remote.WriteTo(down, from); err != nil { + t.Fatalf("remote write %d: %v", size, err) + } + + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + cbuf := make([]byte, udpRecvBufferSize) + cn, _, err := client.ReadFrom(cbuf) + if err != nil { + t.Fatalf("client read %d: %v", size, err) + } + if cn != size || !bytes.Equal(cbuf[:cn], down) { + t.Fatalf("client got n=%d want %d (truncated or corrupt)", cn, size) + } + } +} diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md new file mode 100644 index 0000000..b7ff087 --- /dev/null +++ b/test/compat/DESIGN.md @@ -0,0 +1,227 @@ +# Cross-version compatibility test strategy + +phantom sits between a Minecraft Bedrock client and a remote Bedrock server. It is +mostly a transparent UDP relay, but it parses and rewrites one packet type: the +RakNet Unconnected Pong (0x1c), whose payload is a semicolon-delimited MOTD string +whose shape changes as Mojang ships new protocol versions. + +That makes compatibility failures silent and version-gated. phantom keeps proxying, +but a client on a newer version either does not list the server or displays wrong +information. + +## Goals + +- Validate phantom against every Minecraft protocol version the tooling supports. +- Run unattended in CI on every push, with no external services and no credentials. +- Fail loudly and specifically when a protocol version breaks an invariant. +- Stay compatible with every new Minecraft version. + +## Test types + +Tests are split into two types: unit tests that import phantom's packages, and end to +end tests that may use only the compiled binary. + +### Unit tests + +Unit tests replay a corpus of pong payloads through the parser and the rewriter, +asserting on the resulting bytes and fields. + +- Run in well under a second, with no network, no ports, no subprocess and no Node. +- Cover all 51 supported protocol versions on every push and on every OS. +- Can construct a ProxyServer with whatever internal state a case needs and call + unexported functions. + +They live in two packages because `rewriteUnconnectedPong` is an unexported method on +`*ProxyServer` and Go cannot reach it from an external test package. Parser tests are +in `internal/proto`, rewrite tests in `internal/proxy`. + +`ReadUnconnectedPing` parses untrusted bytes straight off a UDP socket, so there is +also a fuzz target. It is seeded from the corpus, so the fuzzer starts from realistic +structure rather than discovering the RakNet header from scratch. + +### End to end tests + +These run the compiled phantom binary as a subprocess and drive it over real UDP +sockets. Two kinds of upstream stand behind it: + +- A scriptable fake server in `test/compat/fakeserver`, giving byte-exact control over + the pong. It emits malformed, truncated, oversized, zero-field and 30-field pongs, + none of which a real server can be made to produce. +- A real bedrock-protocol server, paired with a real bedrock-protocol client. Its + parser judges phantom's rewritten pong, so a malformed rewrite fails against an + independent implementation instead of round-tripping through phantom's own parser + and hiding the same misunderstanding twice. + +Running the binary rather than an in-process ProxyServer is forced by three things. +`Start()` blocks, `Close()` panics if called before a successful bind, and `serverID` +is a package global, which makes "two instances must advertise different identities" +unobservable from inside a single process. It also exercises the artifact that ships, +including its flag parsing. + +The bedrock-protocol side is driven by Node scripts, each a single-purpose CLI that +prints one line of JSON. Go owns the version matrix, process lifecycle and every +assertion, so `make test` remains the single entry point and Go contributors do not +need to edit JavaScript. + +### Boundary + +End to end tests must not import `internal/proto`, `internal/proxy` or +`internal/clientmap`. They may import `internal/corpus`, which holds fixture data and +contains no phantom protocol logic. `TestBlackBoxRuleHolds` enforces this with +`go list -deps` rather than relying on convention. + +Unit invariants are about bytes and pure functions. End to end invariants are about +process identity, sockets and time. An assertion that needs phantom's internals +belongs in a unit test. + +## Invariants + +Given the pong an upstream server sends and the pong phantom emits in response: + +### Field preservation + +1. Edition, MOTD, ProtocolVersion, Version, Players, MaxPlayers, SubMOTD and GameType + pass through byte-identical. Covered by both types. +2. Fields beyond the twelve phantom models are preserved. Real servers send thirteen. + Covered by unit tests. +3. The 16-byte RakNet magic is unmodified. Covered by unit tests. + +### Rewriting + +4. The ServerID field carries phantom's instance identity, is stable across repeated + pings, and differs between two running instances. The multi-instance half is end to + end only, since `serverID` is a package global. +5. Port4 and Port6 carry phantom's bound port, and both are empty under + `-remove_ports`. A server that advertises no ports keeps advertising none. Unit + tests cover the logic, end to end tests the flag wiring. +6. The pong echoes the ping time the client sent. Unit tests cover the proxied path, + end to end tests the offline path. +7. The binary ServerGUID at bytes 9 to 16 is non-zero and per-instance. RakNet + identifies servers by this value, not by the MOTD string field. Covered by both + types. + +### Session behavior + +8. The RakNet handshake completes through phantom for RakNet protocol versions 8 + through 11. +9. A real bedrock-protocol client completes the RakNet handshake and the login + sequence through phantom. +10. Payloads survive the round trip byte-identically at 1, 576, 1400, 1464 and 1472 + bytes, or are dropped cleanly, never truncated silently. +11. Two concurrent clients never receive each other's datagrams. +12. An idle client is reaped after the configured timeout, and a later packet from + that client still proxies. +13. When the upstream stops responding phantom answers with its offline pong, and + when the upstream returns phantom resumes proxying without a restart. +14. A client pinging with 0x02 receives an offline pong while the upstream is down, as + it does with 0x01. + +All of these are covered by end to end tests. + +### Robustness + +15. Truncated, oversized, zero-field and 30-field pongs never panic phantom and never + produce output an independent parser rejects. Covered by the fuzz target and by + end to end tests against the fake server. + +## Test corpus + +The corpus lives in `internal/corpus/data` and is embedded into the binary, so tests +resolve no paths at runtime. + +### Captured corpus + +`captured.json` holds real pong bytes recorded from a bedrock-protocol server, one +entry per supported Minecraft version. `make corpus` regenerates it, and the result is +committed, so a change in any version's wire bytes shows up as a reviewable diff. CI +replays the committed bytes and never captures new ones. + +bedrock-protocol uses a single advertisement serializer, so the field count is +constant across every version it emits and only the protocol number and version +string vary. The captured corpus is therefore a drift anchor and a source of realistic +bytes, not a source of shape diversity. + +### Synthetic corpus + +`synthetic.json` supplies the shape diversity the captured corpus cannot: 10, 12, 13, +14 and 15 field pongs, empty fields, section-sign colour codes, multi-byte UTF-8, a +raw semicolon inside the MOTD, an oversized MOTD, and 30 fields. It also supplies +malformed frames: empty packets, truncation at several offsets, a length prefix that +exceeds the body, a zeroed magic, and a ping packet ID on an otherwise valid pong. + +Fixtures that test a semantic defect are built as complete, well-formed frames with +exactly one thing wrong. A hand-written truncated fixture would trip the length check +first and prove nothing about the defect it claims to test. + +## Version matrix and sharding + +`test/compat/matrix.json` lists the versions the end to end tests exercise and assigns +each to a CI shard. Unit tests sweep every supported version because it is cheap. End +to end tests boot a real client and server per version, so they sample the oldest +supported version, the newest, and the newest of each minor line in between. + +Sharding provides isolation as well as parallelism. phantom binds UDP port 19132 +unconditionally, and because it sets SO_REUSEPORT a second binder does not fail. The +kernel load-balances datagrams between the two, so two concurrent cases on one host +silently consume each other's packets. One shard per CI runner gives each its own +network stack. Cases run serially within a shard. + +Locally the suite runs serially and checks port 19132 first, skipping with an +explanatory message if it is busy, so a developer running Minecraft or phantom does +not see confusing failures. + +## Harness mechanics + +Readiness is established by behavior. The harness pings until it receives a pong or a +deadline expires. It does not scrape logs and it does not sleep for fixed intervals. + +`-bind_port` is always passed explicitly, because phantom's default of 0 selects a +random port that the test would have no way to discover. + +Timeouts are named constants and scale with the `PHANTOM_TEST_TIMEOUT_SCALE` +environment variable. Cases that confirm a reply will not arrive use a much shorter +budget than readiness cases, since there is nothing left to wait for once phantom is +known to be up. + +Cases that wait on phantom's real timers, such as the 5 second idle sweep, sit behind +a `slow` build tag. CI includes them and `make test` does not. Cases that need a real +client stack sit behind a `node` build tag, so the rest of the suite runs on machines +with no Node toolchain. + +Session tests open a control connection directly to the upstream before testing the +proxied path. If the direct connection fails, the client stack cannot run in that +environment and the test skips. Only a direct success followed by a proxied failure +attributes the problem to phantom. Without that control, an unrelated problem in the +client stack would be indistinguishable from a phantom bug. + +## Continuous integration + +The per-push pipeline runs four jobs: + +- `vet` runs gofmt, `go vet` and `go test -race`, pinned to the Go version declared in + go.mod so accidental use of a newer language feature is caught. +- `unit` runs the corpus tests across Linux, macOS and Windows, plus a short fuzzing + pass. It uses a current Go toolchain rather than the declared minimum, because + recent macOS releases reject binaries without an LC_UUID load command and Go's + internal linker only began emitting one in 1.26. +- `e2e` runs the end to end tests across four shards, one per runner. +- `cross-compile` builds every release target. + +The nightly pipeline runs the full unsampled version sweep, a long fuzzing pass, and a +drift check comparing the versions bedrock-protocol supports against the versions this +repository tests. The drift check is what keeps coverage current as Minecraft ships +new releases. + +## Out of scope + +- Performance and load testing. +- Real console clients, and LAN broadcast discovery to 255.255.255.255. Broadcast is + phantom's real discovery path on console, so this is a genuine gap. Covering it + needs a dummy network interface and root. +- Xbox Live authentication. All tests run in offline mode. +- Game-layer packet semantics. phantom is opaque above RakNet and the tests treat it + that way. Session tests assert that login completes, not what the server sends + afterwards. +- Testing against Mojang's Bedrock Dedicated Server, which would add large downloads, + an EULA, a single supported architecture, and dependence on version-specific + download URLs that have broken in the past. diff --git a/test/compat/e2e_test.go b/test/compat/e2e_test.go new file mode 100644 index 0000000..e83a70d --- /dev/null +++ b/test/compat/e2e_test.go @@ -0,0 +1,449 @@ +//go:build e2e + +package compat + +// End-to-end tests. +// +// Everything here drives a real phantom subprocess over real UDP sockets. No +// knowledge of phantom's internals is used or permitted; TestBlackBoxRuleHolds +// enforces that mechanically. +// +// Run with: go test -tags=e2e ./test/compat/... + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/test/compat/fakeserver" +) + +// fieldsOf splits a MOTD, dropping the empty element left by the conventional +// trailing semicolon. +func fieldsOf(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// motdOf extracts the MOTD from a pong datagram. +func motdOf(t *testing.T, pong []byte) string { + t.Helper() + motd, err := corpus.SplitMOTD(pong) + if err != nil { + t.Fatalf("phantom emitted an unparseable pong (%d bytes): %v", len(pong), err) + } + return motd +} + +// startPair boots a fake upstream and a phantom pointed at it. +func startPair(t *testing.T, up fakeserver.Opts, po Opts) (*fakeserver.Server, *Phantom) { + t.Helper() + RequirePingPort(t) + + srv, err := fakeserver.Start(up) + if err != nil { + t.Fatalf("starting fake upstream: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + po.RemoteServer = srv.Addr() + return srv, Start(t, po) +} + +// TestPongPassesThroughUpstreamFields is invariant 1: everything except the +// server id and the ports must reach the client exactly as the upstream sent it. +func TestPongPassesThroughUpstreamFields(t *testing.T) { + const motd = "MCPE;Passthrough Test;800;1.21.80;7;20;999888777;My Sub;Creative;1;19132;19133;0;" + + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + + in, out := fieldsOf(motd), fieldsOf(motdOf(t, pong)) + + // Indices 6 (server id), 10 and 11 (ports) are phantom's to rewrite. + rewritten := map[int]bool{6: true, 10: true, 11: true} + for i := range in { + if rewritten[i] || i >= len(out) { + continue + } + if in[i] != out[i] { + t.Errorf("field %d must pass through unchanged: upstream %q, client saw %q", + i, in[i], out[i]) + } + } +} + +// TestTrailingFieldsReachClient is invariant 2 measured end-to-end: a real +// client pinging through phantom never sees the 13th field. +func TestTrailingFieldsReachClient(t *testing.T) { + const motd = "MCPE;Trailing;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + + in, out := fieldsOf(motd), fieldsOf(motdOf(t, pong)) + + if len(out) < len(in) { + t.Errorf("client received %d fields, upstream sent %d; lost %q", + len(out), len(in), in[len(out):]) + } +} + +// TestServerIDIsStableAndPerInstance is invariant 4b, and the reason phantom is +// run as a subprocess: serverID is a package global, so two instances cannot be +// distinguished from inside one process. +func TestServerIDIsStableAndPerInstance(t *testing.T) { + const motd = "MCPE;ID Test;800;1.21.80;0;10;111222333;Sub;Creative;1;19132;19133;0;" + + srv, p1 := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + idOf := func(p *Phantom) string { + t.Helper() + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + if len(f) < 7 { + t.Fatalf("pong has only %d fields", len(f)) + } + return f[6] + } + + first := idOf(p1) + + t.Run("stable-across-pings", func(t *testing.T) { + for i := 0; i < 3; i++ { + if got := idOf(p1); got != first { + t.Fatalf("server id changed between pings: %q then %q", first, got) + } + } + }) + + t.Run("differs-from-upstream", func(t *testing.T) { + if first == "111222333" { + t.Error("phantom forwarded the upstream's server id unchanged; " + + "restarting phantom would confuse clients") + } + }) + + t.Run("differs-between-instances", func(t *testing.T) { + p2 := Start(t, Opts{RemoteServer: srv.Addr()}) + if second := idOf(p2); second == first { + t.Errorf("two phantom instances advertised the same server id %q; "+ + "clients cannot tell them apart", second) + } + }) +} + +// TestPortRewriting is invariant 5, exercised through the real CLI flags. +func TestPortRewriting(t *testing.T) { + const motd = "MCPE;Ports;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + t.Run("rewritten-to-bind-port", func(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + want := fmt.Sprintf("%d", p.BindPort) + + if f[10] != want { + t.Errorf("Port4 = %q, want phantom's bind port %q", f[10], want) + } + if f[11] != want { + t.Errorf("Port6 = %q, want phantom's bind port %q", f[11], want) + } + }) + + t.Run("remove_ports-flag", func(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{RemovePorts: true}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + + if len(f) > 10 && f[10] != "" { + t.Errorf("Port4 = %q, want empty under -remove_ports", f[10]) + } + if len(f) > 11 && f[11] != "" { + t.Errorf("Port6 = %q, want empty under -remove_ports", f[11]) + } + }) +} + +// TestProxiedPongEchoesPingTime is the online half of invariant 6. The upstream +// stamps the client's ping time; phantom must not lose it in the rewrite. +func TestProxiedPongEchoesPingTime(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{}, Opts{}) + + want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04} + pong, err := p.PingWith(corpus.PingID, want) + if err != nil { + t.Fatalf("ping: %v", err) + } + if len(pong) < 9 { + t.Fatalf("pong too short: %d bytes", len(pong)) + } + if !bytes.Equal(pong[1:9], want) { + t.Errorf("pong echoed ping time %x, want %x", pong[1:9], want) + } +} + +// deadUpstream points phantom at a closed loopback port so it detects the +// upstream as offline (ECONNREFUSED arrives promptly on loopback). +func deadUpstream(t *testing.T) *Phantom { + t.Helper() + RequirePingPort(t) + + port := freeUDPPort(t) + return Start(t, Opts{RemoteServer: fmt.Sprintf("127.0.0.1:%d", port)}) +} + +// negativeWait is the budget for confirming a pong will NOT arrive. It is much +// shorter than ReadyTimeout because the caller has already established that +// phantom is up and has detected the upstream as offline - so there is nothing +// left to wait for, and burning the full readiness budget on every negative +// case would dominate the suite's runtime. +const negativeWait = 3 * time.Second + +// waitForOfflinePong pings until phantom answers with its canned offline pong. +func waitForOfflinePong(t *testing.T, p *Phantom, packetID byte, pingTime []byte) []byte { + t.Helper() + return waitForOfflinePongWithin(t, p, packetID, pingTime, ReadyTimeout) +} + +func waitForOfflinePongWithin(t *testing.T, p *Phantom, packetID byte, pingTime []byte, budget time.Duration) []byte { + t.Helper() + + deadline := time.Now().Add(budget) + for time.Now().Before(deadline) { + pong, err := p.PingWith(packetID, pingTime) + if err == nil && strings.Contains(strings.ToLower(motdOf(t, pong)), "offline") { + return pong + } + time.Sleep(pollInterval) + } + return nil +} + +// TestOfflinePong covers the server-offline path: invariants 6, 7 and 14. +func TestOfflinePong(t *testing.T) { + t.Run("is-sent-when-upstream-is-down", func(t *testing.T) { + p := deadUpstream(t) + if waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + }) + + t.Run("echoes-ping-time", func(t *testing.T) { + p := deadUpstream(t) + want := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44} + + pong := waitForOfflinePong(t, p, corpus.PingID, want) + if pong == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + + if !bytes.Equal(pong[1:9], want) { + t.Errorf( + "offline pong carries ping time %x, want the client's %x; "+ + "the client uses this to compute displayed latency", + pong[1:9], want) + } + }) + + t.Run("carries-nonzero-guid", func(t *testing.T) { + p := deadUpstream(t) + + pong := waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) + if pong == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + + if bytes.Equal(pong[9:17], make([]byte, 8)) { + t.Errorf( + "offline pong advertises binary ServerGUID 0; every phantom whose " + + "upstream is down would claim the same RakNet identity") + } + }) + + t.Run("answers-0x02-ping", func(t *testing.T) { + p := deadUpstream(t) + + // Establish that the upstream really is detected as down, so a failure + // below is about 0x02 handling and not about timing. + if waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) == nil { + t.Fatalf("phantom never sent an offline pong for 0x01\n--- output ---\n%s", p.Output()) + } + + if waitForOfflinePongWithin(t, p, corpus.PingOpenID, DefaultPingTime(), negativeWait) == nil { + t.Errorf( + "a client pinging with 0x02 (Unconnected Ping Open Connections) " + + "got no offline pong; the offline path matches only 0x01") + } + }) +} + +// TestPayloadIntegrity is invariant 10: non-ping datagrams must survive the +// round trip through phantom byte for byte, at every size RakNet can produce. +func TestPayloadIntegrity(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + // RakNet's ceiling is MTU 1492 minus IP (20) and UDP (8) headers = 1464. + // phantom's read buffer is 1472, so 1464 and 1472 are the interesting edges. + for _, size := range []int{1, 576, 1400, 1464, 1472} { + t.Run(fmt.Sprintf("%d-bytes", size), func(t *testing.T) { + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + payload := make([]byte, size) + payload[0] = 0x84 // a RakNet data datagram, not an offline ping + for i := 1; i < size; i++ { + payload[i] = byte(i % 251) + } + + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + got, err := c.Recv(PingTimeout) + if err != nil { + t.Fatalf("no echo came back: %v", err) + } + if !bytes.Equal(got, payload) { + t.Errorf("payload corrupted: sent %d bytes, got %d back", len(payload), len(got)) + } + }) + } +} + +// TestOversizedDatagramTruncates documents, rather than condemns, what happens +// past phantom's 1472-byte read buffer. RakNet cannot produce a datagram this +// large (its own ceiling is 1464), so this is unreachable in practice - but +// pinning the behaviour means a future buffer change is a deliberate decision +// rather than an accident. +func TestOversizedDatagramTruncates(t *testing.T) { + srv, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + payload := make([]byte, 1500) + payload[0] = 0x84 + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + + if _, err := c.Recv(PingTimeout); err != nil { + t.Logf("no echo returned for an oversized datagram: %v", err) + } + + if got := srv.LastPayload(); got != nil && len(got) == len(payload) { + t.Errorf("upstream received all %d bytes; phantom's 1472-byte buffer "+ + "was expected to truncate. Buffer size may have changed.", len(got)) + } +} + +// TestConcurrentClientsAreIsolated is invariant 11: two clients sharing one +// phantom must never receive each other's traffic. +func TestConcurrentClientsAreIsolated(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + const clients = 4 + type conn struct { + c *Client + tag byte + } + + var conns []conn + for i := 0; i < clients; i++ { + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + defer c.Close() + conns = append(conns, conn{c: c, tag: byte(0x40 + i)}) + } + + // Each client sends a payload uniquely filled with its own tag. + for _, cn := range conns { + payload := bytes.Repeat([]byte{cn.tag}, 64) + payload[0] = 0x84 + if err := cn.c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + } + + for i, cn := range conns { + got, err := cn.c.Recv(PingTimeout) + if err != nil { + t.Fatalf("client %d got no echo: %v", i, err) + } + for j, b := range got[1:] { + if b != cn.tag { + t.Fatalf("client %d received another client's data at byte %d: "+ + "got 0x%02x, want 0x%02x", i, j+1, b, cn.tag) + } + } + } +} + +// TestBlackBoxRuleHolds enforces the tier boundary from DESIGN.md section 3. +// Without this, the black-box rule is a convention that decays the first time +// someone reaches for a convenient internal helper. +func TestBlackBoxRuleHolds(t *testing.T) { + forbidden := []string{ + "github.com/jhead/phantom/internal/proto", + "github.com/jhead/phantom/internal/proxy", + "github.com/jhead/phantom/internal/clientmap", + } + + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + + cmd := exec.Command("go", "list", "-tags=e2e,slow", "-deps", "./test/compat/...") + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go list: %v\n%s", err, out) + } + + deps := string(out) + for _, pkg := range forbidden { + for _, line := range strings.Split(deps, "\n") { + if strings.TrimSpace(line) == pkg { + t.Errorf("test/compat imports %s.\n"+ + "the e2e suite may know only the phantom binary, its "+ + "CLI flags, and UDP. If an assertion needs phantom's internals, it "+ + "belongs in a unit test (internal/proto or internal/proxy).", pkg) + } + } + } +} diff --git a/test/compat/fakeserver/fakeserver.go b/test/compat/fakeserver/fakeserver.go new file mode 100644 index 0000000..0b108d8 --- /dev/null +++ b/test/compat/fakeserver/fakeserver.go @@ -0,0 +1,204 @@ +// Package fakeserver is a scriptable RakNet upstream for the black-box tier. +// +// It exists because a real Minecraft server cannot be made to emit a truncated +// pong, a 30-field MOTD, or a pong from a protocol version that does not exist +// yet. This one can, byte for byte. +// +// It is a test double standing in for the REMOTE server. phantom itself is +// always a real subprocess - see test/compat/harness.go. +package fakeserver + +import ( + "fmt" + "net" + "sync" + "time" + + "github.com/jhead/phantom/internal/corpus" +) + +// Opts configures a fake upstream. +type Opts struct { + // Pong is the exact datagram to answer offline pings with. If nil, one is + // built from MOTD. + Pong []byte + + // MOTD builds a well-formed pong when Pong is nil. + MOTD string + + // EchoPayloads makes the server return any non-ping datagram verbatim, + // which is how payload-integrity and MTU cases are measured. + EchoPayloads bool + + // SilentToPings makes the server ignore pings entirely, simulating an + // upstream that is reachable but not answering. + SilentToPings bool +} + +// Server is a fake RakNet upstream listening on loopback. +type Server struct { + conn *net.UDPConn + opts Opts + + mu sync.Mutex + received [][]byte + pings int + stopped bool + + done chan struct{} + wg sync.WaitGroup +} + +// Start binds a fake upstream to an ephemeral loopback port. +func Start(opts Opts) (*Server, error) { + if opts.Pong == nil { + motd := opts.MOTD + if motd == "" { + motd = "MCPE;Fake Upstream;800;1.21.80;0;10;1234567890;Sub;Survival;1;19132;19133;0;" + } + opts.Pong = corpus.BuildPong( + make([]byte, 8), // ping time; overwritten per-request with the caller's + []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x01, 0x02}, + motd, + ) + } + + addr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return nil, err + } + + s := &Server{conn: conn, opts: opts, done: make(chan struct{})} + s.wg.Add(1) + go s.serve() + return s, nil +} + +// Addr is the host:port phantom should be pointed at with -server. +func (s *Server) Addr() string { return s.conn.LocalAddr().String() } + +func (s *Server) serve() { + defer s.wg.Done() + buf := make([]byte, 4096) + + for { + select { + case <-s.done: + return + default: + } + + _ = s.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, from, err := s.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + return + } + + data := append([]byte(nil), buf[:n]...) + + s.mu.Lock() + s.received = append(s.received, data) + stopped := s.stopped + s.mu.Unlock() + + if stopped || n == 0 { + continue + } + + switch data[0] { + case corpus.PingID, corpus.PingOpenID: + s.mu.Lock() + s.pings++ + s.mu.Unlock() + + if s.opts.SilentToPings { + continue + } + _, _ = s.conn.WriteToUDP(s.pongFor(data), from) + + default: + if s.opts.EchoPayloads { + _, _ = s.conn.WriteToUDP(data, from) + } + } + } +} + +// pongFor returns the configured pong with the caller's ping time stamped into +// it, which is what every real RakNet server does. +func (s *Server) pongFor(ping []byte) []byte { + out := append([]byte(nil), s.opts.Pong...) + if len(ping) >= 9 && len(out) >= 9 { + copy(out[1:9], ping[1:9]) + } + return out +} + +// Stop makes the server go dark without unbinding, simulating an upstream that +// has crashed or stopped responding. Resume brings it back. +func (s *Server) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopped = true +} + +// Resume undoes Stop. +func (s *Server) Resume() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopped = false +} + +// Pings is the number of offline pings received. +func (s *Server) Pings() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.pings +} + +// Received returns copies of every datagram the server has seen. +func (s *Server) Received() [][]byte { + s.mu.Lock() + defer s.mu.Unlock() + out := make([][]byte, len(s.received)) + copy(out, s.received) + return out +} + +// LastPayload returns the most recent non-ping datagram, or nil. +func (s *Server) LastPayload() []byte { + s.mu.Lock() + defer s.mu.Unlock() + for i := len(s.received) - 1; i >= 0; i-- { + d := s.received[i] + if len(d) > 0 && d[0] != corpus.PingID && d[0] != corpus.PingOpenID { + return d + } + } + return nil +} + +// Close shuts the server down. +func (s *Server) Close() error { + select { + case <-s.done: + return nil + default: + close(s.done) + } + err := s.conn.Close() + s.wg.Wait() + return err +} + +// String aids test failure messages. +func (s *Server) String() string { + return fmt.Sprintf("fakeserver(%s)", s.Addr()) +} diff --git a/test/compat/harness.go b/test/compat/harness.go new file mode 100644 index 0000000..21bc7be --- /dev/null +++ b/test/compat/harness.go @@ -0,0 +1,365 @@ +// Package compat is the end-to-end compatibility harness. +// +// BLACK-BOX RULE: this package must not import phantom's implementation +// packages (internal/proto, internal/proxy, internal/clientmap). It knows only +// the built binary, its command-line flags, and UDP. Importing internal/corpus +// is permitted: that package is fixture data, with no phantom protocol logic in +// it. TestBlackBoxRuleHolds in e2e_test.go enforces this mechanically rather +// than trusting anyone to remember. +// +// See DESIGN.md. +package compat + +import ( + "bytes" + "encoding/binary" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/jhead/phantom/internal/corpus" +) + +// Timeouts. Generous by default because CI runners are slow and UDP is lossy; +// override with PHANTOM_TEST_TIMEOUT_SCALE for very slow machines. +var ( + ReadyTimeout = 20 * time.Second + PingTimeout = 2 * time.Second + ShutdownGrace = 5 * time.Second + pollInterval = 150 * time.Millisecond + pingServerPort = 19132 +) + +func init() { + if s := os.Getenv("PHANTOM_TEST_TIMEOUT_SCALE"); s != "" { + if scale, err := strconv.ParseFloat(s, 64); err == nil && scale > 0 { + ReadyTimeout = time.Duration(float64(ReadyTimeout) * scale) + PingTimeout = time.Duration(float64(PingTimeout) * scale) + ShutdownGrace = time.Duration(float64(ShutdownGrace) * scale) + } + } +} + +var ( + buildOnce sync.Once + binPath string + buildErr error +) + +// BuildPhantom compiles the phantom binary once per test run and returns its +// path. Tests drive the real shipped artifact, not an in-process ProxyServer: +// Start() blocks, Close() panics if called before bind, and serverID is a +// package global - so "two instances must advertise different ids" is simply +// not observable in-process. +func BuildPhantom(t *testing.T) string { + t.Helper() + + buildOnce.Do(func() { + dir, err := os.MkdirTemp("", "phantom-compat-*") + if err != nil { + buildErr = err + return + } + out := filepath.Join(dir, "phantom") + + root, err := repoRoot() + if err != nil { + buildErr = err + return + } + + cmd := exec.Command("go", "build", "-o", out, "./cmd") + cmd.Dir = root + if combined, err := cmd.CombinedOutput(); err != nil { + buildErr = fmt.Errorf("building phantom: %v\n%s", err, combined) + return + } + binPath = out + }) + + if buildErr != nil { + t.Fatalf("%v", buildErr) + } + return binPath +} + +// repoRoot walks up from this file's package directory to the module root. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("could not locate go.mod above %s", dir) +} + +// RequirePingPort skips the test unless UDP :19132 is free. +// +// phantom binds :19132 unconditionally, and because it sets SO_REUSEPORT a +// second binder does not fail - the kernel load-balances datagrams between +// them, so two concurrent cases would silently steal each other's packets and +// produce baffling flakes. Skipping loudly beats that. In CI each shard runs on +// its own runner, so the port is always free there. +func RequirePingPort(t *testing.T) { + t.Helper() + + addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: pingServerPort} + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + t.Skipf("UDP :%d is in use (%v) - phantom binds it unconditionally, so this "+ + "test cannot run here. Stop any running phantom or Minecraft instance.", + pingServerPort, err) + } + _ = conn.Close() +} + +// Opts configures a phantom subprocess. +type Opts struct { + RemoteServer string // -server (required) + BindPort int // -bind_port; 0 picks a free one + IdleTimeout time.Duration // -timeout + RemovePorts bool // -remove_ports + Workers int // -workers + Debug bool // -debug +} + +// Phantom is a running phantom subprocess. +type Phantom struct { + BindPort int + Addr string + + cmd *exec.Cmd + out *lockedBuffer + t *testing.T +} + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// Start launches phantom and blocks until it answers a ping, then registers +// cleanup. Readiness is established by BEHAVIOUR - we ping until we get a pong +// - rather than by scraping logs or sleeping a fixed interval. +func Start(t *testing.T, opts Opts) *Phantom { + t.Helper() + + bin := BuildPhantom(t) + + port := opts.BindPort + if port == 0 { + port = freeUDPPort(t) + } + + // -bind_port is always passed explicitly: phantom's default of 0 makes it + // choose a random port, which the test would have no way to discover. + args := []string{ + "-server", opts.RemoteServer, + "-bind", "127.0.0.1", + "-bind_port", strconv.Itoa(port), + } + if opts.IdleTimeout > 0 { + args = append(args, "-timeout", strconv.Itoa(int(opts.IdleTimeout.Seconds()))) + } + if opts.RemovePorts { + args = append(args, "-remove_ports") + } + if opts.Workers > 0 { + args = append(args, "-workers", strconv.Itoa(opts.Workers)) + } + if opts.Debug { + args = append(args, "-debug") + } + + out := &lockedBuffer{} + cmd := exec.Command(bin, args...) + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + t.Fatalf("starting phantom: %v", err) + } + + p := &Phantom{ + BindPort: port, + Addr: fmt.Sprintf("127.0.0.1:%d", port), + cmd: cmd, + out: out, + t: t, + } + t.Cleanup(p.Close) + + if err := p.waitReady(); err != nil { + t.Fatalf("phantom did not become ready: %v\n--- phantom output ---\n%s", err, out.String()) + } + return p +} + +// waitReady pings until phantom proxies a pong back. +func (p *Phantom) waitReady() error { + deadline := time.Now().Add(ReadyTimeout) + var lastErr error + + for time.Now().Before(deadline) { + if p.cmd.ProcessState != nil && p.cmd.ProcessState.Exited() { + return fmt.Errorf("phantom exited early with %v", p.cmd.ProcessState) + } + if _, err := p.Ping(); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(pollInterval) + } + return fmt.Errorf("no pong within %v (last error: %v)", ReadyTimeout, lastErr) +} + +// Ping sends an Unconnected Ping to phantom and returns the pong. +func (p *Phantom) Ping() ([]byte, error) { + return p.PingWith(corpus.PingID, DefaultPingTime()) +} + +// PingWith sends a ping with a specific packet id and ping time, which is how +// the 0x02 and ping-time-echo invariants are exercised. +func (p *Phantom) PingWith(packetID byte, pingTime []byte) ([]byte, error) { + c, err := NewClient(p.Addr) + if err != nil { + return nil, err + } + defer c.Close() + return c.Ping(packetID, pingTime) +} + +// Output returns everything phantom has logged so far. +func (p *Phantom) Output() string { return p.out.String() } + +// Close terminates phantom, escalating to SIGKILL if it will not exit. +func (p *Phantom) Close() { + if p.cmd.Process == nil { + return + } + _ = p.cmd.Process.Signal(os.Interrupt) + + done := make(chan struct{}) + go func() { + _ = p.cmd.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(ShutdownGrace): + _ = p.cmd.Process.Kill() + <-done + } +} + +// Client is a minimal RakNet-speaking UDP client. +type Client struct { + conn *net.UDPConn +} + +// NewClient dials a UDP socket at addr. +func NewClient(addr string) (*Client, error) { + raddr, err := net.ResolveUDPAddr("udp4", addr) + if err != nil { + return nil, err + } + conn, err := net.DialUDP("udp4", nil, raddr) + if err != nil { + return nil, err + } + return &Client{conn: conn}, nil +} + +// DefaultPingTime is an arbitrary fixed ping time; servers must echo it back. +func DefaultPingTime() []byte { + return []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77} +} + +// BuildPing assembles an Unconnected Ping datagram. +func BuildPing(packetID byte, pingTime []byte) []byte { + out := make([]byte, 0, 33) + out = append(out, packetID) + out = append(out, pingTime...) + out = append(out, corpus.Magic...) + guid := make([]byte, 8) + binary.BigEndian.PutUint64(guid, 0x8877665544332211) + return append(out, guid...) +} + +// Ping sends an offline ping and waits for a reply. +func (c *Client) Ping(packetID byte, pingTime []byte) ([]byte, error) { + if err := c.Send(BuildPing(packetID, pingTime)); err != nil { + return nil, err + } + return c.Recv(PingTimeout) +} + +// Send writes a datagram. +func (c *Client) Send(data []byte) error { + _, err := c.conn.Write(data) + return err +} + +// Recv waits for a datagram. +func (c *Client) Recv(timeout time.Duration) ([]byte, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return nil, err + } + buf := make([]byte, 4096) + n, err := c.conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// LocalAddr is the client's source address. +func (c *Client) LocalAddr() net.Addr { return c.conn.LocalAddr() } + +// Close releases the socket. +func (c *Client) Close() error { return c.conn.Close() } + +// freeUDPPort asks the kernel for an unused UDP port. +func freeUDPPort(t *testing.T) int { + t.Helper() + + addr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("resolving: %v", err) + } + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + t.Fatalf("finding a free port: %v", err) + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).Port +} diff --git a/test/compat/matrix.json b/test/compat/matrix.json new file mode 100644 index 0000000..adcb8e2 --- /dev/null +++ b/test/compat/matrix.json @@ -0,0 +1,31 @@ +{ + "_comment": [ + "Which Minecraft versions the end-to-end tests exercise, and how they are", + "distributed across CI shards.", + "", + "This is NOT the full version list. The unit tests sweep all 51 supported", + "versions from internal/corpus/data/captured.json in under a second. Booting a", + "real client and server stack per version is far too slow for that, so the e2e", + "tests sample: the oldest supported version, the newest, and the newest of each minor line in between.", + "", + "Sharding exists for isolation, not just speed. phantom binds UDP :19132", + "unconditionally and sets SO_REUSEPORT, so two concurrent instances on one host", + "load-balance each other's datagrams instead of failing cleanly. One shard per", + "CI runner gives each its own network stack. Within a shard, cases run serially.", + "", + "Adding a version: add it here with a shard, and keep the shards balanced.", + "The nightly matrix-drift job flags versions bedrock-protocol supports that", + "this file does not." + ], + "shards": 4, + "versions": [ + { "mc": "1.16.201", "protocol": 422, "raknet": 10, "shard": 0, "note": "oldest supported by bedrock-protocol" }, + { "mc": "1.16.220", "protocol": 431, "raknet": 10, "shard": 0 }, + { "mc": "1.17.40", "protocol": 471, "raknet": 10, "shard": 1 }, + { "mc": "1.18.30", "protocol": 503, "raknet": 10, "shard": 1 }, + { "mc": "1.19.80", "protocol": 582, "raknet": 11, "shard": 2 }, + { "mc": "1.20.80", "protocol": 671, "raknet": 11, "shard": 2 }, + { "mc": "1.21.130", "protocol": 898, "raknet": 11, "shard": 3 }, + { "mc": "1.26.30", "protocol": 1001, "raknet": 11, "shard": 3, "note": "newest supported" } + ] +} diff --git a/test/compat/node/connect.js b/test/compat/node/connect.js new file mode 100644 index 0000000..e2c5a03 --- /dev/null +++ b/test/compat/node/connect.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// +// Drives a real bedrock-protocol client through phantom and reports how far the +// session got. Success is defined as reaching 'join', which means the RakNet +// handshake (OpenConnectionRequest 1/2, MTU negotiation) AND the login sequence +// both completed - the entire opaque path phantom relays without parsing, and +// all of which break if it mangles, truncates or reorders datagrams. +// +// Deliberately NOT waiting for 'spawn': a client only spawns once the server +// sends start_game and chunk data, which bedrock-protocol's createServer does +// not do on its own. Implementing a mini game server would test game-layer +// semantics that phantom is explicitly opaque to (see DESIGN.md non-goals), +// while adding a large, version-fragile surface that could not distinguish a +// phantom bug from our own incomplete server. +// +// Usage: node connect.js --host 127.0.0.1 --port --version +// Output: {"ok":true,"joined":true} or {"ok":false,"error":"...","stage":"..."} + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const host = arg('host', '127.0.0.1') +const port = parseInt(arg('port', '19132'), 10) +const version = arg('version', '1.21.0') +const timeoutMs = parseInt(arg('timeout', '30000'), 10) + +let stage = 'connecting' +let done = false + +function finish (obj, code) { + if (done) return + done = true + process.stdout.write(JSON.stringify(obj) + '\n') + process.exit(code) +} + +const timer = setTimeout( + () => finish({ ok: false, error: 'timed out', stage }, 1), + timeoutMs +) + +let client +try { + client = bp.createClient({ + host, + port, + version, + offline: true, + username: 'CompatBot', + skipPing: true + }) +} catch (err) { + clearTimeout(timer) + finish({ ok: false, error: err.message, stage: 'createClient' }, 1) +} + +// Track progress so a timeout reports HOW FAR the session got - "timed out at +// join" and "timed out at connect" point at very different bugs. +for (const ev of ['connect', 'login', 'status']) { + client.on(ev, () => { stage = ev }) +} + +client.on('join', () => { + stage = 'join' + clearTimeout(timer) + try { client.close() } catch (_) {} + finish({ ok: true, joined: true, stage, version }, 0) +}) + +client.on('error', err => { + clearTimeout(timer) + finish({ ok: false, error: String(err && err.message || err), stage }, 1) +}) + +client.on('kick', reason => { + clearTimeout(timer) + finish({ ok: false, error: 'kicked: ' + JSON.stringify(reason), stage }, 1) +}) diff --git a/test/compat/node/package-lock.json b/test/compat/node/package-lock.json new file mode 100644 index 0000000..4a76429 --- /dev/null +++ b/test/compat/node/package-lock.json @@ -0,0 +1,1565 @@ +{ + "name": "phantom-compat-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phantom-compat-node", + "version": "1.0.0", + "dependencies": { + "bedrock-protocol": "3.57.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@jsprismarine/jsbinaryutils": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@jsprismarine/jsbinaryutils/-/jsbinaryutils-2.1.4.tgz", + "integrity": "sha512-LFMbAJsHEiq5UJwPzrHYZbCkZ/LZEhV76SYXPDjcQLosqVwcf0yyyr+ICxT4j/d4xPH7vDSvWCqOuFRsSv9wYw==", + "license": "ISC" + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", + "integrity": "sha512-Zfw6bteqM9gQXZ1BIWOgM8xEwMrUGoyL8nW13+O+OOgNX3YhuDN1GDgg1NzdTlmm3j+9sHy7uBZ12r+z9lXnZQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.0 || ^1.1.13" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bedrock-protocol": { + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/bedrock-protocol/-/bedrock-protocol-3.57.0.tgz", + "integrity": "sha512-+4YyRvDD4Wf5g8ZInxjJRoVySDJBQKy0atHBGcQqmrwPJ4DQWrlj3DavQTt78cw3iPZMTQ3ZgKvWMGfnv+jilg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "jsonwebtoken": "^9.0.0", + "jsp-raknet": "^2.1.3", + "minecraft-data": "^3.0.0", + "minecraft-folder-path": "^1.2.0", + "prismarine-auth": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "prismarine-realms": "^1.6.0", + "protodef": "^1.14.0", + "raknet-native": "^1.0.3", + "uuid-1345": "^1.0.2" + }, + "optionalDependencies": { + "raknet-node": "^0.5.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g==", + "license": "MIT" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/cmake-js": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.2.tgz", + "integrity": "sha512-7MfiQ/ijzeE2kO+WFB9bv4QP5Dn2yVaAP2acFJr4NIFy2hT4w6O4EpOTLNcohR5IPX7M4wNf/5taIqMj7UA9ug==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "bluebird": "^3", + "debug": "^4", + "fs-extra": "^5.0.0", + "is-iojs": "^1.0.1", + "lodash": "^4", + "memory-stream": "0", + "npmlog": "^1.2.0", + "rc": "^1.2.7", + "semver": "^5.0.3", + "splitargs": "0", + "tar": "^4", + "unzipper": "^0.8.13", + "url-join": "0", + "which": "^1.0.9", + "yargs": "^3.6.0" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "node_modules/fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", + "dependencies": { + "minipass": "^2.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha512-fVbU2wRE91yDvKUnrIaQlHKAWKY5e08PmztCrwuH5YVQ+Z/p3d0ny2T48o6uvAAXHIUnfaQdHkmxYbQft1eHVA==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iojs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", + "integrity": "sha512-tLn1j3wYSL6DkvEI+V/j0pKohpa5jk+ER74v6S4SgCXnjS0WA+DoZbwZBrrhgwksMvtuwndyGeG5F8YMsoBzSA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsp-raknet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jsp-raknet/-/jsp-raknet-2.2.0.tgz", + "integrity": "sha512-Ji2wZmI84abcgzSnnbdtMSW02yZ/Ui3JI3N69BsyTcWHWsbeWMRHKpzN72cCQM8W9pvTIfLYml9sS9xaC2MJSA==", + "license": "ISC", + "dependencies": { + "@jsprismarine/jsbinaryutils": "2.1.4", + "debug": "^4.3.1", + "typescript": "^4.2.2" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", + "license": "MIT", + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==", + "license": "MIT" + }, + "node_modules/lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", + "license": "MIT" + }, + "node_modules/lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/memory-stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", + "integrity": "sha512-q0D3m846qY6ZkIt+19ZemU5vH56lpOZZwoJc3AICARKh/menBuayQUjAGPrqtHQQMUYERSdOrej92J9kz7LgYA==", + "license": "MMIT", + "dependencies": { + "readable-stream": "~1.0.26-2" + } + }, + "node_modules/memory-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/memory-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/memory-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", + "dependencies": { + "minipass": "^2.9.0" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npmlog": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", + "integrity": "sha512-1J5KqSRvESP6XbjPaXt2H6qDzgizLTM7x0y1cXIjP2PpvdCqyNC7TO3cPRKsuYlElbi/DwkzRRdG2zpmE0IktQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "~0.3.0", + "are-we-there-yet": "~1.0.0", + "gauge": "~1.2.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", + "license": "MIT", + "dependencies": { + "lcid": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raknet-native": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/raknet-native/-/raknet-native-1.2.3.tgz", + "integrity": "sha512-iXqQc/2298LJHQAyoYDdreZ9E4z+4d1y8lxp+EKno1r/ooIer83ShayCDsjpjwRHc9RiMdJz6fLbgBf2hLeTzw==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "cmake-js": "^6.1.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/raknet-node": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/raknet-node/-/raknet-node-0.5.0.tgz", + "integrity": "sha512-xvWsBwUu/UIlsMDN1sm5FX7iCIufuLakoCtlBer4+B4XeWLQFkEiRmwSaon0pKpBc8HKZcyU3S2KjvzYkGi0WA==", + "license": "ISC", + "optional": true, + "dependencies": { + "sha1": "^1.1.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/splitargs": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", + "integrity": "sha512-UUFYD2oWbNwULH6WoVtLUOw8ch586B+HUqcsAjjjeoBQAM1bD4wZRXu01koaxyd8UeYpybWqW4h+lO1Okv40Tg==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + }, + "engines": { + "node": ">=4.5" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", + "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "~1.0.10", + "listenercount": "~1.0.1", + "readable-stream": "~2.1.5", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha512-NkXT2AER7VKXeXtJNSaWLpWIhmtSE3K2PguaLEeWr4JILghcIKqoLt1A3wHrnpDC5+ekf8gfk1GKWkFXe4odMw==", + "license": "MIT", + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==", + "license": "MIT", + "bin": { + "window-size": "cli.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + } + } +} diff --git a/test/compat/node/package.json b/test/compat/node/package.json new file mode 100644 index 0000000..f7f2a7c --- /dev/null +++ b/test/compat/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "phantom-compat-node", + "private": true, + "version": "1.0.0", + "description": "Thin bedrock-protocol CLIs driven by the Go compatibility harness", + "dependencies": { + "bedrock-protocol": "3.57.0" + } +} diff --git a/test/compat/node/ping.js b/test/compat/node/ping.js new file mode 100644 index 0000000..e7b0c84 --- /dev/null +++ b/test/compat/node/ping.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// +// Pings a host with bedrock-protocol's OWN advertisement parser and prints the +// parsed result as JSON. +// +// This is the point of involving Node at all: phantom's rewritten pong gets +// judged by a genuine third-party client parser rather than by phantom's own +// (buggy) one, so a malformed rewrite fails loudly instead of round-tripping +// through the same mistake twice. +// +// Usage: node ping.js --host 127.0.0.1 --port +// Output: {"ok":true,"advertisement":{...}} or {"ok":false,"error":"..."} + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const host = arg('host', '127.0.0.1') +const port = parseInt(arg('port', '19132'), 10) +const timeoutMs = parseInt(arg('timeout', '5000'), 10) + +const timer = setTimeout(() => { + process.stdout.write(JSON.stringify({ ok: false, error: 'ping timed out' }) + '\n') + process.exit(1) +}, timeoutMs) + +bp.ping({ host, port }) + .then(res => { + clearTimeout(timer) + process.stdout.write(JSON.stringify({ ok: true, advertisement: res }) + '\n') + process.exit(0) + }) + .catch(err => { + clearTimeout(timer) + process.stdout.write(JSON.stringify({ + ok: false, + error: String(err && err.message || err) + }) + '\n') + process.exit(1) + }) diff --git a/test/compat/node/serve.js b/test/compat/node/serve.js new file mode 100644 index 0000000..2854ee7 --- /dev/null +++ b/test/compat/node/serve.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// +// Boots a real bedrock-protocol server and holds it open until killed. +// +// Prints one line of JSON to stdout when listening: {"ready":true,"port":N} +// The Go harness waits for that line, then points phantom at this port. +// +// Usage: node serve.js --port --version + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const port = parseInt(arg('port', '19132'), 10) +const version = arg('version', '1.21.0') + +function emit (obj) { + process.stdout.write(JSON.stringify(obj) + '\n') +} + +let server +try { + server = bp.createServer({ + host: '127.0.0.1', + port, + version, + offline: true, + motd: { motd: 'Compat Upstream', levelName: 'compat' } + }) +} catch (err) { + emit({ ready: false, error: err.message }) + process.exit(1) +} + +server.on('error', err => { + emit({ ready: false, error: String(err && err.message || err) }) +}) + +// bedrock-protocol has no single reliable "listening" event across every +// version it supports, so settle briefly and then declare readiness. The Go +// side does not trust this anyway - it polls with a real ping until it gets a +// pong, which is the only readiness signal that actually means anything. +setTimeout(() => emit({ ready: true, port, version }), 1000) + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => { + try { server.close() } catch (_) {} + process.exit(0) + }) +} diff --git a/test/compat/node_test.go b/test/compat/node_test.go new file mode 100644 index 0000000..5cce221 --- /dev/null +++ b/test/compat/node_test.go @@ -0,0 +1,329 @@ +//go:build e2e && node + +package compat + +// End-to-end tests driving a real client stack through phantom. +// +// The fake upstream in fakeserver/ gives byte-exact control but is, by +// construction, only as correct as our own understanding of RakNet. These tests +// close that gap by putting a genuine third-party implementation on both ends: +// bedrock-protocol serves, and bedrock-protocol pings and connects. Its parser +// judges phantom's rewritten pong, so a malformed rewrite fails loudly instead +// of round-tripping through the same misunderstanding twice. +// +// Run with: go test -tags='e2e node' ./test/compat/... +// +// The `node` tag is separate from `e2e` so the rest of the suite runs on machines with +// no Node toolchain. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// Matrix mirrors matrix.json. +type Matrix struct { + Shards int `json:"shards"` + Versions []MatrixVersion `json:"versions"` +} + +// MatrixVersion is one Minecraft version the e2e suite exercises. +type MatrixVersion struct { + MC string `json:"mc"` + Protocol int `json:"protocol"` + RakNet int `json:"raknet"` + Shard int `json:"shard"` + Note string `json:"note"` +} + +// LoadMatrix reads the committed e2e version matrix, honouring PHANTOM_SHARD so +// CI can split it across runners. +func LoadMatrix(t *testing.T) Matrix { + t.Helper() + + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + raw, err := os.ReadFile(filepath.Join(root, "test", "compat", "matrix.json")) + if err != nil { + t.Fatalf("reading matrix.json: %v", err) + } + + var m Matrix + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("parsing matrix.json: %v", err) + } + + if s := os.Getenv("PHANTOM_SHARD"); s != "" { + shard, err := strconv.Atoi(s) + if err != nil { + t.Fatalf("PHANTOM_SHARD=%q is not a number", s) + } + var kept []MatrixVersion + for _, v := range m.Versions { + if v.Shard == shard { + kept = append(kept, v) + } + } + m.Versions = kept + t.Logf("shard %d: %d version(s)", shard, len(kept)) + } + + if len(m.Versions) == 0 { + t.Skip("no versions selected for this shard") + } + return m +} + +// nodeDir is test/compat/node. +func nodeDir(t *testing.T) string { + t.Helper() + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + return filepath.Join(root, "test", "compat", "node") +} + +// requireNode skips unless the Node toolchain and installed deps are present. +func requireNode(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + if _, err := os.Stat(filepath.Join(nodeDir(t), "node_modules")); err != nil { + t.Skipf("test/compat/node/node_modules is missing - run `npm ci` in %s", nodeDir(t)) + } +} + +// bedrockServer is a bedrock-protocol server subprocess. +type bedrockServer struct { + Port int + Addr string + cmd *exec.Cmd + out *lockedBuffer +} + +// startBedrockServer boots a real server for the given version. Readiness is +// confirmed by pinging it until it answers, not by trusting its ready line. +func startBedrockServer(t *testing.T, version string) *bedrockServer { + t.Helper() + + port := freeUDPPort(t) + out := &lockedBuffer{} + + cmd := exec.Command("node", "serve.js", + "--port", strconv.Itoa(port), + "--version", version) + cmd.Dir = nodeDir(t) + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + t.Fatalf("starting bedrock server: %v", err) + } + + s := &bedrockServer{ + Port: port, + Addr: fmt.Sprintf("127.0.0.1:%d", port), + cmd: cmd, + out: out, + } + t.Cleanup(s.Close) + + deadline := time.Now().Add(ReadyTimeout) + for time.Now().Before(deadline) { + c, err := NewClient(s.Addr) + if err == nil { + _, perr := c.Ping(0x01, DefaultPingTime()) + _ = c.Close() + if perr == nil { + return s + } + } + time.Sleep(pollInterval) + } + + t.Fatalf("bedrock-protocol server (%s) never answered a ping\n--- output ---\n%s", + version, out.String()) + return nil +} + +func (s *bedrockServer) Close() { + if s.cmd.Process == nil { + return + } + _ = s.cmd.Process.Kill() + _ = s.cmd.Wait() +} + +// pingResult is ping.js's JSON output. +type pingResult struct { + OK bool `json:"ok"` + Error string `json:"error"` + Ad struct { + MOTD string `json:"motd"` + LevelName string `json:"levelName"` + PlayersOnline int `json:"playersOnline"` + PlayersMax int `json:"playersMax"` + Gamemode string `json:"gamemode"` + ServerID string `json:"serverId"` + PortV4 int `json:"portV4"` + PortV6 int `json:"portV6"` + Protocol interface{} `json:"protocol"` + Version string `json:"version"` + } `json:"advertisement"` +} + +// connectResult is connect.js's JSON output. +type connectResult struct { + OK bool `json:"ok"` + Joined bool `json:"joined"` + Stage string `json:"stage"` + Error string `json:"error"` + Version string `json:"version"` +} + +// runNode executes one of the thin Node CLIs and decodes its JSON line. +func runNode(t *testing.T, timeout time.Duration, target interface{}, args ...string) error { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "node", args...) + cmd.Dir = nodeDir(t) + + out, err := cmd.Output() + line := lastJSONLine(string(out)) + if line == "" { + return fmt.Errorf("no JSON from `node %s`: %v", strings.Join(args, " "), err) + } + if jerr := json.Unmarshal([]byte(line), target); jerr != nil { + return fmt.Errorf("undecodable output %q: %v", line, jerr) + } + return nil +} + +// lastJSONLine picks the final JSON object from stdout, ignoring any noise the +// native RakNet addon prints. +func lastJSONLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + l := strings.TrimSpace(lines[i]) + if strings.HasPrefix(l, "{") && strings.HasSuffix(l, "}") { + return l + } + } + return "" +} + +// TestRealClientPingsThroughPhantom is the core cross-version assertion: for +// every version in the matrix, a real bedrock-protocol client must be able to +// PARSE the pong phantom rewrote, and must see the upstream's identity with +// phantom's port substituted. +func TestRealClientPingsThroughPhantom(t *testing.T) { + requireNode(t) + RequirePingPort(t) + + m := LoadMatrix(t) + + for _, v := range m.Versions { + t.Run(v.MC, func(t *testing.T) { + srv := startBedrockServer(t, v.MC) + p := Start(t, Opts{RemoteServer: srv.Addr}) + + var res pingResult + if err := runNode(t, 30*time.Second, &res, + "ping.js", "--host", "127.0.0.1", "--port", strconv.Itoa(p.BindPort)); err != nil { + t.Fatalf("ping.js: %v", err) + } + + if !res.OK { + t.Fatalf("a real client could not parse phantom's pong: %s", res.Error) + } + + // The advertised version must be the upstream's, untouched. + if res.Ad.Version != v.MC { + t.Errorf("client saw version %q, want the upstream's %q", + res.Ad.Version, v.MC) + } + if got := fmt.Sprintf("%v", res.Ad.Protocol); got != strconv.Itoa(v.Protocol) { + t.Errorf("client saw protocol %q, want %d", got, v.Protocol) + } + + // The advertised port must be phantom's, not the upstream's - that + // substitution is the entire reason phantom rewrites the pong. + if res.Ad.PortV4 != p.BindPort { + t.Errorf("client saw port %d, want phantom's bind port %d "+ + "(upstream is on %d)", res.Ad.PortV4, p.BindPort, srv.Port) + } + if res.Ad.ServerID == "" { + t.Error("client saw an empty server id") + } + }) + } +} + +// TestRealClientSessionThroughPhantom drives a full RakNet handshake and login +// through phantom - the opaque path phantom relays without understanding. +// +// It first opens a CONTROL connection straight to the upstream. That baseline +// is what makes the result trustworthy: if the direct connection fails, the +// client stack cannot run here at all and the test skips; only if direct +// succeeds and the proxied one fails is phantom actually at fault. Without the +// control, a broken RakNet backend would look exactly like a phantom bug. +func TestRealClientSessionThroughPhantom(t *testing.T) { + requireNode(t) + RequirePingPort(t) + + m := LoadMatrix(t) + + for _, v := range m.Versions { + t.Run(v.MC, func(t *testing.T) { + srv := startBedrockServer(t, v.MC) + + // Control: straight to the upstream, phantom not involved. + var direct connectResult + if err := runNode(t, 60*time.Second, &direct, + "connect.js", "--host", "127.0.0.1", + "--port", strconv.Itoa(srv.Port), "--version", v.MC); err != nil { + t.Skipf("control connection could not run (%v); "+ + "the bedrock-protocol client stack is unusable in this "+ + "environment, so a proxied failure would be unattributable", err) + } + if !direct.OK { + t.Skipf("control connection FAILED without phantom in the path "+ + "(stage %q: %s).\nThis is an environment or bedrock-protocol "+ + "problem, not a phantom one - skipping rather than reporting a "+ + "false phantom failure.", direct.Stage, direct.Error) + } + + // The control worked, so anything that fails now is phantom's doing. + p := Start(t, Opts{RemoteServer: srv.Addr}) + + var proxied connectResult + if err := runNode(t, 60*time.Second, &proxied, + "connect.js", "--host", "127.0.0.1", + "--port", strconv.Itoa(p.BindPort), "--version", v.MC); err != nil { + t.Fatalf("connect.js through phantom: %v", err) + } + + if !proxied.OK { + t.Errorf("a real client reached %q directly but only %q through phantom: %s\n"+ + "--- phantom output ---\n%s", + direct.Stage, proxied.Stage, proxied.Error, p.Output()) + } + }) + } +} diff --git a/test/compat/slow_test.go b/test/compat/slow_test.go new file mode 100644 index 0000000..0c7fcbe --- /dev/null +++ b/test/compat/slow_test.go @@ -0,0 +1,100 @@ +//go:build e2e && slow + +package compat + +// Slow end-to-end cases. +// +// These are behind an extra `slow` tag because they wait on phantom's real +// timers: the idle sweep runs every 5s and cannot be shortened from outside the +// process. CI includes them; `make test` does not. +// +// Run with: go test -tags='e2e slow' ./test/compat/... + +import ( + "strings" + "testing" + "time" + + "github.com/jhead/phantom/test/compat/fakeserver" +) + +// TestIdleClientIsReaped is invariant 12. phantom sweeps for idle clients every +// 5 seconds, so with -timeout 1 a silent client should be dropped within ~6s. +func TestIdleClientIsReaped(t *testing.T) { + srv, p := startPair(t, + fakeserver.Opts{EchoPayloads: true}, + Opts{IdleTimeout: time.Second}, + ) + + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + // Establish a session. + payload := []byte{0x84, 0x01, 0x02, 0x03} + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + if _, err := c.Recv(PingTimeout); err != nil { + t.Fatalf("session was never established: %v", err) + } + + before := len(srv.Received()) + + // Go quiet for longer than the idle timeout plus one sweep interval. + time.Sleep(8 * time.Second) + + // The connection should have been reaped. Sending again must still work - + // phantom is expected to build a fresh upstream connection, not to wedge. + if err := c.Send(payload); err != nil { + t.Fatalf("send after idle: %v", err) + } + if _, err := c.Recv(PingTimeout); err != nil { + t.Fatalf("phantom stopped proxying after reaping an idle client: %v\n"+ + "--- phantom output ---\n%s", err, p.Output()) + } + + if len(srv.Received()) <= before { + t.Error("upstream saw no new traffic after the idle period") + } +} + +// TestUpstreamRecovery is invariant 13: phantom must notice the upstream coming +// back and resume proxying without a restart. +func TestUpstreamRecovery(t *testing.T) { + const motd = "MCPE;Recovery;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + srv, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + // 1. Healthy: the upstream's MOTD reaches the client. + pong, err := p.Ping() + if err != nil { + t.Fatalf("initial ping: %v", err) + } + if got := motdOf(t, pong); !strings.Contains(got, "Recovery") { + t.Fatalf("expected the upstream MOTD, got %q", got) + } + + // 2. Upstream goes dark: phantom should fall back to its offline pong. + srv.Stop() + if waitForOfflinePong(t, p, 0x01, DefaultPingTime()) == nil { + t.Fatalf("phantom never fell back to an offline pong\n--- output ---\n%s", p.Output()) + } + + // 3. Upstream returns: phantom must resume proxying the real MOTD. + srv.Resume() + + deadline := time.Now().Add(ReadyTimeout) + for time.Now().Before(deadline) { + pong, err := p.Ping() + if err == nil && strings.Contains(motdOf(t, pong), "Recovery") { + return + } + time.Sleep(pollInterval) + } + + t.Fatalf("phantom did not resume proxying after the upstream returned\n"+ + "--- output ---\n%s", p.Output()) +}