smodusermc commited on
Commit
345396f
·
verified ·
1 Parent(s): bf914f0

Update start.sh

Browse files
Files changed (1) hide show
  1. start.sh +1113 -23
start.sh CHANGED
@@ -238,10 +238,47 @@ PRIVATE_IP_LOG=true
238
  # FORWARD_IP=X-Real-IP any other value = trust that header, no probing
239
  FORWARD_IP="${FORWARD_IP:-auto}"
240
  FORWARD_IP_HEADER="${FORWARD_IP_HEADER:-}"
241
- FORWARD_IP_CANDIDATES="${FORWARD_IP_CANDIDATES:-X-Real-IP X-Forwarded-For CF-Connecting-IP True-Client-IP}"
242
  PUBLIC_URL="${PUBLIC_URL:-https://smodusermc-12.hf.space/}"
243
- FORWARD_IP_STATE="$PRIVATE_DIR/forward-ip.state"
244
  FORWARD_IP_PROBE_PY="${FORWARD_IP_PROBE_PY:-/tmp/forward_ip_probe.py}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
  # >>> embedded forward_ip_probe.py (generated from tools/forward_ip_probe.py) >>>
247
  write_forward_ip_probe_py() {
@@ -348,6 +385,865 @@ ensure_forward_ip_probe_py() {
348
  }
349
  # <<< embedded forward_ip_probe.py <<<
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  # how the login logger is reported: logins.log row format + a status file so
353
  # "it is not logging logins" can be answered from the bucket in one look
@@ -410,7 +1306,9 @@ else
410
  echo " (SYNC_PRIVATE_LOGS=false: auth.log / player-ips.log stay inside the Space)"
411
  fi
412
  [ "$SYNC_CONSOLE_LOGS" = true ] && \
413
- echo " ${HF_BUCKET_HANDLE}/game-data/logs/{paper,bungee}.log last ${CONSOLE_LOG_LINES} console lines"
 
 
414
  if [ "$HIDE_VERIFIED_IP" = true ]; then
415
  echo " The verified client is hidden in the security-logs: its IP is written as"
416
  echo " \"hidden\" and it gets no client=... tag / VERIFY line"
@@ -535,6 +1433,27 @@ start_paper() {
535
  BACKEND_PID=$!
536
  }
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  # =============================================================
539
  # OP Account Setup
540
  # =============================================================
@@ -661,6 +1580,114 @@ forward_ip_was_refused() { # the plugin's own words when the header is missing
661
  | grep -q "header, disconnecting"
662
  }
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  forward_ip_probe_once() { # 0 = the proxy passed the header through
665
  ensure_forward_ip_probe_py
666
  python3 "$FORWARD_IP_PROBE_PY" --url "$PUBLIC_URL" --timeout "${FORWARD_IP_TIMEOUT:-10}" \
@@ -1186,7 +2213,13 @@ handle_paper_line() {
1186
  # before the player is even through):
1187
  # [12:00:00 INFO] Steve[/1.2.3.4:5555] <-> InitialHandler has connected
1188
  # [12:00:00 INFO] [UserConnection] Steve[/1.2.3.4:5555] <-> ServerConnector [lobby] has connected
1189
- # [12:00:00 INFO] Steve executed command: /login hunter2
 
 
 
 
 
 
1190
  handle_bungee_line() {
1191
  local line="${1%$'\r'}" name ip cmd v NOW
1192
  local JOIN_RE='([A-Za-z0-9_.-]{1,16})\[/([^]]+):[0-9]+\] <-> ServerConnector \[?[^]]*\]? has connected'
@@ -1471,8 +2504,19 @@ ip_report_body() {
1471
  [ "$(verdict_for "$n")" = "VERIFIED" ] && printf '%s,' "$n"
1472
  done)
1473
  fi
1474
- awk -F'\t' -v skip="$skip" '
1475
- BEGIN { m = split(skip, a, ","); for (i = 1; i <= m; i++) if (a[i] != "") hidden[a[i]] = 1 }
 
 
 
 
 
 
 
 
 
 
 
1476
  $1 != "" && $2 != "" && $2 != "unknown" && $2 != "hidden" && !($1 in hidden) {
1477
  pair = $1 SUBSEP $2
1478
  if (!(pair in seen)) {
@@ -1484,6 +2528,7 @@ ip_report_body() {
1484
  rows++
1485
  who[$2] = who[$2] " " $1
1486
  users[$2]++
 
1487
  }
1488
  END {
1489
  print "=== Accounts and the IPs they were seen from ==="
@@ -1495,9 +2540,47 @@ ip_report_body() {
1495
  print ""
1496
  print "=== One IP, several accounts ==="
1497
  for (ip in users) if (users[ip] > 1) print " " ip " ->" who[ip]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1498
  }' "$map" 2>/dev/null | sort > "$out"
1499
  }
1500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1501
  # =============================================================
1502
  # LOGGER STATUS (security-logs/logger-status.log)
1503
  # =============================================================
@@ -1521,7 +2604,10 @@ write_logger_status() {
1521
  echo "commands.log : $(wc -l < "$CMD_LOG" 2>/dev/null || echo 0) rows"
1522
  echo "client-checks : $(wc -l < "$CLIENT_LOG" 2>/dev/null || echo 0) rows"
1523
  echo "playerlist : ${PLAYERLIST_LAST:-not polled yet}"
1524
- echo "real client IPs: $(forward_ip_setting 2>/dev/null || true)"
 
 
 
1525
  if [ "$VERIFIED_CLIENT_CONFIGURED" = true ]; then
1526
  echo "verified client: ${VERIFIED_CLIENT_BRAND} (uuid ${VERIFIED_CLIENT_UUID})"
1527
  echo " from : ${VERIFIED_CLIENT_SOURCE:-?}$([ "$VERIFIED_CLIENT_SOURCE" = environment ] && echo " - the Space secrets win over the built-in pair")"
@@ -1960,6 +3046,7 @@ hf_push_saves() {
1960
  hf_push_logs() {
1961
  flush_pending_auth
1962
  report_shared_ips
 
1963
  local STAGING="$LOG_STAGING" out rc
1964
  rm -rf "$STAGING" && mkdir -p "$STAGING"
1965
  [ -d "$SEC_DIR" ] && cp -a "$SEC_DIR" "$STAGING/security-logs"
@@ -1968,8 +3055,10 @@ hf_push_logs() {
1968
  fi
1969
  if [ "$SYNC_CONSOLE_LOGS" = true ]; then
1970
  mkdir -p "$STAGING/logs"
1971
- [ -f /tmp/paper.log ] && tail -n "$CONSOLE_LOG_LINES" /tmp/paper.log > "$STAGING/logs/paper.log" 2>/dev/null
1972
- [ -f /tmp/bungee.log ] && tail -n "$CONSOLE_LOG_LINES" /tmp/bungee.log > "$STAGING/logs/bungee.log" 2>/dev/null
 
 
1973
  fi
1974
  LOG_SYNC_FILES=$(cd "$STAGING" 2>/dev/null && find . -type f -printf '%P(%s) ' 2>/dev/null | sort)
1975
  if bucket_sync_dir "$STAGING" "${HF_BUCKET_HANDLE}/game-data"; then
@@ -2125,29 +3214,23 @@ world-settings:
2125
  enable-zombie-pigmen-portal-spawns: true
2126
  EOF
2127
 
 
 
 
 
2128
  setup_op_account
2129
 
2130
  > /tmp/paper.log
2131
  start_paper
2132
  echo " Paper PID: $BACKEND_PID"
2133
-
2134
- for i in $(seq 1 120); do
2135
- if grep -q "Done" /tmp/paper.log 2>/dev/null; then
2136
- echo " Paper READY (~${i}s)"
2137
- break
2138
- fi
2139
- if ! kill -0 $BACKEND_PID 2>/dev/null; then
2140
- echo " PAPER CRASHED!"
2141
- tail -30 /tmp/paper.log
2142
- exit 1
2143
- fi
2144
- [ $((i % 15)) -eq 0 ] && echo " Loading... (${i}s)"
2145
- sleep 1
2146
- done
2147
 
2148
  # Start security logger (logins/IPs + commands + verified client checks)
2149
  warn_verified_client_problem
2150
  warn_verified_client_mismatch
 
2151
  write_logger_status
2152
  start_security_logger
2153
  echo " Security logger PID: $SECLOG_PID"
@@ -2408,6 +3491,8 @@ hf_sync_loop &
2408
  SYNC_PID=$!
2409
  log_sync_loop &
2410
  LOGSYNC_PID=$!
 
 
2411
 
2412
  # =============================================================
2413
  # Shutdown — save world properly, then push, then stop processes
@@ -2488,6 +3573,11 @@ while true; do
2488
  LOGSYNC_PID=$!
2489
  fi
2490
 
 
 
 
 
 
2491
  if ! kill -0 "$SECLOG_PID" 2>/dev/null; then
2492
  echo "[$(date '+%H:%M:%S')] Security logger died — restarting..."
2493
  start_security_logger
 
238
  # FORWARD_IP=X-Real-IP any other value = trust that header, no probing
239
  FORWARD_IP="${FORWARD_IP:-auto}"
240
  FORWARD_IP_HEADER="${FORWARD_IP_HEADER:-}"
241
+ FORWARD_IP_CANDIDATES="${FORWARD_IP_CANDIDATES:-X-Real-IP X-Forwarded-For CF-Connecting-IP True-Client-IP X-Envoy-External-Address X-Client-IP}"
242
  PUBLIC_URL="${PUBLIC_URL:-https://smodusermc-12.hf.space/}"
243
+ FORWARD_IP_STATE="$PRIV_DIR/forward-ip.state"
244
  FORWARD_IP_PROBE_PY="${FORWARD_IP_PROBE_PY:-/tmp/forward_ip_probe.py}"
245
+ # The boot probe can only succeed once the Space really answers on its public
246
+ # URL, which is usually not the case while it is still starting up - and a
247
+ # failed probe used to be final until the next restart. It is therefore
248
+ # retried in the background until a header works; a retry restarts the proxy,
249
+ # so it only ever happens while nobody is online. 0 disables the retries.
250
+ FORWARD_IP_RETRY_INTERVAL="${FORWARD_IP_RETRY_INTERVAL:-600}"
251
+ # Which of the two it is - the player's address or the proxy's - is not a
252
+ # matter of opinion: everything from outside reaches the game port through the
253
+ # ingress, so the PEERS of that port are the proxy's own addresses. Comparing
254
+ # a logged address against them is what makes "the IPs are wrong" answerable:
255
+ # a logged address that equals a peer is the proxy's, never a player's.
256
+ PROXY_PEERS_PY="${PROXY_PEERS_PY:-/tmp/proxy_peers.py}"
257
+ PROXY_PEERS_STATE="$PRIV_DIR/proxy-peers.log"
258
+ PROXY_PEERS_VIEW="$SEC_DIR/proxy-peers.txt"
259
+ GAME_PORT="${GAME_PORT:-7860}"
260
+
261
+ # =============================================
262
+ # AUTH LOG CAPTURE (why /login needs a patch)
263
+ # =============================================
264
+ # Paper prints "Steve issued server command: /login hunter2" for every command
265
+ # a player types, and that line is the only place the security logger can read
266
+ # /login, /register and /changepassword from. LoginSecurity 3.3.1 (and AuthMe)
267
+ # install a log filter that makes the logging framework DROP every line like
268
+ # that before the console, the log file or the parser ever see it - which is
269
+ # why no login was picked up at all. tools/patch_auth_filter.py rewrites the
270
+ # string constants of that filter inside the plugin jar, so the filter cannot
271
+ # match any more; the plugin itself keeps working unchanged. apply_auth_filter_patch
272
+ # does that before Paper starts, checks the patched class with the JVM's own
273
+ # parser (javap) and rolls back if anything looks wrong.
274
+ AUTH_FILTER_PATCH="${AUTH_FILTER_PATCH:-true}" # false = leave the plugin jars alone
275
+ AUTH_FILTER_PATCH_PY="${AUTH_FILTER_PATCH_PY:-/tmp/patch_auth_filter.py}"
276
+ AUTH_PATCH_BACKUP_DIR="${AUTH_PATCH_BACKUP_DIR:-/tmp/authlog-jar-backups}"
277
+ AUTH_PATCH_JAR_GLOB="${AUTH_PATCH_JAR_GLOB:-*LoginSecurity*.jar *AuthMe*.jar *loginsecurity*.jar *authme*.jar}"
278
+ AUTH_PATCH_STATUS="not run"
279
+ AUTH_PATCHED_CLASSES="" # "<jar>|<class>|<backup>" per patched filter class
280
+ AUTH_PATCH_EXPECT="" # plugin names that must appear in "Enabling ..." lines
281
+ AUTH_PATCH_RESTART_DONE=false
282
 
283
  # >>> embedded forward_ip_probe.py (generated from tools/forward_ip_probe.py) >>>
284
  write_forward_ip_probe_py() {
 
385
  }
386
  # <<< embedded forward_ip_probe.py <<<
387
 
388
+ # >>> embedded proxy_peers.py (generated from tools/proxy_peers.py) >>>
389
+ write_proxy_peers_py() {
390
+ mkdir -p "$(dirname "$PROXY_PEERS_PY")" 2>/dev/null
391
+ cat > "$PROXY_PEERS_PY" <<'PROXY_PEERS_EOF'
392
+ #!/usr/bin/env python3
393
+ """proxy_peers.py - the addresses of the proxy that sits in front of the game port.
394
+
395
+ Everything that reaches the game port (7860) from outside goes through the
396
+ Hugging Face ingress, so the *peers* of the listening socket are the ingress's
397
+ own addresses - never a player's. Comparing a logged player address with them
398
+ is what answers the question the logs alone cannot:
399
+
400
+ * the address equals a proxy peer -> the log holds the PROXY's address, not
401
+ the player's (no forwarded header is in use, so two logins can legitimately
402
+ show two different addresses for one player: the ingress has several nodes)
403
+ * it does not -> the log holds the player's own address
404
+
405
+ No `ss`/`iproute2` needed: the kernel's own tables are read (/proc/net/tcp and
406
+ /proc/net/tcp6, where they exist).
407
+
408
+ usage:
409
+ proxy_peers.py # peers of port 7860
410
+ proxy_peers.py --port 25565 --json
411
+ proxy_peers.py --proc-dir ./fixture # for the tests
412
+
413
+ Exit code is 0 even when nothing can be read - "no peers" is a valid answer.
414
+ """
415
+
416
+ import argparse
417
+ import ipaddress
418
+ import json
419
+ import os
420
+ import sys
421
+
422
+
423
+ def decode_v4(hex_addr: str):
424
+ raw = bytes.fromhex(hex_addr)
425
+ if len(raw) != 4:
426
+ return None
427
+ return str(ipaddress.IPv4Address(raw[::-1]))
428
+
429
+
430
+ def decode_v6(hex_addr: str):
431
+ raw = bytes.fromhex(hex_addr)
432
+ if len(raw) != 16:
433
+ return None
434
+ # /proc/net/tcp6 stores the address as four 32-bit words, each in host
435
+ # (little endian) byte order
436
+ out = b""
437
+ for i in range(4):
438
+ out += raw[i * 4:(i + 1) * 4][::-1]
439
+ return str(ipaddress.IPv6Address(out))
440
+
441
+
442
+ def split_addr(field: str):
443
+ if ":" not in field:
444
+ return None, None
445
+ hex_addr, _, hex_port = field.rpartition(":")
446
+ try:
447
+ port = int(hex_port, 16)
448
+ except ValueError:
449
+ return None, None
450
+ return hex_addr, port
451
+
452
+
453
+ def peers(port: int, proc_dir: str = "/proc"):
454
+ found = []
455
+ for name, decode in (("tcp", decode_v4), ("tcp6", decode_v6)):
456
+ path = os.path.join(proc_dir, "net", name)
457
+ try:
458
+ with open(path, "r") as fh:
459
+ lines = fh.read().splitlines()[1:]
460
+ except OSError:
461
+ continue
462
+ for line in lines:
463
+ parts = line.split()
464
+ if len(parts) < 4:
465
+ continue
466
+ local_addr, local_port = split_addr(parts[1])
467
+ rem_addr, rem_port = split_addr(parts[2])
468
+ if local_port != port or not rem_port:
469
+ continue
470
+ if set(rem_addr) == {"0"}: # the listening socket itself
471
+ continue
472
+ addr = decode(rem_addr)
473
+ if addr and addr not in found:
474
+ found.append(addr)
475
+ return sorted(found, key=lambda a: (0 if "." in a else 1, ipaddress.ip_address(a).packed))
476
+
477
+
478
+ def main(argv=None) -> int:
479
+ ap = argparse.ArgumentParser(description="addresses of the proxy in front of the game port")
480
+ ap.add_argument("--port", type=int, default=7860)
481
+ ap.add_argument("--proc-dir", default="/proc")
482
+ ap.add_argument("--json", action="store_true")
483
+ args = ap.parse_args(argv)
484
+
485
+ found = peers(args.port, args.proc_dir)
486
+ if args.json:
487
+ print(json.dumps({
488
+ "port": args.port,
489
+ "peers": found,
490
+ "ipv4": len([a for a in found if ":" not in a]),
491
+ "ipv6": len([a for a in found if ":" in a]),
492
+ }))
493
+ else:
494
+ for addr in found:
495
+ print(addr)
496
+ return 0
497
+
498
+
499
+ if __name__ == "__main__":
500
+ sys.exit(main())
501
+ PROXY_PEERS_EOF
502
+ }
503
+ ensure_proxy_peers_py() {
504
+ [ -s "$PROXY_PEERS_PY" ] || write_proxy_peers_py
505
+ }
506
+ # <<< embedded proxy_peers.py <<<
507
+
508
+
509
+ # >>> embedded patch_auth_filter.py (generated from tools/patch_auth_filter.py) >>>
510
+ write_auth_filter_patch_py() {
511
+ mkdir -p "$(dirname "$AUTH_FILTER_PATCH_PY")" 2>/dev/null
512
+ cat > "$AUTH_FILTER_PATCH_PY" <<'AUTH_FILTER_PATCH_PY_EOF'
513
+ #!/usr/bin/env python3
514
+ """Stop the auth plugins' log filters from hiding /login from the console.
515
+
516
+ Why this exists
517
+ ---------------
518
+ The security logger in start.sh reads /login, /register and /changepassword
519
+ out of the *console log* (Paper prints "Steve issued server command: /login
520
+ hunter2" for every command a player types). LoginSecurity 3.3.1 does not let
521
+ that line reach the console: its `LoggingFilter` is added to the log4j root
522
+ logger in `LoginSecurity.enable()` and returns DENY for every message that
523
+ looks like an auth command, so the line is dropped before Paper, the file log
524
+ and our parser ever see it. AuthMe does the same thing through
525
+ `LogFilterHelper` (used by its ConsoleFilter and Log4JFilter). That is why
526
+ "logins are not picked up" - no amount of pattern matching can find a line
527
+ that the logging framework never writes.
528
+
529
+ What this does
530
+ --------------
531
+ It rewrites *only the string constants* of those filter classes inside the
532
+ plugin jar, so the deny check can never match a real console line again:
533
+
534
+ "/login" -> "[authlog-patched] /login"
535
+ "issued server command: " -> "[authlog-patched] issued server command: "
536
+
537
+ Nothing else changes: the class file keeps its bytecode, its structure and its
538
+ constant indices (only the bytes of those UTF-8 constants and their lengths are
539
+ rewritten), which `javap -c` on the original and the patched class proves line
540
+ by line. The plugin keeps working exactly as before - it just cannot hide the
541
+ auth lines any more, which is what the server owner wants, because those lines
542
+ are the only place the passwords can be read from (private-logs/auth.log).
543
+
544
+ The password itself is still never written down for the verified client (see
545
+ the masking in start.sh), and the copies of the raw console logs that are
546
+ synced to the bucket have the passwords masked as well.
547
+
548
+ Usage
549
+ -----
550
+ python3 tools/patch_auth_filter.py --check <jar> [<jar> ...]
551
+ python3 tools/patch_auth_filter.py --apply <jar> [<jar> ...]
552
+ python3 tools/patch_auth_filter.py --restore <jar> [<jar> ...]
553
+ python3 tools/patch_auth_filter.py --selftest [--dir DIR]
554
+
555
+ --apply keeps a copy of the untouched jar (--backup-dir, default: next to
556
+ the jar as <jar>.authlog-orig) so --restore can put it back.
557
+ --json prints one machine readable object per jar for start.sh.
558
+ """
559
+
560
+ from __future__ import annotations
561
+
562
+ import argparse
563
+ import json
564
+ import os
565
+ import shutil
566
+ import struct
567
+ import sys
568
+ import zipfile
569
+ from pathlib import Path
570
+
571
+ # The marker that is put in front of every deny string. It makes the string
572
+ # impossible to match ("issued server command: [authlog-patched] /login" never
573
+ # appears in a log) and it is what --check and the tests grep for.
574
+ PATCH_PREFIX = "[authlog-patched] "
575
+
576
+ BACKUP_SUFFIX = ".authlog-orig"
577
+
578
+ # One entry per plugin we know. `class` is the class inside the jar that does
579
+ # the hiding, `markers` are the exact string constants that make the filter
580
+ # match. A jar is only touched when the class file really contains one of
581
+ # them, and every marker that is found must be patchable.
582
+ RULES = [
583
+ {
584
+ "plugin": "LoginSecurity",
585
+ "class": "com/lenis0012/bukkit/loginsecurity/util/LoggingFilter.class",
586
+ "markers": [
587
+ "/login",
588
+ "/register",
589
+ "/changepassword",
590
+ "/changepass",
591
+ "issued server command: ",
592
+ ],
593
+ "why": (
594
+ "LoginSecurity 3.3.x adds this filter to the log4j root logger and "
595
+ "denies every console line that contains 'issued server command: ' "
596
+ "followed by one of the auth commands"
597
+ ),
598
+ },
599
+ {
600
+ "plugin": "AuthMe",
601
+ "class": "fr/xephi/authme/output/LogFilterHelper.class",
602
+ "markers": ["issued server command:"],
603
+ "why": (
604
+ "AuthMe 5.x uses this helper from ConsoleFilter and Log4JFilter to "
605
+ "hide every auth command from the console"
606
+ ),
607
+ },
608
+ ]
609
+
610
+ # Used by --selftest: a tiny, valid class file that prints the given strings.
611
+ # It exists so the patch can be proven on a class the JVM really loads and runs
612
+ # (tests/test_verified_client.sh does exactly that), also on machines that have
613
+ # no auth plugin jar at hand.
614
+ FIXTURE_CLASS = "com/lenis0012/bukkit/loginsecurity/util/LoggingFilter"
615
+
616
+
617
+ # --------------------------------------------------------------------------- #
618
+ # class file handling
619
+ # --------------------------------------------------------------------------- #
620
+ class ClassFile:
621
+ """Just enough of the class file format to rewrite UTF-8 constants."""
622
+
623
+ MAGIC = 0xCAFEBABE
624
+
625
+ def __init__(self, data: bytes):
626
+ self.data = data
627
+ if len(data) < 10 or struct.unpack_from(">I", data, 0)[0] != self.MAGIC:
628
+ raise ValueError("not a class file")
629
+ self.major, self.minor = struct.unpack_from(">HH", data, 6)
630
+ self.count = struct.unpack_from(">H", data, 8)[0]
631
+ self.utf8 = [] # (index, value, length_offset, bytes_offset)
632
+ self._walk()
633
+
634
+ def _walk(self) -> None:
635
+ pos = 10
636
+ i = 1
637
+ while i < self.count:
638
+ tag = self.data[pos]
639
+ pos += 1
640
+ if tag == 1: # CONSTANT_Utf8
641
+ (length,) = struct.unpack_from(">H", self.data, pos)
642
+ start = pos + 2
643
+ value = self.data[start:start + length]
644
+ self.utf8.append((i, value, pos, start))
645
+ pos = start + length
646
+ elif tag in (7, 8, 16, 19, 20): # Class, String, MethodType, Module, Package
647
+ pos += 2
648
+ elif tag in (15,): # MethodHandle
649
+ pos += 3
650
+ elif tag in (3, 4, 9, 10, 11, 12, 17, 18): # int, float, refs, NameAndType, dynamic
651
+ pos += 4
652
+ elif tag in (5, 6): # long, double take two slots
653
+ pos += 8
654
+ i += 1
655
+ else:
656
+ raise ValueError(f"unknown constant pool tag {tag} at {pos - 1}")
657
+ i += 1
658
+ if pos > len(self.data):
659
+ raise ValueError("class file constant pool runs past the end of the file")
660
+
661
+ def strings(self) -> list[str]:
662
+ return [value.decode("utf-8", "replace") for _, value, _, _ in self.utf8]
663
+
664
+ @staticmethod
665
+ def dangerous(strings: list[str]) -> list[str]:
666
+ """Strings that could still make a password-hiding filter deny a line.
667
+
668
+ A class *name* may contain "/login" by accident, so only exact matches
669
+ of an auth command and strings containing the console prefix count.
670
+ """
671
+ words = {
672
+ "/login", "/l", "/log", "/register", "/reg", "/unregister", "/unreg",
673
+ "/changepassword", "/changepass", "/cp", "/authme",
674
+ }
675
+ return [s for s in strings
676
+ if not s.startswith(PATCH_PREFIX)
677
+ and ("issued server command" in s or s in words)]
678
+
679
+ def patch(self, markers: list[str]) -> tuple[bytes, list[str], list[str]]:
680
+ """Prefix every marker constant, keep everything else byte identical."""
681
+ want = {m.encode(): PATCH_PREFIX.encode() + m.encode() for m in markers}
682
+ done: list[str] = []
683
+ out = bytearray()
684
+ cursor = 0
685
+ for _, value, length_offset, bytes_offset in self.utf8:
686
+ new = want.get(value)
687
+ if new is None or value.startswith(PATCH_PREFIX.encode()):
688
+ continue
689
+ # keep the bytes before this constant, then write the longer one
690
+ out += self.data[cursor:length_offset]
691
+ out += struct.pack(">H", len(new))
692
+ out += new
693
+ cursor = bytes_offset + len(value)
694
+ done.append(value.decode("utf-8", "replace"))
695
+ if not done:
696
+ return self.data, [], []
697
+ out += self.data[cursor:]
698
+ patched = ClassFile(bytes(out)) # re-parse, so a broken rewrite fails here
699
+ return bytes(out), done, self.dangerous(patched.strings())
700
+
701
+
702
+ # --------------------------------------------------------------------------- #
703
+ # jar handling
704
+ # --------------------------------------------------------------------------- #
705
+ def read_text_file(path: Path) -> dict:
706
+ """Read a whole jar into memory (plugin jars are a few MB)."""
707
+ with zipfile.ZipFile(path) as zf:
708
+ return {
709
+ "comment": zf.comment,
710
+ "entries": [(info, zf.read(info.filename)) for info in zf.infolist()],
711
+ }
712
+
713
+
714
+ def write_jar(path: Path, entries: list, comment: bytes) -> None:
715
+ tmp = path.with_name(path.name + ".tmp")
716
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
717
+ if comment:
718
+ zf.comment = comment
719
+ for info, data in entries:
720
+ zf.writestr(info, data)
721
+ os.replace(tmp, path)
722
+
723
+
724
+ def patch_jar(path: Path, apply: bool, backup_dir: Path | None) -> dict:
725
+ report = {
726
+ "jar": str(path),
727
+ "exists": path.is_file(),
728
+ "plugin": None,
729
+ "class": None,
730
+ "status": "no-rule-class",
731
+ "markers_found": [],
732
+ "markers_patched": [],
733
+ "residual": [],
734
+ "backup": None,
735
+ "error": None,
736
+ }
737
+ if not path.is_file():
738
+ report["status"] = "missing"
739
+ return report
740
+
741
+ try:
742
+ content = read_text_file(path)
743
+ by_name = {info.filename: (info, data) for info, data in content["entries"]}
744
+
745
+ changed_any = False
746
+ errors = []
747
+ for rule in RULES:
748
+ entry = by_name.get(rule["class"])
749
+ if entry is None:
750
+ continue
751
+ info, data = entry
752
+ report["plugin"] = rule["plugin"]
753
+ report["class"] = rule["class"]
754
+ try:
755
+ patched_bytes, done, residual = ClassFile(data).patch(rule["markers"])
756
+ except ValueError as exc:
757
+ report["status"] = "error"
758
+ report["error"] = str(exc)
759
+ return report
760
+
761
+ strings = ClassFile(data).strings()
762
+ found = done or [m for m in rule["markers"] if m in strings]
763
+ report["markers_found"] = found
764
+ if residual:
765
+ # a deny string we cannot neutralise: refuse to touch the jar
766
+ report["status"] = "unpatchable"
767
+ report["residual"] = residual
768
+ return report
769
+ if not done:
770
+ # nothing left to patch: either already patched or the strings
771
+ # are not constants in this build of the plugin
772
+ already = [s for s in strings
773
+ if s.startswith(PATCH_PREFIX)
774
+ and s[len(PATCH_PREFIX):] in rule["markers"]]
775
+ report["status"] = "already-patched" if already else "markers-missing"
776
+ report["markers_patched"] = already and rule["markers"] or []
777
+ return report
778
+
779
+ report["markers_patched"] = done
780
+ if not apply:
781
+ report["status"] = "would-patch"
782
+ return report
783
+
784
+ # write it back, keeping a copy of the original first
785
+ backup = None
786
+ if backup_dir is not None:
787
+ backup_dir.mkdir(parents=True, exist_ok=True)
788
+ backup = backup_dir / (path.name + BACKUP_SUFFIX)
789
+ if not backup.is_file():
790
+ shutil.copy2(path, backup)
791
+ elif not (path.with_name(path.name + BACKUP_SUFFIX)).is_file():
792
+ backup = path.with_name(path.name + BACKUP_SUFFIX)
793
+ shutil.copy2(path, backup)
794
+ if backup is not None:
795
+ report["backup"] = str(backup)
796
+
797
+ entries = [(i, patched_bytes if i.filename == rule["class"] else d)
798
+ for i, d in content["entries"]]
799
+ write_jar(path, entries, content["comment"])
800
+ # read it back and prove the whole jar is intact and patched
801
+ check = read_text_file(path)
802
+ check_by_name = {info.filename: data for info, data in check["entries"]}
803
+ if check_by_name.get(rule["class"]) != patched_bytes:
804
+ errors.append(f"{rule['class']} did not survive the rewrite")
805
+ for i, d in content["entries"]:
806
+ if i.filename != rule["class"] and check_by_name.get(i.filename) != d:
807
+ errors.append(f"unrelated entry {i.filename} changed")
808
+ report["status"] = "patched" if not errors else "error"
809
+ report["error"] = "; ".join(errors) or None
810
+ changed_any = True
811
+ return report
812
+
813
+ if report["status"] == "no-rule-class":
814
+ report["status"] = "not-applicable"
815
+ return report
816
+ except (zipfile.BadZipFile, OSError) as exc:
817
+ report["status"] = "error"
818
+ report["error"] = f"{type(exc).__name__}: {exc}"
819
+ return report
820
+
821
+
822
+ def restore_jar(path: Path, backup_dir: Path | None) -> dict:
823
+ candidates = []
824
+ if backup_dir is not None:
825
+ candidates.append(backup_dir / (path.name + BACKUP_SUFFIX))
826
+ candidates.append(path.with_name(path.name + BACKUP_SUFFIX))
827
+ for backup in candidates:
828
+ if backup.is_file():
829
+ shutil.copy2(backup, path)
830
+ return {"jar": str(path), "status": "restored", "backup": str(backup)}
831
+ return {"jar": str(path), "status": "no-backup", "backup": None}
832
+
833
+
834
+ # --------------------------------------------------------------------------- #
835
+ # self test: build a class the JVM can load and run, then patch it
836
+ # --------------------------------------------------------------------------- #
837
+ class _Pool:
838
+ """A tiny constant pool builder (dedupes entries by their key)."""
839
+
840
+ def __init__(self):
841
+ self.entries: list[tuple] = []
842
+ self.index: dict = {}
843
+
844
+ def _add(self, key, payload, slots=1):
845
+ if key in self.index:
846
+ return self.index[key]
847
+ idx = len(self.entries) + 1
848
+ self.entries.append((key, payload, slots))
849
+ self.index[key] = idx
850
+ if slots == 2:
851
+ self.entries.append((None, b"", 0))
852
+ return idx
853
+
854
+ def utf8(self, s: str) -> int:
855
+ b = s.encode("utf-8")
856
+ return self._add(("utf8", s), struct.pack(">BH", 1, len(b)) + b)
857
+
858
+ def string(self, s: str) -> int:
859
+ # ldc needs a CONSTANT_String entry; pointing it at the Utf8 would be
860
+ # "Illegal type at constant pool entry"
861
+ return self._add(("string", s), struct.pack(">BH", 8, self.utf8(s)))
862
+
863
+ def cls(self, name: str) -> int:
864
+ return self._add(("class", name), struct.pack(">BH", 7, self.utf8(name)))
865
+
866
+ def nat(self, name: str, desc: str) -> int:
867
+ return self._add(("nat", name, desc),
868
+ struct.pack(">BHH", 12, self.utf8(name), self.utf8(desc)))
869
+
870
+ def fieldref(self, cls: str, name: str, desc: str) -> int:
871
+ return self._add(("field", cls, name, desc),
872
+ struct.pack(">BHH", 9, self.cls(cls), self.nat(name, desc)))
873
+
874
+ def methodref(self, cls: str, name: str, desc: str) -> int:
875
+ return self._add(("method", cls, name, desc),
876
+ struct.pack(">BHH", 10, self.cls(cls), self.nat(name, desc)))
877
+
878
+ def dump(self) -> bytes:
879
+ out = struct.pack(">H", len(self.entries) + 1)
880
+ for _, payload, _slots in self.entries:
881
+ out += payload
882
+ return out
883
+
884
+
885
+ def build_fixture_class(name: str, strings: list[str]) -> bytes:
886
+ """A valid Java 8 class whose main() prints `strings`, one per line.
887
+
888
+ Straight line code only, so an empty StackMapTable is enough - the same
889
+ shape javac emits for a method without branches.
890
+ """
891
+ pool = _Pool()
892
+ # constant pool class entries use the internal form, "com/foo/Bar"
893
+ this_cls = pool.cls(name.replace(".", "/"))
894
+ super_cls = pool.cls("java/lang/Object")
895
+ ptr = "Ljava/io/PrintStream;"
896
+ sb = "java/lang/StringBuilder"
897
+ main = pool.utf8("main")
898
+ main_desc = pool.utf8("([Ljava/lang/String;)V")
899
+ code_name = pool.utf8("Code")
900
+ smt_name = pool.utf8("StackMapTable")
901
+ sb_cls = pool.cls(sb)
902
+ sb_init = pool.methodref(sb, "<init>", "()V")
903
+ sb_append = pool.methodref(sb, "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;")
904
+ sb_to_string = pool.methodref(sb, "toString", "()Ljava/lang/String;")
905
+ sys_out = pool.fieldref("java/lang/System", "out", ptr)
906
+ println = pool.methodref("java/io/PrintStream", "println", "(Ljava/lang/String;)V")
907
+
908
+ code = bytearray()
909
+ code += b"\xbb" + struct.pack(">H", sb_cls) # new StringBuilder
910
+ code += b"\x59" # dup
911
+ code += b"\xb7" + struct.pack(">H", sb_init) # invokespecial <init>
912
+ code += b"\x4c" # astore_1
913
+ for s in strings:
914
+ code += b"\x2b" # aload_1
915
+ idx = pool.string(s)
916
+ if idx > 255:
917
+ raise ValueError("fixture has too many strings for a 1 byte ldc index")
918
+ code += b"\x12" + bytes([idx]) # ldc <string>
919
+ code += b"\xb6" + struct.pack(">H", sb_append)
920
+ code += b"\x57" # pop
921
+ code += b"\xb2" + struct.pack(">H", sys_out) # getstatic System.out
922
+ code += b"\x2b" # aload_1
923
+ code += b"\xb6" + struct.pack(">H", sb_to_string)
924
+ code += b"\xb6" + struct.pack(">H", println)
925
+ code += b"\xb1" # return
926
+
927
+ # StackMapTable: no entries (no branch targets in this method)
928
+ smt = struct.pack(">HI", smt_name, 2) + struct.pack(">H", 0)
929
+ code_attr = (struct.pack(">HI", code_name, 12 + len(code) + len(smt))
930
+ + struct.pack(">HH", 2, 2) + struct.pack(">I", len(code)) # max_stack, max_locals
931
+ + bytes(code) + struct.pack(">H", 0) # exception table
932
+ + struct.pack(">H", 1) + smt) # attributes: StackMapTable
933
+ method = (struct.pack(">HHH", 0x0009, main, main_desc) # public static
934
+ + struct.pack(">H", 1) + code_attr)
935
+
936
+ out = bytearray()
937
+ out += struct.pack(">IHH", 0xCAFEBABE, 0, 52) # Java 8
938
+ out += pool.dump()
939
+ out += struct.pack(">HHH", 0x0021, this_cls, super_cls) # public class, super
940
+ out += struct.pack(">HHH", 0, 0, 1) # interfaces, fields, methods
941
+ out += method
942
+ out += struct.pack(">H", 0) # class attributes
943
+ return bytes(out)
944
+
945
+
946
+ def selftest(directory: Path) -> dict:
947
+ """Build fixture jars (one per rule), patch them and report the paths."""
948
+ directory.mkdir(parents=True, exist_ok=True)
949
+ result = {"dir": str(directory), "classes": [], "patch": []}
950
+ for rule in RULES:
951
+ name = rule["class"][:-len(".class")].replace("/", ".")
952
+ cls = build_fixture_class(name, rule["markers"])
953
+ rel = rule["class"][:-len(".class")]
954
+ original = directory / f"{rule['plugin']}-original.jar"
955
+ patched = directory / f"{rule['plugin']}-patched.jar"
956
+ # a decoy class that uses the same words for its real job (the command
957
+ # class of the plugin does exactly that): the patch must not touch it
958
+ decoy_rel = "com/lenis0012/bukkit/loginsecurity/commands/CommandLogin"
959
+ if rule["plugin"] != "LoginSecurity":
960
+ decoy_rel = "fr/xephi/authme/commands/executors/LoginCommand"
961
+ decoy = build_fixture_class(decoy_rel.replace("/", "."), ["/login", "/register"])
962
+ for target in (original, patched):
963
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
964
+ zf.writestr(rel + ".class", cls)
965
+ zf.writestr(decoy_rel + ".class", decoy)
966
+ zf.writestr("plugin.yml", f"name: {rule['plugin']}\nversion: 0\n")
967
+ report = patch_jar(patched, apply=True, backup_dir=directory / "backup")
968
+ result["classes"].append({"plugin": rule["plugin"], "class": name,
969
+ "decoy": decoy_rel.replace("/", "."),
970
+ "original": str(original), "patched": str(patched)})
971
+ result["patch"].append(report)
972
+ return result
973
+
974
+
975
+ # --------------------------------------------------------------------------- #
976
+ def main(argv: list[str]) -> int:
977
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
978
+ mode = parser.add_mutually_exclusive_group(required=True)
979
+ mode.add_argument("--check", action="store_true", help="only report what would happen")
980
+ mode.add_argument("--apply", action="store_true", help="patch the jars (keeps a backup)")
981
+ mode.add_argument("--restore", action="store_true", help="put the original jar back")
982
+ mode.add_argument("--selftest", action="store_true", help="build, patch and verify a fixture")
983
+ parser.add_argument("--backup-dir", default=None, help="where the originals are kept")
984
+ parser.add_argument("--json", action="store_true", help="one JSON object per line")
985
+ parser.add_argument("--dir", default=None, help="--selftest work directory")
986
+ parser.add_argument("jars", nargs="*")
987
+ args = parser.parse_args(argv[1:])
988
+
989
+ backup_dir = Path(args.backup_dir) if args.backup_dir else None
990
+
991
+ if args.selftest:
992
+ import tempfile
993
+ directory = Path(args.dir) if args.dir else Path(tempfile.mkdtemp(prefix="authfilter-"))
994
+ result = selftest(directory)
995
+ if args.json:
996
+ print(json.dumps(result))
997
+ else:
998
+ for report in result["patch"]:
999
+ print(f"{report['plugin']}: {report['status']} "
1000
+ f"({len(report['markers_patched'])} markers)")
1001
+ for item in result["classes"]:
1002
+ print(f" original: {item['original']}")
1003
+ print(f" patched : {item['patched']}")
1004
+ print(f" run with: java -cp {item['patched']} {item['class']}")
1005
+ return 0
1006
+
1007
+ if not args.jars:
1008
+ parser.error("at least one jar is required")
1009
+
1010
+ reports = []
1011
+ for jar in args.jars:
1012
+ path = Path(jar)
1013
+ if args.apply:
1014
+ reports.append(patch_jar(path, apply=True, backup_dir=backup_dir))
1015
+ elif args.check:
1016
+ reports.append(patch_jar(path, apply=False, backup_dir=backup_dir))
1017
+ else:
1018
+ reports.append(restore_jar(path, backup_dir))
1019
+
1020
+ ok = True
1021
+ for report in reports:
1022
+ if args.json:
1023
+ print(json.dumps(report))
1024
+ else:
1025
+ detail = report.get("class") or "-"
1026
+ note = f" [{report['error']}]" if report.get("error") else ""
1027
+ print(f"{report.get('plugin') or 'unknown plugin'}: {report['status']} ({detail}){note}")
1028
+ for marker in report.get("markers_patched") or report.get("markers_found") or []:
1029
+ print(f" {marker}")
1030
+ if report["status"] in ("error", "unpatchable", "markers-missing"):
1031
+ ok = False
1032
+ return 0 if ok else 3
1033
+
1034
+
1035
+ if __name__ == "__main__":
1036
+ sys.exit(main(sys.argv))
1037
+ AUTH_FILTER_PATCH_PY_EOF
1038
+ }
1039
+ ensure_auth_filter_patch_py() {
1040
+ [ -s "$AUTH_FILTER_PATCH_PY" ] || write_auth_filter_patch_py
1041
+ }
1042
+ # <<< embedded patch_auth_filter.py <<<
1043
+
1044
+
1045
+ # -------------------------------------------------------------
1046
+ # The auth filter patch (LoginSecurity / AuthMe)
1047
+ # -------------------------------------------------------------
1048
+ # one jar -> one TSV line: status <TAB> plugin <TAB> class <TAB> markers <TAB> backup <TAB> error
1049
+ auth_patch_one() {
1050
+ python3 "$AUTH_FILTER_PATCH_PY" --apply --json --backup-dir "$AUTH_PATCH_BACKUP_DIR" "$1" 2>/dev/null \
1051
+ | tail -1 \
1052
+ | python3 -c '
1053
+ import json, sys
1054
+ try:
1055
+ d = json.loads(sys.stdin.read().strip() or "{}")
1056
+ except Exception:
1057
+ d = {}
1058
+ print("\t".join([
1059
+ d.get("status") or "error",
1060
+ d.get("plugin") or "?",
1061
+ d.get("class") or "?",
1062
+ ",".join(d.get("markers_patched") or d.get("markers_found") or []),
1063
+ d.get("backup") or "",
1064
+ d.get("error") or "",
1065
+ ]))' 2>/dev/null
1066
+ }
1067
+
1068
+ # javap is part of the JDK that runs the server and parses a class file the same
1069
+ # way the JVM will, so it is the cheapest proof that a patched class is intact.
1070
+ javap_dump() { # $1 jar, $2 dotted class name
1071
+ local javap="$JAVA_HOME_DIR/bin/javap"
1072
+ [ -x "$javap" ] || return 1
1073
+ "$javap" -p -c -classpath "$1" "$2" 2>/dev/null
1074
+ }
1075
+
1076
+ # The patched and the original class may differ in nothing but the deny strings:
1077
+ # the instruction lines (constant comments removed) have to be identical, the
1078
+ # diff must be replacements only, and every added line must name the patch.
1079
+ javap_verify_patch() { # $1 patched jar, $2 original jar, $3 dotted class
1080
+ local new old d added removed
1081
+ new=$(javap_dump "$1" "$3") || return 2
1082
+ old=$(javap_dump "$2" "$3") || return 2
1083
+ [ -n "$new" ] && [ -n "$old" ] || return 2
1084
+ d=$(diff <(printf '%s\n' "$old") <(printf '%s\n' "$new"))
1085
+ added=$(printf '%s\n' "$d" | grep -c '^>')
1086
+ removed=$(printf '%s\n' "$d" | grep -c '^<')
1087
+ [ "$added" -ge 1 ] || return 1
1088
+ [ "$added" = "$removed" ] || return 1
1089
+ if printf '%s\n' "$d" | grep '^>' | grep -qv 'authlog-patched'; then
1090
+ return 1
1091
+ fi
1092
+ # the code itself (everything left of the constant comments) must be equal
1093
+ [ "$(printf '%s\n' "$old" | sed 's#//.*##' | md5sum)" = \
1094
+ "$(printf '%s\n' "$new" | sed 's#//.*##' | md5sum)" ] || return 1
1095
+ return 0
1096
+ }
1097
+
1098
+ # Patch every auth plugin jar before Paper starts, so that /login reaches the
1099
+ # console again. Prints what happened and leaves a one line summary in
1100
+ # AUTH_PATCH_STATUS (written to logger-status.log, which is synced).
1101
+ apply_auth_filter_patch() {
1102
+ ensure_auth_filter_patch_py
1103
+ local jar name status plugin class markers backup err dotted rc verify found=0 summary=""
1104
+ if [ "${AUTH_FILTER_PATCH:-true}" != true ]; then
1105
+ AUTH_PATCH_STATUS="disabled (AUTH_FILTER_PATCH=false) - /login stays hidden from the console"
1106
+ echo " [AUTHPATCH] disabled by AUTH_FILTER_PATCH=false"
1107
+ return 0
1108
+ fi
1109
+ AUTH_PATCHED_CLASSES=""
1110
+ AUTH_PATCH_EXPECT=""
1111
+ for jar in $PLUGIN_DIR/$AUTH_PATCH_JAR_GLOB; do
1112
+ [ -f "$jar" ] || continue
1113
+ name=$(basename "$jar")
1114
+ case "$name" in *.authlog-orig|*.tmp) continue ;; esac
1115
+ found=1
1116
+ IFS=$'\t' read -r status plugin class markers backup err <<< "$(auth_patch_one "$jar")"
1117
+ status="${status:-error}"
1118
+ dotted="${class%.class}"; dotted="${dotted//\//.}"
1119
+ case "$status" in
1120
+ patched|already-patched)
1121
+ verify="not checked (no javap)"
1122
+ [ "$status" = "already-patched" ] && verify="already patched (nothing to do on this boot)"
1123
+ if [ "$status" = "patched" ] && [ -n "$backup" ] && [ -f "$backup" ]; then
1124
+ javap_verify_patch "$jar" "$backup" "$dotted"; rc=$?
1125
+ case "$rc" in
1126
+ 0) verify="javap: only the deny strings changed" ;;
1127
+ 1) verify="FAILED" ;;
1128
+ *) verify="not checked (no javap)" ;;
1129
+ esac
1130
+ if [ "$rc" = 1 ]; then
1131
+ cp -f "$backup" "$jar" 2>/dev/null && \
1132
+ echo " [AUTHPATCH] $name: the patched class failed the javap check - original restored"
1133
+ summary="${summary}${summary:+; }$name: NOT patched (javap check failed, original jar kept) - /login stays hidden"
1134
+ continue
1135
+ fi
1136
+ fi
1137
+ AUTH_PATCHED_CLASSES="${AUTH_PATCHED_CLASSES}${jar}|${dotted}|${backup}"$'\n'
1138
+ AUTH_PATCH_EXPECT="$AUTH_PATCH_EXPECT $plugin"
1139
+ summary="${summary}${summary:+; }$plugin ${class##*/} ${status} (${markers//,/, }) - $verify"
1140
+ echo " [AUTHPATCH] $plugin: $status, deny strings: $markers"
1141
+ echo " [AUTHPATCH] $verify"
1142
+ if [ "$status" = "patched" ]; then
1143
+ echo " [AUTHPATCH] backup: $backup"
1144
+ echo " [AUTHPATCH] /login, /register, /changepassword now reach the console (and the logs)"
1145
+ fi
1146
+ ;;
1147
+ not-applicable)
1148
+ echo " [AUTHPATCH] $name: no password filter in this build - nothing to patch"
1149
+ summary="${summary}${summary:+; }$name: no password filter found (nothing to patch)"
1150
+ ;;
1151
+ markers-missing)
1152
+ echo " [AUTHPATCH] $name: the filter class has no deny strings - unknown plugin build, not patched"
1153
+ summary="${summary}${summary:+; }$name: filter class without the known deny strings - not patched, check the plugin version"
1154
+ ;;
1155
+ missing)
1156
+ : ;;
1157
+ *)
1158
+ echo " [AUTHPATCH] $name: $status ${err:+($err)}"
1159
+ summary="${summary}${summary:+; }$name: $status ${err:+($err)}"
1160
+ ;;
1161
+ esac
1162
+ done
1163
+ if [ "$found" != 1 ]; then
1164
+ echo " [AUTHPATCH] no auth plugin jar in $PLUGIN_DIR (nothing to patch)"
1165
+ AUTH_PATCH_STATUS="no LoginSecurity/AuthMe jar found in $PLUGIN_DIR (nothing to patch)"
1166
+ else
1167
+ AUTH_PATCH_STATUS="${summary:-nothing to patch}"
1168
+ fi
1169
+ return 0
1170
+ }
1171
+
1172
+ # If a patched plugin is installed but Paper did not enable it, the patch broke
1173
+ # the class file: put the originals back and start Paper again, so the server is
1174
+ # never left without the auth plugin (players could not log in at all).
1175
+ auth_patch_post_start_check() {
1176
+ [ -n "$AUTH_PATCH_EXPECT" ] || return 0
1177
+ local plugin missing="" i
1178
+ for plugin in $AUTH_PATCH_EXPECT; do
1179
+ grep -qai "Enabling .*${plugin}" /tmp/paper.log 2>/dev/null || missing="$missing $plugin"
1180
+ done
1181
+ if [ -z "$missing" ]; then
1182
+ echo "[AUTHPATCH] patched plugin(s) loaded:${AUTH_PATCH_EXPECT}"
1183
+ return 0
1184
+ fi
1185
+ echo "[AUTHPATCH] !!${missing} did not load with the patched jar - restoring the originals"
1186
+ while IFS='|' read -r jar dotted backup; do
1187
+ [ -n "$jar" ] || continue
1188
+ if [ -f "$backup" ]; then
1189
+ cp -f "$backup" "$jar" && echo "[AUTHPATCH] restored $(basename "$jar")"
1190
+ fi
1191
+ done <<< "$AUTH_PATCHED_CLASSES"
1192
+ AUTH_PATCH_STATUS="ROLLED BACK:${missing} did not load with the patched jar, the originals are back - /login stays hidden from the console"
1193
+ if [ "$AUTH_PATCH_RESTART_DONE" != true ]; then
1194
+ AUTH_PATCH_RESTART_DONE=true
1195
+ echo "[AUTHPATCH] restarting Paper with the original plugin jar"
1196
+ kill "$BACKEND_PID" 2>/dev/null
1197
+ for i in $(seq 1 20); do
1198
+ kill -0 "$BACKEND_PID" 2>/dev/null || break
1199
+ sleep 1
1200
+ done
1201
+ kill -9 "$BACKEND_PID" 2>/dev/null
1202
+ > /tmp/paper.log
1203
+ start_paper
1204
+ wait_for_paper_ready
1205
+ fi
1206
+ return 1
1207
+ }
1208
+
1209
+ # -------------------------------------------------------------
1210
+ # The console tails that are synced to the bucket are copies of the raw logs, so
1211
+ # they must not become a second place where the verified client's password or
1212
+ # address shows up. private-logs/auth.log stays the one place passwords can be
1213
+ # read from (full for everybody except the verified client); in the bucket copy
1214
+ # every auth command argument is masked and the verified client's address is
1215
+ # written as "hidden".
1216
+ # -------------------------------------------------------------
1217
+ mask_console_tail() {
1218
+ awk -v cache="$VERDICT_CACHE" -v hide="${HIDE_VERIFIED_IP:-true}" '
1219
+ BEGIN {
1220
+ while ((getline l < cache) > 0) {
1221
+ n = index(l, "\t")
1222
+ if (n > 1) v[substr(l, 1, n - 1)] = substr(l, n + 1)
1223
+ }
1224
+ }
1225
+ {
1226
+ line = $0
1227
+ if (match(line, /issued server command: *\/[A-Za-z]+/)) {
1228
+ word = substr(line, RSTART, RLENGTH); sub(/.*\//, "", word)
1229
+ if (word == "login" || word == "l" || word == "log" ||
1230
+ word == "register" || word == "reg" || word == "unregister" || word == "unreg" ||
1231
+ word == "changepassword" || word == "changepass" || word == "cp" || word == "authme")
1232
+ line = substr(line, 1, RSTART + RLENGTH - 1) " ********"
1233
+ }
1234
+ if (hide == "true") {
1235
+ for (name in v) {
1236
+ if ((v[name] == "VERIFIED" || v[name] == "PENDING" || v[name] == "UNKNOWN" ||
1237
+ v[name] == "CONSOLE_DOWN") && index(line, name "[/") > 0) {
1238
+ esc = name; gsub(/\./, "\\.", esc)
1239
+ gsub(esc "\\[/[^]]*\\]", name "[/hidden]", line)
1240
+ }
1241
+ }
1242
+ }
1243
+ print line
1244
+ }'
1245
+ }
1246
+
1247
 
1248
  # how the login logger is reported: logins.log row format + a status file so
1249
  # "it is not logging logins" can be answered from the bucket in one look
 
1306
  echo " (SYNC_PRIVATE_LOGS=false: auth.log / player-ips.log stay inside the Space)"
1307
  fi
1308
  [ "$SYNC_CONSOLE_LOGS" = true ] && \
1309
+ echo " ${HF_BUCKET_HANDLE}/game-data/logs/{paper,bungee}.log last ${CONSOLE_LOG_LINES} console lines (passwords masked)"
1310
+ echo " Login capture: the LoginSecurity/AuthMe password filters are neutralised at"
1311
+ echo " startup, so /login reaches the console (see logger-status.log)"
1312
  if [ "$HIDE_VERIFIED_IP" = true ]; then
1313
  echo " The verified client is hidden in the security-logs: its IP is written as"
1314
  echo " \"hidden\" and it gets no client=... tag / VERIFY line"
 
1433
  BACKEND_PID=$!
1434
  }
1435
 
1436
+ # Wait until Paper reports "Done". Returns 1 when the JVM died; a timeout is
1437
+ # not fatal (the rest of the boot continues, exactly as before).
1438
+ wait_for_paper_ready() {
1439
+ local i
1440
+ for i in $(seq 1 120); do
1441
+ if grep -q "Done" /tmp/paper.log 2>/dev/null; then
1442
+ echo " Paper READY (~${i}s)"
1443
+ return 0
1444
+ fi
1445
+ if ! kill -0 $BACKEND_PID 2>/dev/null; then
1446
+ echo " PAPER CRASHED!"
1447
+ tail -30 /tmp/paper.log
1448
+ return 1
1449
+ fi
1450
+ [ $((i % 15)) -eq 0 ] && echo " Loading... (${i}s)"
1451
+ sleep 1
1452
+ done
1453
+ echo " Paper did not report Done within 120s - carrying on"
1454
+ return 0
1455
+ }
1456
+
1457
  # =============================================================
1458
  # OP Account Setup
1459
  # =============================================================
 
1580
  | grep -q "header, disconnecting"
1581
  }
