202 lines
6.6 KiB
Python
202 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import dns.resolver
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import socket
|
|
import dataclasses
|
|
from typing import TYPE_CHECKING
|
|
|
|
from mcstatus import JavaServer, BedrockServer
|
|
from mcstatus.responses import JavaStatusResponse
|
|
from mcstatus.motd import Motd
|
|
|
|
if TYPE_CHECKING:
|
|
from typing_extensions import TypeAlias
|
|
|
|
SupportedServers: TypeAlias = "JavaServer | BedrockServer"
|
|
|
|
PING_PACKET_FAIL_WARNING = (
|
|
"warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n"
|
|
" this is likely a bug in the server-side implementation.\n"
|
|
' (note: ping packet failed due to "{ping_exc}")\n'
|
|
" for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n"
|
|
)
|
|
|
|
QUERY_FAIL_WARNING = (
|
|
"The server did not respond to the query protocol."
|
|
"\nPlease ensure that the server has enable-query turned on,"
|
|
" and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)."
|
|
"\nSee https://minecraft.wiki/w/Query for further information."
|
|
)
|
|
|
|
|
|
def _motd(motd: Motd) -> str:
|
|
"""Formats MOTD for human-readable output, with leading line break
|
|
if multiline."""
|
|
s = motd.to_ansi()
|
|
return f"\n{s}" if "\n" in s else f" {s}"
|
|
|
|
|
|
def _kind(serv: SupportedServers) -> str:
|
|
if isinstance(serv, JavaServer):
|
|
return "Java"
|
|
elif isinstance(serv, BedrockServer):
|
|
return "Bedrock"
|
|
else:
|
|
raise ValueError(f"unsupported server for kind: {serv}")
|
|
|
|
|
|
def _ping_with_fallback(server: SupportedServers) -> float:
|
|
# bedrock doesn't have ping method
|
|
if isinstance(server, BedrockServer):
|
|
return server.status().latency
|
|
|
|
# try faster ping packet first, falling back to status with a warning.
|
|
ping_exc = None
|
|
try:
|
|
return server.ping(tries=1)
|
|
except Exception as e:
|
|
ping_exc = e
|
|
|
|
latency = server.status().latency
|
|
|
|
address = f"{server.address.host}:{server.address.port}"
|
|
print(
|
|
PING_PACKET_FAIL_WARNING.format(address=address, ping_exc=ping_exc),
|
|
file=sys.stderr,
|
|
)
|
|
|
|
return latency
|
|
|
|
|
|
def ping_cmd(server: SupportedServers) -> int:
|
|
print(_ping_with_fallback(server))
|
|
return 0
|
|
|
|
|
|
def status_cmd(server: SupportedServers) -> int:
|
|
response = server.status()
|
|
|
|
java_res = response if isinstance(response, JavaStatusResponse) else None
|
|
|
|
if not java_res:
|
|
player_sample = ""
|
|
elif java_res.players.sample is not None:
|
|
player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample])
|
|
else:
|
|
player_sample = "No players online"
|
|
|
|
if player_sample:
|
|
player_sample = " " + player_sample
|
|
|
|
print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})")
|
|
print(f"motd:{_motd(response.motd)}")
|
|
print(f"players: {response.players.online}/{response.players.max}{player_sample}")
|
|
print(f"ping: {response.latency:.2f} ms")
|
|
return 0
|
|
|
|
|
|
def json_cmd(server: SupportedServers) -> int:
|
|
data = {"online": False, "kind": _kind(server)}
|
|
|
|
status_res = query_res = exn = None
|
|
try:
|
|
status_res = server.status(tries=1)
|
|
except Exception as e:
|
|
exn = exn or e
|
|
|
|
try:
|
|
if isinstance(server, JavaServer):
|
|
query_res = server.query(tries=1)
|
|
except Exception as e:
|
|
exn = exn or e
|
|
|
|
# construct 'data' dict outside try/except to ensure data processing errors
|
|
# are noticed.
|
|
data["online"] = bool(status_res or query_res)
|
|
if not data["online"]:
|
|
assert exn, "server offline but no exception?"
|
|
data["error"] = str(exn)
|
|
|
|
if status_res is not None:
|
|
data["status"] = dataclasses.asdict(status_res)
|
|
|
|
# ensure we are overwriting the motd and not making a new dict field
|
|
assert "motd" in data["status"], "motd field missing. has it been renamed?"
|
|
data["status"]["motd"] = status_res.motd.simplify().to_minecraft()
|
|
|
|
if query_res is not None:
|
|
# TODO: QueryResponse is not (yet?) a dataclass
|
|
data["query"] = qdata = {}
|
|
|
|
qdata["ip"] = query_res.raw["hostip"]
|
|
qdata["port"] = query_res.raw["hostport"]
|
|
qdata["map"] = query_res.map_name
|
|
qdata["plugins"] = query_res.software.plugins
|
|
qdata["raw"] = query_res.raw
|
|
|
|
json.dump(data, sys.stdout)
|
|
return 0
|
|
|
|
|
|
def query_cmd(server: SupportedServers) -> int:
|
|
if not isinstance(server, JavaServer):
|
|
print("The 'query' protocol is only supported by Java servers.", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
response = server.query()
|
|
except socket.timeout:
|
|
print(QUERY_FAIL_WARNING, file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
|
|
print(f"software: {_kind(server)} {response.software.version} {response.software.brand}")
|
|
print(f"motd:{_motd(response.motd)}")
|
|
print(f"plugins: {response.software.plugins}")
|
|
print(f"players: {response.players.online}/{response.players.max} {response.players.list}")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] = sys.argv[1:]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
"mcstatus",
|
|
description="""
|
|
mcstatus provides an easy way to query 1.7 or newer Minecraft servers for any
|
|
information they can expose. It provides three modes of access: query, status,
|
|
ping and json.
|
|
""",
|
|
)
|
|
|
|
parser.add_argument("address", help="The address of the server.")
|
|
parser.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true")
|
|
|
|
subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.")
|
|
parser.set_defaults(func=status_cmd)
|
|
|
|
subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd)
|
|
subparsers.add_parser("status", help="Prints server status.").set_defaults(func=status_cmd)
|
|
subparsers.add_parser(
|
|
"query", help="Prints detailed server information. Must be enabled in servers' server.properties file."
|
|
).set_defaults(func=query_cmd)
|
|
subparsers.add_parser(
|
|
"json",
|
|
help="Prints server status and query in json.",
|
|
).set_defaults(func=json_cmd)
|
|
|
|
args = parser.parse_args(argv)
|
|
lookup = JavaServer.lookup if not args.bedrock else BedrockServer.lookup
|
|
|
|
try:
|
|
server = lookup(args.address)
|
|
return args.func(server)
|
|
except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers, ConnectionError, TimeoutError) as e:
|
|
# catch and hide traceback for expected user-facing errors
|
|
print(f"Error: {e!r}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|