Spaces:
Running
Running
Update start.sh
Browse files
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="$
|
| 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: /
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: $(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1972 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 2135 |
-
|
| 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
|