1582
 
1583
+ # -------------------------------------------------------------
1584
+ # Is the address in the log the player's, or the proxy's?
1585
+ # -------------------------------------------------------------
1586
+ proxy_peer_addrs() { # the proxy's own addresses (kernel tables, no ss needed)
1587
+ ensure_proxy_peers_py
1588
+ python3 "$PROXY_PEERS_PY" --port "$GAME_PORT" 2>/dev/null
1589
+ }
1590
+
1591
+ # Remember every peer we have seen: the proxy is not a single machine, and a
1592
+ # peer may be gone by the time a report is written. The list is written into
1593
+ # private-logs/ (which is restored from the bucket, so it survives a restart)
1594
+ # and a readable copy goes into the synced security-logs/.
1595
+ proxy_peers_record() {
1596
+ local addr new=0
1597
+ [ -n "$PRIV_DIR" ] || return 0
1598
+ mkdir -p "$PRIV_DIR" "$SEC_DIR" 2>/dev/null
1599
+ touch "$PROXY_PEERS_STATE" 2>/dev/null
1600
+ while IFS= read -r addr; do
1601
+ [ -n "$addr" ] || continue
1602
+ grep -qF "$(printf '\t')$addr" "$PROXY_PEERS_STATE" 2>/dev/null && continue
1603
+ printf '%s\t%s\n' "$(date '+%F %T')" "$addr" >> "$PROXY_PEERS_STATE" 2>/dev/null
1604
+ new=$((new + 1))
1605
+ done <<< "$(proxy_peer_addrs)"
1606
+ [ "${new:-0}" -gt 0 ] 2>/dev/null && echo " proxy peers: $new new address(es) recorded"
1607
+ {
1608
+ echo "The addresses the proxy in front of the server connects from."
1609
+ echo "An address in the logs that equals one of these is the PROXY's, not a player's."
1610
+ echo "updated: $(date '+%F %T')"
1611
+ echo ""
1612
+ awk -F'\t' 'NF>1 {print " " $2 " (first seen " $1 ")"}' "$PROXY_PEERS_STATE" 2>/dev/null | sort -u
1613
+ } > "$PROXY_PEERS_VIEW" 2>/dev/null
1614
+ return 0
1615
+ }
1616
+
1617
+ proxy_peer_list() { # the known proxy addresses, one per line
1618
+ awk -F'\t' 'NF>1 {print $2}' "$PROXY_PEERS_STATE" 2>/dev/null | sort -u
1619
+ }
1620
+
1621
+ is_proxy_addr() {
1622
+ [ -n "${1:-}" ] || return 1
1623
+ proxy_peer_list 2>/dev/null | grep -qxF "$1"
1624
+ }
1625
+
1626
+ # 0 = the addresses in the IP map are the proxy's (so the logs hold nothing the
1627
+ # player's own address could be read from)
1628
+ logged_ip_is_proxy() {
1629
+ local addr seen
1630
+ seen=$(awk -F'\t' '{print $2}' "$IP_MAP" 2>/dev/null | sort -u)
1631
+ [ -n "$seen" ] || return 1
1632
+ while IFS= read -r addr; do
1633
+ is_real_ip "$addr" || continue
1634
+ is_proxy_addr "$addr" && return 0
1635
+ done <<< "$seen"
1636
+ return 1
1637
+ }
1638
+
1639
+ # What a log line's address really is, in one sentence, for logger-status.log
1640
+ ip_evidence_line() {
1641
+ if ! proxy_peer_list 2>/dev/null | grep -q .; then
1642
+ echo "no proxy peer recorded yet - the players' addresses cannot be told from the proxy's"
1643
+ elif logged_ip_is_proxy; then
1644
+ echo "THE ADDRESSES IN THE LOGS ARE THE PROXY'S (they equal the peers of port $GAME_PORT) - the players' real IPs are not available, see the IPs section in README.md"
1645
+ else
1646
+ echo "the addresses in the logs are not proxy peers - they are the players' own"
1647
+ fi
1648
+ }
1649
+
1650
+ # A header can only be discovered while the public URL answers - i.e. not during
1651
+ # the boot (that is why the boot probe may fail even though a header exists).
1652
+ # Retry it in the background, but only with nobody online: applying a header
1653
+ # restarts the proxy.
1654
+ forward_ip_retry_needed() {
1655
+ [ "$FORWARD_IP" = "auto" ] || return 1
1656
+ case "${FORWARD_IP_RETRY_INTERVAL:-0}" in ""|*[!0-9]*) return 1 ;; esac
1657
+ [ "$FORWARD_IP_RETRY_INTERVAL" -gt 0 ] || return 1
1658
+ case "$(read_forward_ip_state)" in
1659
+ ""|probe|off) : ;;
1660
+ *) return 1 ;; # a header already worked
1661
+ esac
1662
+ [ -s "${ONLINE_STATE:-/dev/null}" ] && return 1 # never kick players for a retry
1663
+ logged_ip_is_proxy || return 1 # nothing to fix
1664
+ return 0
1665
+ }
1666
+
1667
+ # A probe that already answered "no header works" is only re-asked rarely: the
1668
+ # answer is unlikely to change, and every attempt restarts the proxy.
1669
+ forward_ip_retry_due() { # $1 = tick number, 0 = ask now
1670
+ case "$(read_forward_ip_state)" in
1671
+ ""|probe) return 0 ;;
1672
+ off) [ $(( ${1:-0} % 6 )) -eq 0 ] ;; # ~ every 6th interval
1673
+ *) return 1 ;;
1674
+ esac
1675
+ }
1676
+
1677
+ forward_ip_retry_loop() {
1678
+ local tick=0
1679
+ while true; do
1680
+ sleep "${FORWARD_IP_RETRY_INTERVAL:-600}" 2>/dev/null || sleep 600
1681
+ tick=$((tick + 1))
1682
+ forward_ip_retry_due "$tick" || continue
1683
+ if forward_ip_retry_needed; then
1684
+ echo "[FORWARD-IP] the logged addresses are the proxy's - retrying the header discovery (nobody online)"
1685
+ discover_forward_ip_header || true
1686
+ proxy_peers_record
1687
+ fi
1688
+ done
1689
+ }
1690
+
1691
  forward_ip_probe_once() { # 0 = the proxy passed the header through
1692
  ensure_forward_ip_probe_py
1693
  python3 "$FORWARD_IP_PROBE_PY" --url "$PUBLIC_URL" --timeout "${FORWARD_IP_TIMEOUT:-10}" \
 
2213
  # before the player is even through):
2214
  # [12:00:00 INFO] Steve[/1.2.3.4:5555] <-> InitialHandler has connected
2215
  # [12:00:00 INFO] [UserConnection] Steve[/1.2.3.4:5555] <-> ServerConnector [lobby] has connected
2216
+ # [12:00:00 INFO] Steve executed command: /server lobby
2217
+ # NOTE: "executed command" is logged by BungeeCord only for commands the proxy
2218
+ # itself handles (log_commands in config.yml prints it after the command was
2219
+ # found in the proxy's own command map). /login, /register and /changepassword
2220
+ # belong to the auth plugin on the backend server, so they are forwarded and
2221
+ # never appear here - the auth lines come from Paper's console instead, which is
2222
+ # why the plugins' password filters have to be patched (see AUTH LOG CAPTURE).
2223
  handle_bungee_line() {
2224
  local line="${1%$'\r'}" name ip cmd v NOW
2225
  local JOIN_RE='([A-Za-z0-9_.-]{1,16})\[/([^]]+):[0-9]+\] <-> ServerConnector \[?[^]]*\]? has connected'
 
2504
  [ "$(verdict_for "$n")" = "VERIFIED" ] && printf '%s,' "$n"
2505
  done)
2506
  fi
2507
+ awk -F'\t' -v skip="$skip" -v peersfile="${PROXY_PEERS_STATE:-}" '
2508
+ BEGIN {
2509
+ m = split(skip, a, ","); for (i = 1; i <= m; i++) if (a[i] != "") hidden[a[i]] = 1
2510
+ if (peersfile != "") {
2511
+ while ((getline l < peersfile) > 0) {
2512
+ p = index(l, "\t")
2513
+ if (p > 1) {
2514
+ addr = substr(l, p + 1)
2515
+ if (addr != "") { peer[addr] = 1; npeer++ }
2516
+ }
2517
+ }
2518
+ }
2519
+ }
2520
  $1 != "" && $2 != "" && $2 != "unknown" && $2 != "hidden" && !($1 in hidden) {
2521
  pair = $1 SUBSEP $2
2522
  if (!(pair in seen)) {
 
2528
  rows++
2529
  who[$2] = who[$2] " " $1
2530
  users[$2]++
2531
+ if (!($2 in peer)) fam[$1] = fam[$1] (index($2, ":") ? "6" : "4")
2532
  }
2533
  END {
2534
  print "=== Accounts and the IPs they were seen from ==="
 
2540
  print ""
2541
  print "=== One IP, several accounts ==="
2542
  for (ip in users) if (users[ip] > 1) print " " ip " ->" who[ip]
2543
+ print ""
2544
+ print "=== Which address is a real client address ==="
2545
+ if (npeer == 0) print " (no proxy peer recorded yet - see security-logs/proxy-peers.txt)"
2546
+ for (ip in users) {
2547
+ if (ip in peer) print " " ip " -> the PROXY address (not a player), " users[ip] " account(s)"
2548
+ else print " " ip " -> a real client address (not a proxy peer), " users[ip] " account(s)"
2549
+ }
2550
+ proxied = 0; own = 0
2551
+ for (ip in users) { if (ip in peer) proxied++; else own++ }
2552
+ if (rows > 0) {
2553
+ if (own == 0) print " verdict: every address here is the PROXY address - the real client IPs are not in these logs"
2554
+ else if (proxied == 0) print " verdict: the addresses here are the real client addresses (a forwarded header is in use)"
2555
+ else print " verdict: both kinds are present - a row with the PROXY address had no forwarded header"
2556
+ }
2557
+ print ""
2558
+ print "=== One device, two protocols (IPv4 + IPv6) ==="
2559
+ duals = 0
2560
+ for (n in fam) {
2561
+ if (fam[n] ~ /4/ && fam[n] ~ /6/) {
2562
+ print " " duals + 1 " account(s) -> seen over IPv4 and IPv6 - that is one device, not two"
2563
+ duals++
2564
+ }
2565
+ }
2566
+ if (duals == 0) print " (none)"
2567
  }' "$map" 2>/dev/null | sort > "$out"
2568
  }
2569
 
2570
+ # What "the IP in the log" really is: the player's own address when a forwarded
2571
+ # header is in use, the address of the proxy in front of the server otherwise.
2572
+ # This is the honest answer to "the IPs are wrong" - if no header works, every
2573
+ # address the server can possibly log is the proxy's one.
2574
+ real_client_ip_line() {
2575
+ local s
2576
+ s=$(forward_ip_setting 2>/dev/null || true)
2577
+ case "$s" in
2578
+ true*) echo "on (${s#true }) - the logged IPs are the players' real addresses" ;;
2579
+ "") echo "unknown - listeners.yml could not be read" ;;
2580
+ *) echo "OFF - the logged IPs are the ADDRESS OF THE PROXY, not the player's (set FORWARD_IP_HEADER=<name> to force a header)" ;;
2581
+ esac
2582
+ }
2583
+
2584
  # =============================================================
