naldo commited on
Commit
d9bfd8a
·
1 Parent(s): 75d8c49

Bundle portable Temurin Java for ZeroGPU

Browse files
Files changed (2) hide show
  1. minecraft_stack.py +482 -459
  2. packages.txt +0 -1
minecraft_stack.py CHANGED
@@ -1,459 +1,482 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import json
5
- import os
6
- import re
7
- import shutil
8
- import socket
9
- import subprocess
10
- import tarfile
11
- import threading
12
- import time
13
- import urllib.request
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- import yaml
18
-
19
- BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
20
- JAVA_DIR = BASE / "java"
21
- VIA_DIR = BASE / "viaproxy"
22
- RELAY_DIR = BASE / "relay"
23
- TOOLS_DIR = BASE / "tools"
24
- BIN_DIR = BASE / "bin"
25
- LOG_PATH = BASE / "stack.log"
26
-
27
- FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
28
- FABRIC_MC_VERSION = "1.19"
29
- FABRIC_LOADER_VERSION = "0.14.18"
30
-
31
- VIAPROXY_URL = "https://github.com/ViaVersion/ViaProxy/releases/download/v3.4.13/ViaProxy-3.4.13.jar"
32
- VIAPROXY_SHA256 = "4bbb6a6b9d3dd6a2028773ed44b97a98751e4a48e416aaad4bf6d9c860083be9"
33
-
34
- GEYSER_URL = "https://download.geysermc.org/v2/projects/geyser/versions/2.11.3/builds/1247/downloads/viaproxy"
35
- GEYSER_SHA256 = "85eea53e7fe58e4d97c2144a9d061577d37f619ab635227b68a948aa62d22924"
36
-
37
- GO_URL = "https://go.dev/dl/go1.25.14.linux-amd64.tar.gz"
38
- GO_SHA256 = "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712"
39
-
40
- BEDROCK_PROTOCOL = int(os.environ.get("BEDROCK_PROTOCOL", "2193"))
41
- BEDROCK_VERSION = os.environ.get("BEDROCK_VERSION", "1.26.51")
42
- WORLD_NAME = os.environ.get("MINECRAFT_WORLD_NAME", "Voyager AI World")
43
- HOST_NAME = os.environ.get("MINECRAFT_HOST_NAME", "Voyager AI")
44
-
45
-
46
- def _mkdirs() -> None:
47
- for path in (BASE, JAVA_DIR, VIA_DIR, RELAY_DIR, TOOLS_DIR, BIN_DIR):
48
- path.mkdir(parents=True, exist_ok=True)
49
-
50
-
51
- def _log(message: str) -> None:
52
- _mkdirs()
53
- line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
54
- print(line, flush=True)
55
- with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
56
- fh.write(line + "\n")
57
-
58
-
59
- def _sha256(path: Path) -> str:
60
- h = hashlib.sha256()
61
- with path.open("rb") as fh:
62
- for chunk in iter(lambda: fh.read(1024 * 1024), b""):
63
- h.update(chunk)
64
- return h.hexdigest()
65
-
66
-
67
- def _download(url: str, path: Path, sha256: str | None = None) -> Path:
68
- path.parent.mkdir(parents=True, exist_ok=True)
69
- if path.exists() and (not sha256 or _sha256(path) == sha256):
70
- return path
71
- tmp = path.with_suffix(path.suffix + ".part")
72
- tmp.unlink(missing_ok=True)
73
- req = urllib.request.Request(url, headers={"User-Agent": "minecraft-friends-ai-zerogpu/1.0"})
74
- _log(f"download {url}")
75
- with urllib.request.urlopen(req, timeout=120) as response, tmp.open("wb") as out:
76
- shutil.copyfileobj(response, out)
77
- if sha256 and _sha256(tmp) != sha256:
78
- tmp.unlink(missing_ok=True)
79
- raise RuntimeError(f"SHA256 mismatch for {url}")
80
- tmp.replace(path)
81
- return path
82
-
83
-
84
- def _wait_tcp(host: str, port: int, timeout: float) -> bool:
85
- deadline = time.time() + timeout
86
- while time.time() < deadline:
87
- try:
88
- with socket.create_connection((host, port), timeout=1):
89
- return True
90
- except OSError:
91
- time.sleep(1)
92
- return False
93
-
94
-
95
- def _run_capture(args: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> str:
96
- _log("exec " + " ".join(args))
97
- proc = subprocess.run(
98
- args,
99
- cwd=str(cwd),
100
- env=env,
101
- text=True,
102
- stdout=subprocess.PIPE,
103
- stderr=subprocess.STDOUT,
104
- timeout=timeout,
105
- check=False,
106
- )
107
- output = proc.stdout or ""
108
- for line in output.splitlines()[-80:]:
109
- _log("setup> " + line)
110
- if proc.returncode != 0:
111
- raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}")
112
- return output
113
-
114
-
115
- def _patch_scalar(text: str, key: str, value: str) -> str:
116
- pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
117
- if re.search(pattern, text):
118
- return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
119
- return text + f"\n{key}: {value}\n"
120
-
121
-
122
- class MinecraftStack:
123
- def __init__(self) -> None:
124
- self.lock = threading.RLock()
125
- self.worker: threading.Thread | None = None
126
- self.processes: dict[str, subprocess.Popen[str]] = {}
127
- self.state = "stopped"
128
- self.error = ""
129
- self.started_at: float | None = None
130
-
131
- def _pump(self, name: str, proc: subprocess.Popen[str]) -> None:
132
- assert proc.stdout is not None
133
- try:
134
- for line in proc.stdout:
135
- _log(f"{name}> {line.rstrip()}")
136
- except Exception as exc:
137
- _log(f"{name} log pump error: {exc!r}")
138
-
139
- def _spawn(self, name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen[str]:
140
- _log(f"start {name}: {' '.join(args)}")
141
- proc = subprocess.Popen(
142
- args,
143
- cwd=str(cwd),
144
- env=env,
145
- stdin=subprocess.PIPE,
146
- stdout=subprocess.PIPE,
147
- stderr=subprocess.STDOUT,
148
- text=True,
149
- bufsize=1,
150
- )
151
- self.processes[name] = proc
152
- threading.Thread(target=self._pump, args=(name, proc), daemon=True, name=f"log-{name}").start()
153
- return proc
154
-
155
- def _stop_process(self, name: str) -> None:
156
- proc = self.processes.get(name)
157
- if not proc or proc.poll() is not None:
158
- return
159
- try:
160
- if name == "minecraft" and proc.stdin:
161
- proc.stdin.write("stop\n")
162
- proc.stdin.flush()
163
- proc.wait(timeout=20)
164
- return
165
- except Exception:
166
- pass
167
- try:
168
- proc.terminate()
169
- proc.wait(timeout=12)
170
- except Exception:
171
- try:
172
- proc.kill()
173
- except Exception:
174
- pass
175
-
176
- def _ensure_fabric(self) -> Path:
177
- installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
178
- JAVA_DIR.mkdir(parents=True, exist_ok=True)
179
- launcher = JAVA_DIR / "fabric-server-launch.jar"
180
- if not launcher.exists():
181
- _run_capture(
182
- [
183
- "java", "-jar", str(installer), "server",
184
- "-mcversion", FABRIC_MC_VERSION,
185
- "-loader", FABRIC_LOADER_VERSION,
186
- "-downloadMinecraft",
187
- "-dir", str(JAVA_DIR),
188
- ],
189
- JAVA_DIR,
190
- timeout=300,
191
- )
192
- (JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
193
- props = {
194
- "server-ip": "127.0.0.1",
195
- "server-port": "25565",
196
- "online-mode": "false",
197
- "enforce-secure-profile": "false",
198
- "enable-command-block": "true",
199
- "spawn-protection": "0",
200
- "allow-flight": "true",
201
- "gamemode": "survival",
202
- "force-gamemode": "true",
203
- "difficulty": "peaceful",
204
- "max-players": "10",
205
- "view-distance": "8",
206
- "simulation-distance": "6",
207
- "level-name": "world",
208
- "motd": "Voyager AI + Bedrock Friends",
209
- }
210
- (JAVA_DIR / "server.properties").write_text(
211
- "\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
212
- encoding="utf-8",
213
- )
214
- return launcher
215
-
216
- def _start_fabric(self) -> None:
217
- launcher = self._ensure_fabric()
218
- proc = self._spawn(
219
- "minecraft",
220
- ["java", "-Xms1G", "-Xmx8G", "-jar", str(launcher), "nogui"],
221
- JAVA_DIR,
222
- )
223
- if not _wait_tcp("127.0.0.1", 25565, 120):
224
- raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
225
- time.sleep(2)
226
- if proc.stdin:
227
- for cmd in (
228
- "op bot",
229
- "gamerule keepInventory true",
230
- "gamerule doDaylightCycle false",
231
- "difficulty peaceful",
232
- ):
233
- proc.stdin.write(cmd + "\n")
234
- proc.stdin.flush()
235
- _log("Minecraft Java 1.19/Fabric ready on 127.0.0.1:25565")
236
-
237
- def _ensure_viaproxy(self) -> tuple[Path, Path]:
238
- jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
239
- plugin_dir = VIA_DIR / "plugins"
240
- plugin_dir.mkdir(parents=True, exist_ok=True)
241
- _download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
242
- cfg = VIA_DIR / "viaproxy.yml"
243
- if not cfg.exists():
244
- proc = subprocess.Popen(
245
- ["java", "-jar", str(jar), "config", str(cfg)],
246
- cwd=str(VIA_DIR),
247
- stdout=subprocess.PIPE,
248
- stderr=subprocess.STDOUT,
249
- text=True,
250
- )
251
- deadline = time.time() + 25
252
- while time.time() < deadline and not cfg.exists() and proc.poll() is None:
253
- time.sleep(1)
254
- if proc.poll() is None:
255
- proc.terminate()
256
- try:
257
- proc.wait(timeout=8)
258
- except Exception:
259
- proc.kill()
260
- if not cfg.exists():
261
- raise RuntimeError("ViaProxy did not generate viaproxy.yml")
262
- text = cfg.read_text(encoding="utf-8", errors="replace")
263
- text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
264
- text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
265
- text = _patch_scalar(text, "proxy-online-mode", "false")
266
- text = _patch_scalar(text, "auth-method", "none")
267
- cfg.write_text(text, encoding="utf-8")
268
- return jar, cfg
269
-
270
- def _patch_geyser(self, path: Path) -> None:
271
- data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
272
- bedrock = data.setdefault("bedrock", {})
273
- bedrock["address"] = "127.0.0.1"
274
- bedrock["port"] = 19132
275
- bedrock["clone-remote-port"] = False
276
- java = data.setdefault("java", {})
277
- java["auth-type"] = "offline"
278
- advanced = data.setdefault("advanced", {})
279
- adv_bedrock = advanced.setdefault("bedrock", {})
280
- adv_bedrock["broadcast-port"] = 19132
281
- adv_bedrock["validate-bedrock-login"] = False
282
- data["debug-mode"] = False
283
- path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
284
- _log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
285
-
286
- def _start_viaproxy(self) -> None:
287
- jar, cfg = self._ensure_viaproxy()
288
- geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
289
- if not geyser_cfg.exists():
290
- proc = self._spawn("viaproxy-bootstrap", ["java", "-jar", str(jar), "config", str(cfg)], VIA_DIR)
291
- deadline = time.time() + 35
292
- while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
293
- time.sleep(1)
294
- self._stop_process("viaproxy-bootstrap")
295
- if not geyser_cfg.exists():
296
- raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
297
- self._patch_geyser(geyser_cfg)
298
- proc = self._spawn("viaproxy", ["java", "-jar", str(jar), "config", str(cfg)], VIA_DIR)
299
- time.sleep(12)
300
- if proc.poll() is not None:
301
- raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
302
- _log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
303
-
304
- def _ensure_go(self) -> Path:
305
- go = TOOLS_DIR / "go" / "bin" / "go"
306
- if go.exists():
307
- return go
308
- archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
309
- target = TOOLS_DIR / "go"
310
- if target.exists():
311
- shutil.rmtree(target)
312
- with tarfile.open(archive, "r:gz") as tf:
313
- tf.extractall(TOOLS_DIR)
314
- if not go.exists():
315
- raise RuntimeError("Go toolchain extraction failed")
316
- return go
317
-
318
- def _ensure_nether2rak(self) -> Path:
319
- binary = BIN_DIR / "nether2rak"
320
- if binary.exists():
321
- return binary
322
- go = self._ensure_go()
323
- src = Path("/app/third_party/nether2rak")
324
- if not src.exists():
325
- src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
326
- if not src.exists():
327
- raise RuntimeError("Vendored nether2rak source is missing")
328
- env = os.environ.copy()
329
- env.update(
330
- {
331
- "PATH": f"{go.parent}:{env.get('PATH', '')}",
332
- "GOTOOLCHAIN": "local",
333
- "GOCACHE": str(BASE / "go-cache"),
334
- "GOMODCACHE": str(BASE / "go-mod-cache"),
335
- "CGO_ENABLED": "0",
336
- }
337
- )
338
- _run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
339
- binary.chmod(0o755)
340
- return binary
341
-
342
- def _write_relay_config(self) -> Path:
343
- cfg = {
344
- "backend_transport": "raknet",
345
- "geyser_address": "127.0.0.1:19132",
346
- "nethernet_backend_address": "http://127.0.0.1:19134",
347
- "host_name": HOST_NAME,
348
- "world_name": WORLD_NAME,
349
- "max_players": 10,
350
- "fake_player_count": 0,
351
- "protocol": BEDROCK_PROTOCOL,
352
- "version": BEDROCK_VERSION,
353
- "update_interval_seconds": 30,
354
- "allowed_xuids": [],
355
- "compression": "snappy",
356
- "compression_threshold": 256,
357
- "fix_native_bds_persistence": False,
358
- "disable_client_encryption": False,
359
- "relay_direct_ip": False,
360
- "relay_direct_ip_listen_address": ":19134",
361
- "invite_port": 7782,
362
- "ping_port": 7777,
363
- "pprof_port": 6060,
364
- "extra_broadcasts": [],
365
- }
366
- path = RELAY_DIR / "config.json"
367
- path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
368
- return path
369
-
370
- def _start_relay(self) -> None:
371
- binary = self._ensure_nether2rak()
372
- self._write_relay_config()
373
- proc = self._spawn("friends", [str(binary)], RELAY_DIR)
374
- time.sleep(4)
375
- if proc.poll() is not None:
376
- raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
377
- _log("Xbox Friends relay started; Microsoft device-code login may be required")
378
-
379
- def _boot(self) -> None:
380
- with self.lock:
381
- self.state = "starting"
382
- self.error = ""
383
- self.started_at = time.time()
384
- try:
385
- _mkdirs()
386
- _log("=== Minecraft stack bootstrap ===")
387
- self._start_fabric()
388
- self._start_viaproxy()
389
- self._start_relay()
390
- with self.lock:
391
- self.state = "running"
392
- _log("=== Minecraft stack running ===")
393
- except Exception as exc:
394
- with self.lock:
395
- self.state = "error"
396
- self.error = repr(exc)
397
- _log(f"STACK ERROR: {exc!r}")
398
-
399
- def start_async(self) -> dict[str, Any]:
400
- with self.lock:
401
- if self.worker and self.worker.is_alive():
402
- return self.status()
403
- if any(p.poll() is None for p in self.processes.values()) and self.state == "running":
404
- return self.status()
405
- self.worker = threading.Thread(target=self._boot, daemon=True, name="minecraft-stack")
406
- self.worker.start()
407
- return self.status()
408
-
409
- def stop(self) -> dict[str, Any]:
410
- with self.lock:
411
- for name in ("friends", "viaproxy", "viaproxy-bootstrap", "minecraft"):
412
- self._stop_process(name)
413
- self.state = "stopped"
414
- _log("Minecraft stack stopped")
415
- return self.status()
416
-
417
- def tail(self, lines: int = 160) -> str:
418
- if not LOG_PATH.exists():
419
- return "Sem logs do Minecraft ainda."
420
- items = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
421
- return "\n".join(items[-max(10, min(lines, 500)):])
422
-
423
- def status(self) -> dict[str, Any]:
424
- with self.lock:
425
- processes = {
426
- name: {"pid": proc.pid, "running": proc.poll() is None, "returncode": proc.poll()}
427
- for name, proc in self.processes.items()
428
- }
429
- log = self.tail(120)
430
- login_url = None
431
- device_code = None
432
- url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
433
- if url_match:
434
- login_url = url_match[-1]
435
- for pattern in (
436
- r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
437
- r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
438
- ):
439
- found = re.findall(pattern, log, flags=re.I)
440
- if found:
441
- device_code = found[-1].upper()
442
- break
443
- return {
444
- "state": self.state,
445
- "error": self.error or None,
446
- "started_at": self.started_at,
447
- "java_server": "127.0.0.1:25565",
448
- "bedrock_backend": "127.0.0.1:19132",
449
- "access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
450
- "bedrock_protocol": BEDROCK_PROTOCOL,
451
- "bedrock_version": BEDROCK_VERSION,
452
- "microsoft_login_url": login_url,
453
- "microsoft_device_code": device_code,
454
- "processes": processes,
455
- "log_tail": log,
456
- }
457
-
458
-
459
- STACK = MinecraftStack()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import socket
9
+ import subprocess
10
+ import tarfile
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
20
+ JAVA_DIR = BASE / "java"
21
+ VIA_DIR = BASE / "viaproxy"
22
+ RELAY_DIR = BASE / "relay"
23
+ TOOLS_DIR = BASE / "tools"
24
+ BIN_DIR = BASE / "bin"
25
+ LOG_PATH = BASE / "stack.log"
26
+
27
+ FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
28
+ FABRIC_MC_VERSION = "1.19"
29
+ FABRIC_LOADER_VERSION = "0.14.18"
30
+
31
+ VIAPROXY_URL = "https://github.com/ViaVersion/ViaProxy/releases/download/v3.4.13/ViaProxy-3.4.13.jar"
32
+ VIAPROXY_SHA256 = "4bbb6a6b9d3dd6a2028773ed44b97a98751e4a48e416aaad4bf6d9c860083be9"
33
+
34
+ GEYSER_URL = "https://download.geysermc.org/v2/projects/geyser/versions/2.11.3/builds/1247/downloads/viaproxy"
35
+ GEYSER_SHA256 = "85eea53e7fe58e4d97c2144a9d061577d37f619ab635227b68a948aa62d22924"
36
+
37
+ GO_URL = "https://go.dev/dl/go1.25.14.linux-amd64.tar.gz"
38
+ GO_SHA256 = "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712"
39
+
40
+ JAVA_URL = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_x64_linux_hotspot_21.0.12.1_1.tar.gz"
41
+ JAVA_SHA256 = "2413149700df0f7d440500a84a8f764c535f21e5a5e87d38328b64eec2c5b500"
42
+
43
+ BEDROCK_PROTOCOL = int(os.environ.get("BEDROCK_PROTOCOL", "2193"))
44
+ BEDROCK_VERSION = os.environ.get("BEDROCK_VERSION", "1.26.51")
45
+ WORLD_NAME = os.environ.get("MINECRAFT_WORLD_NAME", "Voyager AI World")
46
+ HOST_NAME = os.environ.get("MINECRAFT_HOST_NAME", "Voyager AI")
47
+
48
+
49
+ def _mkdirs() -> None:
50
+ for path in (BASE, JAVA_DIR, VIA_DIR, RELAY_DIR, TOOLS_DIR, BIN_DIR):
51
+ path.mkdir(parents=True, exist_ok=True)
52
+
53
+
54
+ def _log(message: str) -> None:
55
+ _mkdirs()
56
+ line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
57
+ print(line, flush=True)
58
+ with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
59
+ fh.write(line + "\n")
60
+
61
+
62
+ def _sha256(path: Path) -> str:
63
+ h = hashlib.sha256()
64
+ with path.open("rb") as fh:
65
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
66
+ h.update(chunk)
67
+ return h.hexdigest()
68
+
69
+
70
+ def _download(url: str, path: Path, sha256: str | None = None) -> Path:
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ if path.exists() and (not sha256 or _sha256(path) == sha256):
73
+ return path
74
+ tmp = path.with_suffix(path.suffix + ".part")
75
+ tmp.unlink(missing_ok=True)
76
+ req = urllib.request.Request(url, headers={"User-Agent": "minecraft-friends-ai-zerogpu/1.0"})
77
+ _log(f"download {url}")
78
+ with urllib.request.urlopen(req, timeout=120) as response, tmp.open("wb") as out:
79
+ shutil.copyfileobj(response, out)
80
+ if sha256 and _sha256(tmp) != sha256:
81
+ tmp.unlink(missing_ok=True)
82
+ raise RuntimeError(f"SHA256 mismatch for {url}")
83
+ tmp.replace(path)
84
+ return path
85
+
86
+
87
+ def _wait_tcp(host: str, port: int, timeout: float) -> bool:
88
+ deadline = time.time() + timeout
89
+ while time.time() < deadline:
90
+ try:
91
+ with socket.create_connection((host, port), timeout=1):
92
+ return True
93
+ except OSError:
94
+ time.sleep(1)
95
+ return False
96
+
97
+
98
+ def _run_capture(args: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> str:
99
+ _log("exec " + " ".join(args))
100
+ proc = subprocess.run(
101
+ args,
102
+ cwd=str(cwd),
103
+ env=env,
104
+ text=True,
105
+ stdout=subprocess.PIPE,
106
+ stderr=subprocess.STDOUT,
107
+ timeout=timeout,
108
+ check=False,
109
+ )
110
+ output = proc.stdout or ""
111
+ for line in output.splitlines()[-80:]:
112
+ _log("setup> " + line)
113
+ if proc.returncode != 0:
114
+ raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}")
115
+ return output
116
+
117
+
118
+ def _patch_scalar(text: str, key: str, value: str) -> str:
119
+ pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
120
+ if re.search(pattern, text):
121
+ return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
122
+ return text + f"\n{key}: {value}\n"
123
+
124
+
125
+ class MinecraftStack:
126
+ def __init__(self) -> None:
127
+ self.lock = threading.RLock()
128
+ self.worker: threading.Thread | None = None
129
+ self.processes: dict[str, subprocess.Popen[str]] = {}
130
+ self.state = "stopped"
131
+ self.error = ""
132
+ self.started_at: float | None = None
133
+
134
+ def _pump(self, name: str, proc: subprocess.Popen[str]) -> None:
135
+ assert proc.stdout is not None
136
+ try:
137
+ for line in proc.stdout:
138
+ _log(f"{name}> {line.rstrip()}")
139
+ except Exception as exc:
140
+ _log(f"{name} log pump error: {exc!r}")
141
+
142
+ def _spawn(self, name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen[str]:
143
+ _log(f"start {name}: {' '.join(args)}")
144
+ proc = subprocess.Popen(
145
+ args,
146
+ cwd=str(cwd),
147
+ env=env,
148
+ stdin=subprocess.PIPE,
149
+ stdout=subprocess.PIPE,
150
+ stderr=subprocess.STDOUT,
151
+ text=True,
152
+ bufsize=1,
153
+ )
154
+ self.processes[name] = proc
155
+ threading.Thread(target=self._pump, args=(name, proc), daemon=True, name=f"log-{name}").start()
156
+ return proc
157
+
158
+ def _stop_process(self, name: str) -> None:
159
+ proc = self.processes.get(name)
160
+ if not proc or proc.poll() is not None:
161
+ return
162
+ try:
163
+ if name == "minecraft" and proc.stdin:
164
+ proc.stdin.write("stop\n")
165
+ proc.stdin.flush()
166
+ proc.wait(timeout=20)
167
+ return
168
+ except Exception:
169
+ pass
170
+ try:
171
+ proc.terminate()
172
+ proc.wait(timeout=12)
173
+ except Exception:
174
+ try:
175
+ proc.kill()
176
+ except Exception:
177
+ pass
178
+
179
+ def _ensure_java(self) -> Path:
180
+ root = TOOLS_DIR / "jre21"
181
+ existing = list(root.glob("*/bin/java")) if root.exists() else []
182
+ if existing:
183
+ return existing[0]
184
+ archive = _download(JAVA_URL, TOOLS_DIR / "temurin21-jre.tar.gz", JAVA_SHA256)
185
+ if root.exists():
186
+ shutil.rmtree(root)
187
+ root.mkdir(parents=True, exist_ok=True)
188
+ with tarfile.open(archive, "r:gz") as tf:
189
+ tf.extractall(root)
190
+ found = list(root.glob("*/bin/java"))
191
+ if not found:
192
+ raise RuntimeError("Temurin JRE 21 extraction failed")
193
+ found[0].chmod(0o755)
194
+ version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
195
+ _log("portable Java ready: " + version.splitlines()[0])
196
+ return found[0]
197
+
198
+ def _ensure_fabric(self) -> Path:
199
+ java = self._ensure_java()
200
+ installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
201
+ JAVA_DIR.mkdir(parents=True, exist_ok=True)
202
+ launcher = JAVA_DIR / "fabric-server-launch.jar"
203
+ if not launcher.exists():
204
+ _run_capture(
205
+ [
206
+ str(java), "-jar", str(installer), "server",
207
+ "-mcversion", FABRIC_MC_VERSION,
208
+ "-loader", FABRIC_LOADER_VERSION,
209
+ "-downloadMinecraft",
210
+ "-dir", str(JAVA_DIR),
211
+ ],
212
+ JAVA_DIR,
213
+ timeout=300,
214
+ )
215
+ (JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
216
+ props = {
217
+ "server-ip": "127.0.0.1",
218
+ "server-port": "25565",
219
+ "online-mode": "false",
220
+ "enforce-secure-profile": "false",
221
+ "enable-command-block": "true",
222
+ "spawn-protection": "0",
223
+ "allow-flight": "true",
224
+ "gamemode": "survival",
225
+ "force-gamemode": "true",
226
+ "difficulty": "peaceful",
227
+ "max-players": "10",
228
+ "view-distance": "8",
229
+ "simulation-distance": "6",
230
+ "level-name": "world",
231
+ "motd": "Voyager AI + Bedrock Friends",
232
+ }
233
+ (JAVA_DIR / "server.properties").write_text(
234
+ "\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
235
+ encoding="utf-8",
236
+ )
237
+ return launcher
238
+
239
+ def _start_fabric(self) -> None:
240
+ launcher = self._ensure_fabric()
241
+ proc = self._spawn(
242
+ "minecraft",
243
+ [str(self._ensure_java()), "-Xms1G", "-Xmx8G", "-jar", str(launcher), "nogui"],
244
+ JAVA_DIR,
245
+ )
246
+ if not _wait_tcp("127.0.0.1", 25565, 120):
247
+ raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
248
+ time.sleep(2)
249
+ if proc.stdin:
250
+ for cmd in (
251
+ "op bot",
252
+ "gamerule keepInventory true",
253
+ "gamerule doDaylightCycle false",
254
+ "difficulty peaceful",
255
+ ):
256
+ proc.stdin.write(cmd + "\n")
257
+ proc.stdin.flush()
258
+ _log("Minecraft Java 1.19/Fabric ready on 127.0.0.1:25565")
259
+
260
+ def _ensure_viaproxy(self) -> tuple[Path, Path]:
261
+ jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
262
+ plugin_dir = VIA_DIR / "plugins"
263
+ plugin_dir.mkdir(parents=True, exist_ok=True)
264
+ _download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
265
+ cfg = VIA_DIR / "viaproxy.yml"
266
+ if not cfg.exists():
267
+ proc = subprocess.Popen(
268
+ [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)],
269
+ cwd=str(VIA_DIR),
270
+ stdout=subprocess.PIPE,
271
+ stderr=subprocess.STDOUT,
272
+ text=True,
273
+ )
274
+ deadline = time.time() + 25
275
+ while time.time() < deadline and not cfg.exists() and proc.poll() is None:
276
+ time.sleep(1)
277
+ if proc.poll() is None:
278
+ proc.terminate()
279
+ try:
280
+ proc.wait(timeout=8)
281
+ except Exception:
282
+ proc.kill()
283
+ if not cfg.exists():
284
+ raise RuntimeError("ViaProxy did not generate viaproxy.yml")
285
+ text = cfg.read_text(encoding="utf-8", errors="replace")
286
+ text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
287
+ text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
288
+ text = _patch_scalar(text, "proxy-online-mode", "false")
289
+ text = _patch_scalar(text, "auth-method", "none")
290
+ cfg.write_text(text, encoding="utf-8")
291
+ return jar, cfg
292
+
293
+ def _patch_geyser(self, path: Path) -> None:
294
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
295
+ bedrock = data.setdefault("bedrock", {})
296
+ bedrock["address"] = "127.0.0.1"
297
+ bedrock["port"] = 19132
298
+ bedrock["clone-remote-port"] = False
299
+ java = data.setdefault("java", {})
300
+ java["auth-type"] = "offline"
301
+ advanced = data.setdefault("advanced", {})
302
+ adv_bedrock = advanced.setdefault("bedrock", {})
303
+ adv_bedrock["broadcast-port"] = 19132
304
+ adv_bedrock["validate-bedrock-login"] = False
305
+ data["debug-mode"] = False
306
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
307
+ _log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
308
+
309
+ def _start_viaproxy(self) -> None:
310
+ jar, cfg = self._ensure_viaproxy()
311
+ geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
312
+ if not geyser_cfg.exists():
313
+ proc = self._spawn("viaproxy-bootstrap", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
314
+ deadline = time.time() + 35
315
+ while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
316
+ time.sleep(1)
317
+ self._stop_process("viaproxy-bootstrap")
318
+ if not geyser_cfg.exists():
319
+ raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
320
+ self._patch_geyser(geyser_cfg)
321
+ proc = self._spawn("viaproxy", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
322
+ time.sleep(12)
323
+ if proc.poll() is not None:
324
+ raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
325
+ _log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
326
+
327
+ def _ensure_go(self) -> Path:
328
+ go = TOOLS_DIR / "go" / "bin" / "go"
329
+ if go.exists():
330
+ return go
331
+ archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
332
+ target = TOOLS_DIR / "go"
333
+ if target.exists():
334
+ shutil.rmtree(target)
335
+ with tarfile.open(archive, "r:gz") as tf:
336
+ tf.extractall(TOOLS_DIR)
337
+ if not go.exists():
338
+ raise RuntimeError("Go toolchain extraction failed")
339
+ return go
340
+
341
+ def _ensure_nether2rak(self) -> Path:
342
+ binary = BIN_DIR / "nether2rak"
343
+ if binary.exists():
344
+ return binary
345
+ go = self._ensure_go()
346
+ src = Path("/app/third_party/nether2rak")
347
+ if not src.exists():
348
+ src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
349
+ if not src.exists():
350
+ raise RuntimeError("Vendored nether2rak source is missing")
351
+ env = os.environ.copy()
352
+ env.update(
353
+ {
354
+ "PATH": f"{go.parent}:{env.get('PATH', '')}",
355
+ "GOTOOLCHAIN": "local",
356
+ "GOCACHE": str(BASE / "go-cache"),
357
+ "GOMODCACHE": str(BASE / "go-mod-cache"),
358
+ "CGO_ENABLED": "0",
359
+ }
360
+ )
361
+ _run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
362
+ binary.chmod(0o755)
363
+ return binary
364
+
365
+ def _write_relay_config(self) -> Path:
366
+ cfg = {
367
+ "backend_transport": "raknet",
368
+ "geyser_address": "127.0.0.1:19132",
369
+ "nethernet_backend_address": "http://127.0.0.1:19134",
370
+ "host_name": HOST_NAME,
371
+ "world_name": WORLD_NAME,
372
+ "max_players": 10,
373
+ "fake_player_count": 0,
374
+ "protocol": BEDROCK_PROTOCOL,
375
+ "version": BEDROCK_VERSION,
376
+ "update_interval_seconds": 30,
377
+ "allowed_xuids": [],
378
+ "compression": "snappy",
379
+ "compression_threshold": 256,
380
+ "fix_native_bds_persistence": False,
381
+ "disable_client_encryption": False,
382
+ "relay_direct_ip": False,
383
+ "relay_direct_ip_listen_address": ":19134",
384
+ "invite_port": 7782,
385
+ "ping_port": 7777,
386
+ "pprof_port": 6060,
387
+ "extra_broadcasts": [],
388
+ }
389
+ path = RELAY_DIR / "config.json"
390
+ path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
391
+ return path
392
+
393
+ def _start_relay(self) -> None:
394
+ binary = self._ensure_nether2rak()
395
+ self._write_relay_config()
396
+ proc = self._spawn("friends", [str(binary)], RELAY_DIR)
397
+ time.sleep(4)
398
+ if proc.poll() is not None:
399
+ raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
400
+ _log("Xbox Friends relay started; Microsoft device-code login may be required")
401
+
402
+ def _boot(self) -> None:
403
+ with self.lock:
404
+ self.state = "starting"
405
+ self.error = ""
406
+ self.started_at = time.time()
407
+ try:
408
+ _mkdirs()
409
+ _log("=== Minecraft stack bootstrap ===")
410
+ self._start_fabric()
411
+ self._start_viaproxy()
412
+ self._start_relay()
413
+ with self.lock:
414
+ self.state = "running"
415
+ _log("=== Minecraft stack running ===")
416
+ except Exception as exc:
417
+ with self.lock:
418
+ self.state = "error"
419
+ self.error = repr(exc)
420
+ _log(f"STACK ERROR: {exc!r}")
421
+
422
+ def start_async(self) -> dict[str, Any]:
423
+ with self.lock:
424
+ if self.worker and self.worker.is_alive():
425
+ return self.status()
426
+ if any(p.poll() is None for p in self.processes.values()) and self.state == "running":
427
+ return self.status()
428
+ self.worker = threading.Thread(target=self._boot, daemon=True, name="minecraft-stack")
429
+ self.worker.start()
430
+ return self.status()
431
+
432
+ def stop(self) -> dict[str, Any]:
433
+ with self.lock:
434
+ for name in ("friends", "viaproxy", "viaproxy-bootstrap", "minecraft"):
435
+ self._stop_process(name)
436
+ self.state = "stopped"
437
+ _log("Minecraft stack stopped")
438
+ return self.status()
439
+
440
+ def tail(self, lines: int = 160) -> str:
441
+ if not LOG_PATH.exists():
442
+ return "Sem logs do Minecraft ainda."
443
+ items = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
444
+ return "\n".join(items[-max(10, min(lines, 500)):])
445
+
446
+ def status(self) -> dict[str, Any]:
447
+ with self.lock:
448
+ processes = {
449
+ name: {"pid": proc.pid, "running": proc.poll() is None, "returncode": proc.poll()}
450
+ for name, proc in self.processes.items()
451
+ }
452
+ log = self.tail(120)
453
+ login_url = None
454
+ device_code = None
455
+ url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
456
+ if url_match:
457
+ login_url = url_match[-1]
458
+ for pattern in (
459
+ r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
460
+ r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
461
+ ):
462
+ found = re.findall(pattern, log, flags=re.I)
463
+ if found:
464
+ device_code = found[-1].upper()
465
+ break
466
+ return {
467
+ "state": self.state,
468
+ "error": self.error or None,
469
+ "started_at": self.started_at,
470
+ "java_server": "127.0.0.1:25565",
471
+ "bedrock_backend": "127.0.0.1:19132",
472
+ "access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
473
+ "bedrock_protocol": BEDROCK_PROTOCOL,
474
+ "bedrock_version": BEDROCK_VERSION,
475
+ "microsoft_login_url": login_url,
476
+ "microsoft_device_code": device_code,
477
+ "processes": processes,
478
+ "log_tail": log,
479
+ }
480
+
481
+
482
+ STACK = MinecraftStack()
packages.txt DELETED
@@ -1 +0,0 @@
1
- default-jre-headless