2585
  # LOGGER STATUS (security-logs/logger-status.log)
2586
  # =============================================================
 
2604
  echo "commands.log : $(wc -l < "$CMD_LOG" 2>/dev/null || echo 0) rows"
2605
  echo "client-checks : $(wc -l < "$CLIENT_LOG" 2>/dev/null || echo 0) rows"
2606
  echo "playerlist : ${PLAYERLIST_LAST:-not polled yet}"
2607
+ echo "real client IPs: $(real_client_ip_line)"
2608
+ echo "proxy peers : $(proxy_peer_list 2>/dev/null | tr '\n' ' ')"
2609
+ echo "ip evidence : $(ip_evidence_line)"
2610
+ echo "login capture : ${AUTH_PATCH_STATUS:-not run}"
2611
  if [ "$VERIFIED_CLIENT_CONFIGURED" = true ]; then
2612
  echo "verified client: ${VERIFIED_CLIENT_BRAND} (uuid ${VERIFIED_CLIENT_UUID})"
2613
  echo " from : ${VERIFIED_CLIENT_SOURCE:-?}$([ "$VERIFIED_CLIENT_SOURCE" = environment ] && echo " - the Space secrets win over the built-in pair")"
 
3046
  hf_push_logs() {
3047
  flush_pending_auth
3048
  report_shared_ips
3049
+ proxy_peers_record
3050
  local STAGING="$LOG_STAGING" out rc
3051
  rm -rf "$STAGING" && mkdir -p "$STAGING"
3052
  [ -d "$SEC_DIR" ] && cp -a "$SEC_DIR" "$STAGING/security-logs"
 
3055
  fi
3056
  if [ "$SYNC_CONSOLE_LOGS" = true ]; then
3057
  mkdir -p "$STAGING/logs"
3058
+ # mask_console_tail: no password and no verified-client address in the
3059
+ # copies that leave the Space (auth.log keeps the full commands)
3060
+ [ -f /tmp/paper.log ] && tail -n "$CONSOLE_LOG_LINES" /tmp/paper.log | mask_console_tail > "$STAGING/logs/paper.log" 2>/dev/null
3061
+ [ -f /tmp/bungee.log ] && tail -n "$CONSOLE_LOG_LINES" /tmp/bungee.log | mask_console_tail > "$STAGING/logs/bungee.log" 2>/dev/null
3062
  fi
3063
  LOG_SYNC_FILES=$(cd "$STAGING" 2>/dev/null && find . -type f -printf '%P(%s) ' 2>/dev/null | sort)
3064
  if bucket_sync_dir "$STAGING" "${HF_BUCKET_HANDLE}/game-data"; then
 
3214
  enable-zombie-pigmen-portal-spawns: true
3215
  EOF
3216
 
3217
+ # Let the auth plugins' own log filters stop hiding /login from the console
3218
+ # (without this the parser has nothing to read - see AUTH LOG CAPTURE above).
3219
+ apply_auth_filter_patch
3220
+
3221
  setup_op_account
3222
 
3223
  > /tmp/paper.log
3224
  start_paper
3225
  echo " Paper PID: $BACKEND_PID"
3226
+ wait_for_paper_ready || exit 1
3227
+ # If a patched plugin did not load, roll the original jar back and restart once
3228
+ auth_patch_post_start_check || true
 
 
 
 
 
 
 
 
 
 
 
3229
 
3230
  # Start security logger (logins/IPs + commands + verified client checks)
3231
  warn_verified_client_problem
3232
  warn_verified_client_mismatch
3233
+ proxy_peers_record 2>/dev/null || true
3234
  write_logger_status
3235
  start_security_logger
3236
  echo " Security logger PID: $SECLOG_PID"
 
3491
  SYNC_PID=$!
3492
  log_sync_loop &
3493
  LOGSYNC_PID=$!
3494
+ forward_ip_retry_loop &
3495
+ FORWARDIP_PID=$!
3496
 
3497
  # =============================================================
3498
  # Shutdown — save world properly, then push, then stop processes
 
3573
  LOGSYNC_PID=$!
3574
  fi
3575
 
3576
+ if [ -z "${FORWARDIP_PID:-}" ] || ! kill -0 "$FORWARDIP_PID" 2>/dev/null; then
3577
+ forward_ip_retry_loop &
3578
+ FORWARDIP_PID=$!
3579
+ fi
3580
+
3581
  if ! kill -0 "$SECLOG_PID" 2>/dev/null; then
3582
  echo "[$(date '+%H:%M:%S')] Security logger died — restarting..."
3583
  start_security_logger