mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-27 16:07:15 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3131 lines
151 KiB
Python
3131 lines
151 KiB
Python
#!/usr/bin/env python3
|
|
"""The dedicated server's protocol simulator: the HTTP API, the RCON over
|
|
WebSocket and the admin page of docs/dedicated_server.md, with a scripted
|
|
server behind them and no game.
|
|
|
|
python3 tools/server_sim.py --port 8099 then open http://127.0.0.1:8099/
|
|
|
|
It serves the page's files from port/assets/server_ui (read at each request,
|
|
so an edit shows on a reload), plays games one after another with players
|
|
joining, chatting, killing and leaving, a speed hack and a kick, and keeps
|
|
the doc's rules: the headers, sessions, CSRF and Origin, the roles,
|
|
lockout. It is the page's development server and the reference that the
|
|
page's tests (tools/server_ui_tests) and third-party tools test against.
|
|
|
|
Like the real server: --trusted-proxy <address> believes that proxy's
|
|
X-Forwarded-For (its last entry), -Host and -Proto (and only then marks the
|
|
cookie Secure); --request-limit counts each address's requests (60 a minute
|
|
without a session, 600 with one, WebSocket upgrades too: off by default, as
|
|
the tests sign in often). --owner-password signs in as the owner from
|
|
loopback alone (a connection's own address in 127.0.0.0/8, not the trusted
|
|
proxy's), as server.web_owner_password does. A stop (quit, sv_stop,
|
|
sv_restart, a drain's end) is said (the shutdown event) and the simulator
|
|
goes on, unless --exit-on-stop: then it exits with the stop's code.
|
|
|
|
Fault injection: --slow-consumer (each WebSocket is written slowly and a
|
|
burst follows a subscription, so its queue overflows), --drop (WebSockets
|
|
are cut without a close frame a few seconds after they open, and one API
|
|
request in ten loses its connection unanswered), --big-replies (command
|
|
replies of more than 1 MiB), --fragment (every message the server sends is
|
|
split into continuation frames, with a ping between them).
|
|
|
|
The first line on standard output is `listening on http://<address>:<port>`
|
|
(with --port 0 a free port is taken). Standard library only.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import http
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import secrets
|
|
import socket
|
|
import socketserver
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
UI_DIR = ROOT / "port" / "assets" / "server_ui"
|
|
|
|
ROLES = ["viewer", "moderator", "admin", "owner"]
|
|
SUBPROTOCOL = "opence-rcon-1"
|
|
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
MAXIMUM_HEAD = 8 * 1024
|
|
MAXIMUM_BODY = 64 * 1024
|
|
HEAD_SECONDS = 10
|
|
SESSION_LIFETIME = 12 * 3600
|
|
SESSION_IDLE = 30 * 60
|
|
MAXIMUM_SESSIONS = 16
|
|
LOG_RING = 2048
|
|
EVENT_RING = 512
|
|
WS_QUEUE = 1024
|
|
WS_CONNECTIONS = 8
|
|
WS_FRAME = 64 * 1024
|
|
WS_MESSAGE = 256 * 1024
|
|
BUILD = "build-206"
|
|
COMMAND_LINE = 511
|
|
ROTATION_TEXT = 4096
|
|
ENTRY_TEXT = 255
|
|
NOTICE_SIZE = 128
|
|
NETWORK_VERSION = 38
|
|
# the RCON's hello's capabilities, and the status's (server_http.c's server_http_capabilities)
|
|
CAPABILITIES = ["events", "status", "rotation", "settings", "bans", "owner_web", "ban_address", "stop",
|
|
"event_addresses", "identity"]
|
|
PUBLISHER_KEY_ID = "3f9a2c7d1e5b8a04"
|
|
DRAINS = ("now", "game", "empty", "cancel")
|
|
|
|
CSP = "default-src 'self'; frame-ancestors 'none'"
|
|
SECURITY_HEADERS = [
|
|
("Cache-Control", "no-store"),
|
|
("X-Content-Type-Options", "nosniff"),
|
|
# (same-origin, not no-referrer: under no-referrer a browser sends the page's own posts with Origin: null)
|
|
("Referrer-Policy", "same-origin"),
|
|
("Content-Security-Policy", CSP),
|
|
]
|
|
ASSETS = {
|
|
"/": ("index.html", "text/html; charset=utf-8"),
|
|
"/index.html": ("index.html", "text/html; charset=utf-8"),
|
|
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
|
"/app.css": ("app.css", "text/css; charset=utf-8"),
|
|
"/icon.svg": ("icon.svg", "image/svg+xml"),
|
|
}
|
|
|
|
# ---------- the CSP check on the page's files (the build's embedder uses it too)
|
|
|
|
_JS_FORBIDDEN = [
|
|
(r"\binnerHTML\b|\bouterHTML\b|\binsertAdjacentHTML\b", "HTML set from a string"),
|
|
(r"\bdocument\.write", "document.write"),
|
|
(r"(?<![\w.])eval\s*\(", "eval"),
|
|
(r"\bnew\s+Function\b", "new Function"),
|
|
(r"\bset(?:Timeout|Interval)\s*\(\s*['\"`]", "a timer given a string"),
|
|
(r"setAttribute\(\s*['\"](?:style|on[a-z]+)['\"]", "a style or event handler attribute"),
|
|
(r"\.style\.cssText|\.style\s*=", "an inline style"),
|
|
(r"https?://", "an external address"),
|
|
]
|
|
|
|
|
|
def csp_problems(name, text):
|
|
"""what in one of the page's files the page's Content-Security-Policy
|
|
(default-src 'self') would block or the project forbids: inline scripts
|
|
and styles, style= and on*= attributes, string evaluation, external
|
|
requests; an empty list when there is nothing"""
|
|
problems = []
|
|
kind = name.rsplit(".", 1)[-1].lower()
|
|
if kind in ("html", "svg"):
|
|
for match in re.finditer(r"<script\b([^>]*)>(.*?)</script\s*>", text, re.I | re.S):
|
|
if kind == "svg" or not re.search(r"\bsrc\s*=", match.group(1), re.I) or match.group(2).strip():
|
|
problems.append("an inline <script>")
|
|
if re.search(r"<style\b", text, re.I):
|
|
problems.append("a <style> element")
|
|
for tag in re.finditer(r"<[A-Za-z][^>]*>", text):
|
|
if re.search(r"\sstyle\s*=", tag.group(0), re.I):
|
|
problems.append("a style= attribute: " + tag.group(0)[:60])
|
|
if re.search(r"\son[a-z]+\s*=", tag.group(0), re.I):
|
|
problems.append("an on*= attribute: " + tag.group(0)[:60])
|
|
if re.search(r"javascript:", text, re.I):
|
|
problems.append("a javascript: address")
|
|
for match in re.finditer(r"\b(?:src|href|xlink:href|action|srcset|poster|data)\s*=\s*[\"']?\s*((?:https?:)?//)",
|
|
text, re.I):
|
|
tag_start = text.rfind("<", 0, match.start())
|
|
tag = text[tag_start:match.start()]
|
|
if not re.match(r"<a\b", tag, re.I):
|
|
problems.append("an external request: " + text[match.start():match.start() + 60])
|
|
if kind == "svg" and re.search(r"<foreignObject\b", text, re.I):
|
|
problems.append("a foreignObject")
|
|
elif kind == "js":
|
|
for pattern, what in _JS_FORBIDDEN:
|
|
for match in re.finditer(pattern, text):
|
|
line = text.count("\n", 0, match.start()) + 1
|
|
problems.append(f"{what} (line {line})")
|
|
elif kind == "css":
|
|
if re.search(r"@import\b", text, re.I):
|
|
problems.append("an @import")
|
|
for match in re.finditer(r"url\(\s*['\"]?([^'\")]*)", text, re.I):
|
|
target = match.group(1).strip()
|
|
if re.match(r"(?:[a-z]+:|//)", target, re.I):
|
|
problems.append("a url() the policy blocks: " + target[:60])
|
|
if re.search(r"expression\s*\(", text, re.I):
|
|
problems.append("an expression()")
|
|
return problems
|
|
|
|
|
|
# ---------- what the simulated server has
|
|
|
|
MAPS = [
|
|
("beavercreek", "Battle Creek"), ("bloodgulch", "Blood Gulch"), ("boardingaction", "Boarding Action"),
|
|
("carousel", "Derelict"), ("chillout", "Chill Out"), ("damnation", "Damnation"),
|
|
("dangercanyon", "Danger Canyon"), ("deathisland", "Death Island"), ("gephyrophobia", "Gephyrophobia"),
|
|
("hangemhigh", "Hang 'Em High"), ("icefields", "Ice Fields"), ("infinity", "Infinity"),
|
|
("longest", "Longest"), ("prisoner", "Prisoner"), ("putput", "Chiron TL-34"), ("ratrace", "Rat Race"),
|
|
("sidewinder", "Sidewinder"), ("timberland", "Timberland"), ("wizard", "Wizard"),
|
|
]
|
|
# (a Custom Edition map whose name the game shows is unknown: its title is its name)
|
|
CUSTOM_MAPS = [("hugeass", "Hugeass"), ("bigassv2", None)]
|
|
|
|
# the built-in gametypes (port/linux/game/server_gametypes.c's, then OpenCE's
|
|
# of game_modes.c): name -> (engine, teams, variant); /api/gametypes sends
|
|
# their names alone
|
|
BUILTINS = {
|
|
"slayer": ("slayer", False, "Slayer"), "team_slayer": ("slayer", True, "Team Slayer"),
|
|
"slayer_pro": ("slayer", False, "Slayer Pro"), "elimination": ("slayer", False, "Elimination"),
|
|
"phantoms": ("slayer", False, "Phantoms"), "endurance": ("slayer", False, "Endurance"),
|
|
"rockets": ("slayer", False, "Rockets"), "snipers": ("slayer", False, "Snipers"),
|
|
"stalker": ("oddball", False, "Stalker"),
|
|
"ctf": ("ctf", True, "CTF"), "ironctf": ("ctf", True, "Iron CTF"), "ctf_pro": ("ctf", True, "CTF Pro"),
|
|
"invasion": ("ctf", True, "Invasion"),
|
|
"oddball": ("oddball", False, "Oddball"), "team_oddball": ("oddball", True, "Team Ball"),
|
|
"reverse_tag": ("oddball", False, "Reverse Tag"), "accumulation": ("oddball", False, "Accumulate"),
|
|
"juggernaut": ("oddball", False, "Juggernaut"),
|
|
"king": ("king", False, "King"), "team_king": ("king", True, "Team King"), "king_pro": ("king", False, "King Pro"),
|
|
"crazy_king": ("king", False, "Crazy King"),
|
|
"race": ("race", False, "Race"), "team_race": ("race", True, "Team Race"), "rally": ("race", False, "Rally"),
|
|
"team_rally": ("race", True, "Team Rally"),
|
|
"swat": ("slayer", True, "SWAT"), "infection": ("slayer", True, "Infection"), "gun_game": ("slayer", False, "Gun Game"),
|
|
"hardcore": ("slayer", True, "Hardcore"), "hardcore_ctf": ("ctf", True, "HC CTF"),
|
|
"hardcore_oddball": ("oddball", True, "HC Oddball"), "hardcore_king": ("king", True, "HC King"),
|
|
}
|
|
GAMETYPE_ALIASES = {"ffa": "slayer", "ffa_slayer": "slayer", "teamslayer": "team_slayer", "tslayer": "team_slayer",
|
|
"koth": "king", "team_koth": "team_king", "iron_ctf": "ironctf", "teamrace": "team_race",
|
|
"teamoddball": "team_oddball"}
|
|
|
|
LOADOUT_WEAPONS = ["none", "random", "assault_rifle", "pistol", "shotgun", "sniper_rifle", "rocket_launcher",
|
|
"plasma_pistol", "plasma_rifle", "needler"]
|
|
|
|
|
|
def _field(table, key, kind, description, engine="all", alias=None, low=None, high=None, choices=None):
|
|
if kind == "boolean":
|
|
low, high = None, None
|
|
return {"key": key, "alias": alias, "table": table, "engine": engine, "type": kind, "min": low, "max": high,
|
|
"choices": choices, "default": None, "description": description}
|
|
|
|
|
|
# (port/linux/src/server_rotation.c's server_rule_definitions)
|
|
SCHEMA = [
|
|
_field("universal", "score_to_win", "integer", "The score that wins (kills, captures, laps, seconds of the ball "
|
|
"or the hill...); 0 for no limit", alias="score", low=0, high=999),
|
|
_field("universal", "lives", "integer", "Lives each player has; 0 for no limit", low=0, high=99),
|
|
_field("universal", "respawn_time", "integer", "Seconds before a dead player respawns", alias="respawn", low=0,
|
|
high=300),
|
|
_field("universal", "respawn_time_growth", "integer", "Seconds added to the respawn at each death", low=0, high=300),
|
|
_field("universal", "suicide_penalty", "integer", "Seconds added to a suicide's respawn", low=0, high=300),
|
|
_field("universal", "health", "integer", "Players' health, in percent", low=25, high=400),
|
|
_field("universal", "teams", "boolean", "Teams (CTF always has them)"),
|
|
_field("universal", "odd_man_out", "boolean", "The player in the lead is shown to everyone"),
|
|
_field("universal", "radar", "boolean", "Players on the motion tracker"),
|
|
_field("universal", "friend_indicators", "boolean", "Teammates' markers over their heads"),
|
|
_field("universal", "infinite_grenades", "boolean", "Grenades never run out"),
|
|
_field("universal", "no_shields", "boolean", "Players have no shields"),
|
|
_field("universal", "invisible", "boolean", "Players are always camouflaged"),
|
|
_field("universal", "generic_equipment", "boolean", "Every map starts players with the same weapons"),
|
|
_field("universal", "weapon_set", "choice", "The weapons on the map", alias="weapons",
|
|
choices=["normal", "pistols", "rifles", "plasma", "sniper", "no_sniping", "rocket_launchers", "shotguns",
|
|
"short_range", "human", "no_grenades", "covenant", "classic", "heavy"]),
|
|
_field("universal", "vehicle_set", "choice", "The vehicles on the map", alias="vehicles",
|
|
choices=["default", "none", "warthog", "ghost", "scorpion"]),
|
|
_field("universal", "goal_radar", "choice", "How the flag, the ball or the hill is shown",
|
|
choices=["motion_tracker", "nav_point", "none"]),
|
|
_field("options", "time_limit", "integer", "The game's minutes; 0 for no limit", alias="time", low=0, high=1440),
|
|
_field("options", "friendly_fire", "choice", "Damage to teammates", alias="ff",
|
|
choices=["on", "off", "shields_only", "explosives_only"]),
|
|
_field("options", "friendly_fire_penalty", "integer", "Seconds added to a team killer's respawn", low=0, high=300),
|
|
_field("options", "vehicle_respawn_time", "integer", "Seconds before a destroyed vehicle respawns; 0 for never",
|
|
low=0, high=300),
|
|
_field("options", "auto_team_balance", "boolean", "Joining players go to the smaller team", alias="balance"),
|
|
_field("options", "radar_players", "choice", "Which players the motion tracker shows",
|
|
choices=["all", "friends", "none"]),
|
|
_field("options", "loadout", "choice", "The starting weapons: the weapon set's (category), or primary and "
|
|
"secondary (custom)", choices=["category", "custom"]),
|
|
_field("options", "primary_weapon", "choice", "A custom loadout's first weapon", choices=LOADOUT_WEAPONS),
|
|
_field("options", "secondary_weapon", "choice", "A custom loadout's second weapon", choices=LOADOUT_WEAPONS),
|
|
_field("options", "no_map_weapons", "boolean", "No weapons lie on the map"),
|
|
_field("options", "team_vehicles", "choice", "Each team's vehicles: the vehicle set's (gametype), every one the "
|
|
"map places (pc), or the counts below (custom)", choices=["gametype", "pc", "custom"]),
|
|
_field("options", "warthogs", "integer", "Warthogs of each team, with team_vehicles custom", low=0, high=4),
|
|
_field("options", "ghosts", "integer", "Ghosts of each team, with team_vehicles custom", low=0, high=4),
|
|
_field("options", "scorpions", "integer", "Scorpions of each team, with team_vehicles custom", low=0, high=4),
|
|
_field("options", "rocket_warthogs", "integer", "Rocket Warthogs of each team, with team_vehicles custom", low=0,
|
|
high=4),
|
|
_field("options", "banshees", "integer", "Banshees of each team, with team_vehicles custom", low=0, high=4),
|
|
_field("options", "gun_turrets", "integer", "Gun turrets of each team, with team_vehicles custom", low=0, high=4),
|
|
_field("options", "hardcore", "boolean", "Hardcore: no motion sensor, no aim assist, a kill restores the "
|
|
"killer's health and shields"),
|
|
_field("ctf", "assault", "boolean", "One team defends the flag, the other attacks", engine="ctf"),
|
|
_field("ctf", "reset_on_capture", "boolean", "Both flags go home at a capture", engine="ctf"),
|
|
_field("ctf", "flag_must_reset", "boolean", "A dropped flag goes home only by its timer", engine="ctf"),
|
|
_field("ctf", "flag_at_home_to_score", "boolean", "A team's own flag must be home to score", engine="ctf"),
|
|
_field("ctf", "single_flag_time", "integer", "Seconds of each side's turn in a single flag game", engine="ctf",
|
|
low=0, high=600),
|
|
_field("slayer", "no_death_bonus", "boolean", "No point for a kill after a death", engine="slayer"),
|
|
_field("slayer", "no_kill_penalty", "boolean", "No point taken for a suicide", engine="slayer"),
|
|
_field("slayer", "kill_in_order", "boolean", "Players must be killed in order", engine="slayer"),
|
|
_field("king", "moving_hill", "boolean", "The hill moves", engine="king"),
|
|
_field("oddball", "random_start", "boolean", "The ball starts at a random place", engine="oddball"),
|
|
_field("oddball", "ball_spawn_delay", "boolean", "The ball respawns after a delay", engine="oddball"),
|
|
_field("oddball", "speed_with_ball", "integer", "The ball carrier's speed", engine="oddball", low=0, high=3),
|
|
_field("oddball", "trait_with_ball", "integer", "The ball carrier's trait", engine="oddball", low=0, high=3),
|
|
_field("oddball", "trait_without_ball", "integer", "The others' trait", engine="oddball", low=0, high=3),
|
|
_field("oddball", "ball_type", "integer", "The kind of ball game", engine="oddball", low=0, high=2),
|
|
_field("oddball", "ball_spawn_count", "integer", "Balls on the map", engine="oddball", low=1, high=16),
|
|
_field("race", "race_type", "integer", "The kind of race", engine="race", low=0, high=2),
|
|
_field("race", "team_scoring", "integer", "How a team's laps are counted", engine="race", low=0, high=2),
|
|
]
|
|
SCHEMA_BY_KEY = {field["key"]: field for field in SCHEMA}
|
|
SCHEMA_BY_ALIAS = {field["alias"]: field for field in SCHEMA if field["alias"]}
|
|
|
|
GAMETYPE_FILES = {
|
|
"btb_slayer": 'base = "team_slayer"\nname = "BTB Slayer"\n\n'
|
|
'[universal]\nscore_to_win = 50\nweapon_set = "normal"\n\n[options]\ntime_limit = 15\n'
|
|
'friendly_fire = "off"\nteam_vehicles = "pc"\n',
|
|
"pistols-only": 'base = "team_slayer"\nname = "Pistols"\n\n'
|
|
'[universal]\nno_shields = true\nradar = false\nweapon_set = "pistols"\n',
|
|
}
|
|
|
|
|
|
def _setting(name, kind, default, comment, env, secret=False, choices=None, low=None, high=None):
|
|
return {"name": name, "type": kind, "value": default, "default": default, "comment": comment, "env": env,
|
|
"pinned": False, "secret": secret, "choices": choices, "min": low, "max": high}
|
|
|
|
|
|
def default_settings():
|
|
"""the settings sv_set changes (server_commands.c's settable_settings, in
|
|
port_config.c's order), as GET /api/settings sends them: typed values,
|
|
and choices, min and max where they are known (null otherwise)"""
|
|
return [
|
|
_setting("network.shield_effects", "string", "stock", "Host: the shield effects every player of a network "
|
|
"game sees, whatever their own.", "HALO_NET_SHIELD_EFFECTS",
|
|
choices=["stock", "glow", "light_show", "overshield", "glow_overshield", "light_show_overshield",
|
|
"players"]),
|
|
_setting("network.voice_lobby", "boolean", True, "Hosting: voice chat in the lobby, before and after a game.",
|
|
"HALO_NET_VOICE_LOBBY"),
|
|
_setting("network.voice_mode", "string", "team_global_enemy_proximity", "Hosting: voice chat in a game.",
|
|
"HALO_NET_VOICE_MODE", choices=["off", "team_proximity", "team_enemy_proximity", "team_global",
|
|
"team_global_enemy_proximity"]),
|
|
_setting("network.voice_kbps", "integer", 24, "Hosting: the voices' quality, in kilobits a second (8 to 64).",
|
|
"HALO_NET_VOICE_KBPS", low=8, high=64),
|
|
_setting("network.text_chat", "string", "on", "Hosting: the text chat: \"on\" (links in lines are refused), "
|
|
"\"links\" or \"off\".", "HALO_NET_TEXT_CHAT", choices=["on", "links", "off"]),
|
|
_setting("network.votekick", "boolean", True, "Hosting: let the players vote to kick a player.",
|
|
"HALO_NET_VOTEKICK"),
|
|
_setting("network.votekick_minutes", "integer", 5, "Hosting: the minutes a player must have played on this "
|
|
"server to start a vote to kick (0 to 60).", "HALO_NET_VOTEKICK_MINUTES", low=0, high=60),
|
|
_setting("network.votekick_ban_minutes", "integer", 30, "Hosting: the minutes a player kicked by a vote "
|
|
"cannot join again (1 to 1440).", "HALO_NET_VOTEKICK_BAN_MINUTES", low=1, high=1440),
|
|
_setting("network.map_share", "boolean", True, "Hosting: send a player who joins without the game's Custom "
|
|
"Edition map the map.", "HALO_NET_MAP_SHARE"),
|
|
_setting("network.map_share_upload", "integer", 1024, "Hosting: the most kilobytes a second the host sends "
|
|
"of maps (16 to 20480).", "HALO_NET_MAP_SHARE_UPLOAD", low=16, high=20480),
|
|
_setting("network.coop_friendly_fire", "string", "on", "Whether the players of an online co-op game hurt "
|
|
"each other.", "HALO_NET_COOP_FRIENDLY_FIRE", choices=["off", "on", "shields_only", "explosives_only"]),
|
|
_setting("network.coop_ally_friendly_fire", "boolean", True, "Whether the players of an online co-op game "
|
|
"hurt their AI allies.", "HALO_NET_COOP_ALLY_FRIENDLY_FIRE"),
|
|
_setting("network.coop_player_collisions", "boolean", True, "Whether the players of an online co-op game bump "
|
|
"into each other.", "HALO_NET_COOP_PLAYER_COLLISIONS"),
|
|
_setting("network.public_address", "string", "", "Hosting: the internet address joiners reach this machine "
|
|
"at, as host[:port].", "HALO_NET_PUBLIC_ADDRESS"),
|
|
_setting("server.name", "string", "OpenCE Server", "The game's name in the lobby and the server browser: 15 "
|
|
"characters of printable ASCII.", "HALO_SERVER_NAME"),
|
|
_setting("server.max_players", "integer", 16, "The most players (2 to 127).", "HALO_SERVER_MAX_PLAYERS",
|
|
low=2, high=127),
|
|
_setting("server.min_players", "integer", 1, "Players in the lobby before it counts down to a game (1 to 16).",
|
|
"HALO_SERVER_MIN_PLAYERS", low=1, high=16),
|
|
_setting("server.password", "string", "", "The password the server browser asks for (the invite link still "
|
|
"joins); empty for none. Never logged.", "HALO_SERVER_PASSWORD", secret=True),
|
|
_setting("server.public", "boolean", True, "List the server in everyone's server browser.",
|
|
"HALO_SERVER_PUBLIC"),
|
|
_setting("server.rotation_fallback", "boolean", False, "With no playable rotation entry, play "
|
|
"bloodgulch:slayer rather than stop (exit code 3).", "HALO_SERVER_ROTATION_FALLBACK"),
|
|
_setting("server.lobby_seconds", "integer", 15, "Seconds the lobby counts down once min_players are in (0 to "
|
|
"300; 0 starts at once).", "HALO_SERVER_LOBBY_SECONDS", low=0, high=300),
|
|
_setting("server.postgame_seconds", "integer", 12, "Seconds the scores show after a game (0 to 300).",
|
|
"HALO_SERVER_POSTGAME_SECONDS", low=0, high=300),
|
|
_setting("server.empty_seconds", "integer", 30, "Seconds a game nobody is in goes on before it ends (0: "
|
|
"never).", "HALO_SERVER_EMPTY_SECONDS"),
|
|
_setting("server.idle_minutes", "integer", 0, "Minutes a game goes on with no score before it ends (0: "
|
|
"never).", "HALO_SERVER_IDLE_MINUTES"),
|
|
_setting("server.team_start", "string", "one", "A team game: \"one\" starts with one team's players, "
|
|
"\"both\" waits for a player on each team, \"skip\" skips team games while one player waits.",
|
|
"HALO_SERVER_TEAM_START", choices=["one", "both", "skip"]),
|
|
_setting("server.motd", "string", "", "A notice each machine is shown as it joins (127 characters); empty "
|
|
"for none.", "HALO_SERVER_MOTD"),
|
|
_setting("server.frame_rate", "integer", 60, "Frames a second (30 to 120); the game's ticks stay 30 a second.",
|
|
"HALO_SERVER_FRAME_RATE", low=30, high=120),
|
|
_setting("server.web_password", "string", "", "The web page's admin password (8 characters or more).",
|
|
"HALO_SERVER_WEB_PASSWORD", secret=True),
|
|
_setting("server.web_moderator_password", "string", "", "A second password, with the moderator role; empty "
|
|
"for none.", "HALO_SERVER_WEB_MODERATOR_PASSWORD", secret=True),
|
|
_setting("server.metrics", "boolean", True, "Serve /metrics (Prometheus) on the web port without a password.",
|
|
"HALO_SERVER_METRICS"),
|
|
_setting("server.log_format", "string", "text", "The standard output's lines: \"text\" or \"json\".",
|
|
"HALO_SERVER_LOG_FORMAT", choices=["text", "json"]),
|
|
_setting("server.log_addresses", "boolean", False, "Players' internet addresses and hardware ids in the log, "
|
|
"the events and the API for every role (the owner's console has them always).",
|
|
"HALO_SERVER_LOG_ADDRESSES"),
|
|
_setting("server.status_file", "string", "", "A file the status (as /api/status) is written to every 2 "
|
|
"seconds, for a panel without HTTP; empty for none.", "HALO_SERVER_STATUS_FILE"),
|
|
_setting("server.auto_update", "string", "off", "\"off\"; \"check\" logs a newer release; \"install\" "
|
|
"installs it once the server has been empty for 10 minutes.", "HALO_SERVER_AUTO_UPDATE",
|
|
choices=["off", "check", "install"]),
|
|
_setting("server.coop_difficulty", "string", "normal", "Co-op rotation entries' (<level>:coop) difficulty.",
|
|
"HALO_SERVER_COOP_DIFFICULTY", choices=["easy", "normal", "hard", "impossible"]),
|
|
_setting("server.master_publish", "boolean", True, "Publish a public server to the official master servers.",
|
|
"HALO_SERVER_MASTER_PUBLISH"),
|
|
_setting("server.region", "string", "", "A region shown in the server browser (eu, na-east...): 16 "
|
|
"characters; empty for none.", "HALO_SERVER_REGION"),
|
|
_setting("server.master_publish_address", "boolean", False, "Let the master servers give this server's "
|
|
"address to players.", "HALO_SERVER_MASTER_PUBLISH_ADDRESS"),
|
|
]
|
|
|
|
|
|
# what the owner's console alone changes (server_commands.c's owner_settings)
|
|
OWNER_SETTINGS = {"server.status_file", "server.auto_update", "server.log_addresses"}
|
|
|
|
PLAYER_NAMES = ["Milenko", "Kestrel", "Wren", "Odin", "Harlow", "Juno", "Vega", "Corvid", "Tamsin", "Brask",
|
|
"Ilse", "Rook", "Sable", "Quill", "Fenwick", "Marlo"]
|
|
CHAT_LINES = ["gg", "nice shot", "who has the flag?", "rockets up top", "lag?", "one more", "camping sniper again",
|
|
"rematch after this", "behind you!", "ez"]
|
|
|
|
DURATION_UNITS = {"": 60, "m": 60, "s": 1, "h": 3600, "d": 86400, "w": 7 * 86400, "y": 365 * 86400}
|
|
|
|
|
|
class CommandError(Exception):
|
|
"""a refusal: an HTTP status and a code (before a line runs), or (with
|
|
status None) a command's failure, its message the line it says"""
|
|
|
|
def __init__(self, code, message, status=400, lines=None, **extra):
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.message = message
|
|
self.status = status
|
|
self.lines = lines or []
|
|
self.extra = extra
|
|
|
|
|
|
class Failed(Exception):
|
|
"""a command that failed: the line it said (the real server's commands
|
|
say why, and the API reads the code from that line)"""
|
|
|
|
def __init__(self, line):
|
|
super().__init__(line)
|
|
self.line = line
|
|
|
|
|
|
def now_ms():
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
def json_bytes(value):
|
|
return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def split_words(text):
|
|
"""the words of a command line (server_commands.c's split_words): spaces
|
|
apart, a "quoted" word whole (no escapes), 8 at most"""
|
|
words = []
|
|
for match in re.finditer(r'"([^"]*)"?|([^\s"][^ \t]*)', text):
|
|
words.append(match.group(1) if match.group(1) is not None else match.group(2))
|
|
if len(words) == 8:
|
|
break
|
|
return words
|
|
|
|
|
|
def rest_after_words(line, count):
|
|
"""the text after the line's first count words (a notice's, a reason's)"""
|
|
position = 0
|
|
for _ in range(count):
|
|
while position < len(line) and line[position] in " \t":
|
|
position += 1
|
|
if position < len(line) and line[position] == '"':
|
|
end = line.find('"', position + 1)
|
|
position = len(line) if end < 0 else end + 1
|
|
else:
|
|
while position < len(line) and line[position] not in " \t":
|
|
position += 1
|
|
return line[position:].lstrip(" \t")
|
|
|
|
|
|
def printable_text(text, size):
|
|
"""a text kept to printable ASCII, its surrounding quotes off, size - 1
|
|
characters at most (server_commands.c's printable_text)"""
|
|
text = text.strip("".join(chr(code) for code in range(0x21)) + "\x7f")
|
|
if len(text) > 1 and text[0] == text[-1] == '"':
|
|
text = text[1:-1]
|
|
return "".join(character for character in text if 0x20 <= ord(character) < 0x7F)[:size - 1].rstrip(" ")
|
|
|
|
|
|
def line_text(text):
|
|
"""a text the API puts into a line: no quotes, no control characters,
|
|
127 at most (server_http.c's line_text)"""
|
|
return "".join(character for character in text if 0x20 <= ord(character) < 0x7F and character != '"')[:127]
|
|
|
|
|
|
def parse_duration(text):
|
|
"""30m, 2h, 7d, 1w, 45s, a bare number of minutes, or forever (0): the
|
|
seconds, -1 if it is none (ten years at most)"""
|
|
if text.lower() in ("forever", "permanent") or text == "0":
|
|
return 0
|
|
match = re.fullmatch(r"(\d+)([smhdwy]?)", text or "")
|
|
if not match:
|
|
return -1
|
|
number = int(match.group(1))
|
|
if number <= 0 or number > 100000:
|
|
return -1
|
|
seconds = number * DURATION_UNITS[match.group(2)]
|
|
return -1 if seconds > 10 * 365 * 86400 else seconds
|
|
|
|
|
|
def rule_value(field, text):
|
|
"""a rule's value as the real server keeps it: a whole number (true 1,
|
|
false 0, a choice its index); ValueError with the real server's reason"""
|
|
lowered = text.lower()
|
|
if field["type"] == "integer":
|
|
if len(text) > 9 or not re.fullmatch(r"-?\d+", text):
|
|
raise ValueError(f"{field['key']} takes a whole number, not \"{text}\"")
|
|
value = int(text)
|
|
if value < field["min"] or value > field["max"]:
|
|
raise ValueError(f"{field['key']} takes {field['min']} to {field['max']}, not {value}")
|
|
return value
|
|
if field["type"] == "boolean":
|
|
if lowered in ("true", "yes", "on", "1"):
|
|
return 1
|
|
if lowered in ("false", "no", "off", "0"):
|
|
return 0
|
|
raise ValueError(f"{field['key']} takes true or false, not \"{text}\"")
|
|
choices = field["choices"]
|
|
if lowered in choices:
|
|
return choices.index(lowered)
|
|
if re.fullmatch(r"\d{1,9}", text) and int(text) < len(choices):
|
|
return int(text)
|
|
raise ValueError(f"{field['key']} takes {', '.join(choices[:-1])} or {choices[-1]}, not \"{text}\"")
|
|
|
|
|
|
def find_rule(key):
|
|
key = key.lower()
|
|
return SCHEMA_BY_KEY.get(key) or SCHEMA_BY_ALIAS.get(key)
|
|
|
|
|
|
# ---------- gametype files: the TOML the doc's files hold, a small part of the language
|
|
|
|
ENGINE_TABLES = ("ctf", "slayer", "king", "oddball", "race")
|
|
|
|
|
|
def parse_toml(text):
|
|
"""{table: (its line, {key: (value, line)})} with '' the top level;
|
|
GametypeError(line, message)"""
|
|
tables = {"": (0, {})}
|
|
current = ""
|
|
for number, raw in enumerate(text.split("\n"), 1):
|
|
line = _strip_comment(raw).strip()
|
|
if not line:
|
|
continue
|
|
table = re.fullmatch(r"\[\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\]", line)
|
|
if table:
|
|
current = table.group(1)
|
|
if current in tables:
|
|
raise GametypeError(number, f"[{current}] is defined twice (line {number})")
|
|
tables[current] = (number, {})
|
|
continue
|
|
pair = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)", line)
|
|
if not pair:
|
|
raise GametypeError(number, f"not a key = value line (line {number})")
|
|
key, value_text = pair.group(1), pair.group(2).strip()
|
|
if key in tables[current][1]:
|
|
raise GametypeError(number, f"{key} is defined twice (line {number})")
|
|
tables[current][1][key] = (_toml_value(value_text, number), number)
|
|
return tables
|
|
|
|
|
|
def _strip_comment(line):
|
|
quoted = False
|
|
for index, character in enumerate(line):
|
|
if character == '"' and (index == 0 or line[index - 1] != "\\"):
|
|
quoted = not quoted
|
|
elif character == "#" and not quoted:
|
|
return line[:index]
|
|
return line
|
|
|
|
|
|
def _toml_value(text, number):
|
|
if text in ("true", "false"):
|
|
return text == "true"
|
|
if re.fullmatch(r"[+-]?\d+", text):
|
|
return int(text)
|
|
if re.fullmatch(r'"((?:[^"\\]|\\.)*)"', text):
|
|
return re.sub(r"\\(.)", lambda match: {"n": "\n", "t": "\t"}.get(match.group(1), match.group(1)), text[1:-1])
|
|
if re.fullmatch(r"\[[\d\s,+-]*\]", text):
|
|
return [int(item) for item in text[1:-1].replace(" ", "").split(",") if item]
|
|
raise GametypeError(number, f"{text[:40]} is not a value (line {number})")
|
|
|
|
|
|
class GametypeError(Exception):
|
|
def __init__(self, line, message):
|
|
super().__init__(message)
|
|
self.line = line
|
|
self.message = message
|
|
|
|
|
|
def _toml_text(value):
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (int, str)):
|
|
return str(value)
|
|
return None
|
|
|
|
|
|
def validate_gametype(text):
|
|
"""(base, name, rules) of a gametype file as server_toml.c and
|
|
server_gametypes.c check it (top-level base and name only, a rule by
|
|
its full name in its own table), or GametypeError (line 0: the file's
|
|
whole, as the base's)"""
|
|
if len(text) > 64 * 1024:
|
|
raise GametypeError(0, "the file is larger than 64 KB")
|
|
tables = parse_toml(text)
|
|
base, name, rules, engine_table = "", "", {}, ""
|
|
for key, (value, number) in tables[""][1].items():
|
|
if key == "base" and isinstance(value, str) and len(value) < 64:
|
|
base = value.lower()
|
|
elif key == "name" and isinstance(value, str):
|
|
if len(value) > 11:
|
|
raise GametypeError(number, "name is longer than 11 characters")
|
|
if any(not 0x20 <= ord(character) < 0x7F for character in value):
|
|
raise GametypeError(number, "name has a character that is not printable ASCII")
|
|
name = value
|
|
elif key in ("base", "name"):
|
|
raise GametypeError(number, f"{key} is a string of {'63' if key == 'base' else '11'} characters at most")
|
|
else:
|
|
raise GametypeError(number, f"{key} is not base or name (a rule goes in its table: [universal], "
|
|
"[options], [ctf] ... [race])")
|
|
for table, (table_line, keys) in tables.items():
|
|
if table == "":
|
|
continue
|
|
if table not in ("universal", "options") + ENGINE_TABLES:
|
|
raise GametypeError(table_line, f"[{table}] is no table of a gametype file")
|
|
if table in ENGINE_TABLES:
|
|
if engine_table and engine_table != table:
|
|
raise GametypeError(table_line, f"[{engine_table}] and [{table}]: a gametype has one game engine")
|
|
engine_table = table
|
|
for key, (value, number) in keys.items():
|
|
field = SCHEMA_BY_KEY.get(key)
|
|
if not field:
|
|
raise GametypeError(number, f"no rule is named {key} (in [{table}])")
|
|
if field["table"] != table:
|
|
raise GametypeError(number, f"{key} goes in [{field['table']}], not [{table}]")
|
|
value_text = _toml_text(value)
|
|
if value_text is None:
|
|
raise GametypeError(number, f"{key} takes a number, true or false, or a word")
|
|
try:
|
|
rules[key] = rule_value(field, value_text)
|
|
except ValueError as failure:
|
|
raise GametypeError(number, str(failure)) from None
|
|
if not base:
|
|
raise GametypeError(1, "there is no base = \"<gametype>\" (slayer, team_slayer, ctf, ...)")
|
|
base = GAMETYPE_ALIASES.get(base, base)
|
|
if base not in BUILTINS:
|
|
raise GametypeError(0, f"base = \"{base}\" is no built-in gametype")
|
|
engine = BUILTINS[base][0]
|
|
if engine_table and engine_table != engine:
|
|
raise GametypeError(0, f"[{engine_table}] is not its base's game engine ({engine})")
|
|
return base, name, rules
|
|
|
|
|
|
def gametype_file_name_valid(name):
|
|
return re.fullmatch(r"[a-z0-9_-]{1,32}", name) is not None
|
|
|
|
|
|
# ---------- the simulated server
|
|
|
|
|
|
class Player:
|
|
def __init__(self, slot, name, machine, team):
|
|
self.slot = slot
|
|
self.name = name
|
|
self.machine = machine
|
|
self.team = team
|
|
self.score = self.kills = self.deaths = self.assists = 0
|
|
self.ping = random.randint(20, 140)
|
|
self.joined = int(time.time())
|
|
self.muted_until = None
|
|
self.muted = False
|
|
self.address = f"203.0.113.{10 + machine}"
|
|
self.hwid = hashlib.sha256(name.encode()).hexdigest()[:32]
|
|
|
|
def json(self, addresses, playing=True):
|
|
"""as server_status.c's server_status_player: the ping null until the
|
|
player is in a game, the address and hardware id for the owner (or
|
|
with server.log_addresses)"""
|
|
value = {"slot": self.slot, "name": self.name, "team": self.team, "score": self.score, "kills": self.kills,
|
|
"deaths": self.deaths, "assists": self.assists, "ping": self.ping if playing else None,
|
|
"machine": self.machine, "joined": self.joined, "muted": self.muted}
|
|
if addresses:
|
|
value.update(address=self.address, hwid=self.hwid)
|
|
return value
|
|
|
|
|
|
class Session:
|
|
def __init__(self, role, by):
|
|
self.token = secrets.token_hex(32)
|
|
self.csrf = secrets.token_hex(16)
|
|
self.role = role
|
|
self.by = by
|
|
self.created = time.time()
|
|
self.used = self.created
|
|
|
|
@property
|
|
def expires(self):
|
|
"""(as the real server's: the session's lifetime's end; it also ends
|
|
after SESSION_IDLE without a request)"""
|
|
return int(self.created + SESSION_LIFETIME)
|
|
|
|
def valid(self, at):
|
|
return at < self.created + SESSION_LIFETIME and at < self.used + SESSION_IDLE
|
|
|
|
|
|
# the server's commands (server_commands.c's table): name, alias, role,
|
|
# usage, description
|
|
COMMANDS = [
|
|
("help", None, "viewer", "[command]", "the commands, or one's use"),
|
|
("sv_status", None, "viewer", "[--json]", "the server and its game"),
|
|
("sv_players", None, "viewer", "[--json]", "the players"),
|
|
("sv_rotation", "sv_mapcycle", "viewer", "", "the rotation"),
|
|
("sv_maps", None, "viewer", "", "the multiplayer maps there are"),
|
|
("sv_gametypes", None, "viewer", "", "the gametypes there are"),
|
|
("sv_bans", "sv_banlist", "admin", "", "bans.txt's bans"),
|
|
("sv_settings", None, "admin", "", "the settings sv_set changes"),
|
|
("sv_version", None, "viewer", "", "the build and its network version"),
|
|
("sv_log", None, "viewer", "[lines]", "the log's last lines"),
|
|
("sv_masters", None, "viewer", "", "the master servers and their answers"),
|
|
("sv_publisher", None, "viewer", "", "the publisher key's id and public key"),
|
|
("say", "sv_say", "moderator", "<text>", "a notice on every machine"),
|
|
("tell", None, "moderator", "<player> <text>", "a notice on a player's machine"),
|
|
("kick", "sv_kick", "moderator", "<player>", "drops a player's machine (it may join again)"),
|
|
("ban", "sv_ban", "moderator", "<player> [30m|2h|7d|forever] [reason]",
|
|
"drops a player's machine and bans its address and hardware id (a moderator's: a day at most)"),
|
|
("unban", "sv_unban", "admin", "<number|address|hardware id>", "takes a ban out of bans.txt"),
|
|
("sv_ban_address", None, "admin", "<address|hardware id> [30m|2h|7d|forever] [reason]",
|
|
"bans an address or hardware id, its player here or not"),
|
|
("mute", None, "moderator", "<player> [minutes]", "mutes a player's chat and voice for everyone"),
|
|
("unmute", None, "moderator", "<player>", "unmutes a player"),
|
|
("sv_map", None, "moderator", "<map> [gametype]", "plays that game now (the rotation goes on after)"),
|
|
("sv_map_next", "sv_next", "moderator", "", "ends the game, no scores, and plays the next"),
|
|
("sv_end_game", None, "moderator", "", "ends the game: its scores, then the next"),
|
|
("sv_rotation_set", None, "admin", "<entries>", "replaces the rotation"),
|
|
("sv_rotation_add", "sv_mapcycle_add", "admin", "<entry>", "adds an entry to the rotation"),
|
|
("sv_rotation_del", "sv_mapcycle_del", "admin", "<number>", "takes an entry out of the rotation"),
|
|
("sv_rotation_begin", "sv_mapcycle_begin", "admin", "[number]", "the rotation's next game is that entry"),
|
|
("sv_set", None, "admin", "<setting> <value>", "changes a setting (sv_settings lists them)"),
|
|
("sv_name", None, "admin", "<name>", "the server's name (15 characters)"),
|
|
("sv_maxplayers", None, "admin", "<2-127>", "the most players"),
|
|
("sv_password", None, "admin", "[password]", "the server browser's password (none without one)"),
|
|
("sv_public", None, "admin", "<0|1>", "listed in the server browser, or by invite only"),
|
|
("sv_motd", None, "admin", "<text>", "the notice each machine sees as it joins"),
|
|
("sv_reload", None, "admin", "", "reads the server's settings and the rotation again"),
|
|
("sv_stop", None, "owner", "[now|game|empty|cancel] [notice]",
|
|
"stops the server now, once the game is over, or once it is empty"),
|
|
("sv_restart", None, "owner", "[now|game|empty|cancel] [notice]", "as sv_stop, with exit code 5 (to be started again)"),
|
|
("quit", "exit", "owner", "", "stops the server"),
|
|
]
|
|
SETTING_ALIASES = {"sv_name": "server.name", "sv_maxplayers": "server.max_players", "sv_password": "server.password",
|
|
"sv_public": "server.public", "sv_motd": "server.motd"}
|
|
|
|
|
|
def line_is_secret(line):
|
|
"""a password's command, never logged (server_console.c's)"""
|
|
words = split_words(line)
|
|
if words and words[0].lower() == "sv_password":
|
|
return True
|
|
return len(words) >= 2 and words[0].lower() == "sv_set" and ("password" in words[1] or
|
|
"publisher_key" in words[1])
|
|
|
|
|
|
def find_command(word):
|
|
word = word.lower()
|
|
return next((command for command in COMMANDS if word in (command[0], command[1])), None)
|
|
|
|
|
|
class Sim:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
self.lock = threading.RLock()
|
|
self.started = time.time()
|
|
self.random = random.Random(options.seed)
|
|
random.seed(options.seed)
|
|
self.settings = default_settings()
|
|
self.setting("server.web_password")["value"] = options.password
|
|
self.setting("server.web_moderator_password")["value"] = options.moderator_password or ""
|
|
# (one setting its environment variable sets, as a container's would)
|
|
self.setting("server.frame_rate")["pinned"] = True
|
|
self.sessions = {}
|
|
self.addresses = {}
|
|
self.checks = deque()
|
|
self.say_minute = 0
|
|
self.say_count = 0
|
|
self.seq = 0
|
|
self.events = deque(maxlen=EVENT_RING)
|
|
self.log_n = 0
|
|
self.log_lines = deque(maxlen=LOG_RING)
|
|
self.connections = []
|
|
self.players = {}
|
|
self.next_machine = 1
|
|
self.names = deque(PLAYER_NAMES)
|
|
self.gametype_files = dict(GAMETYPE_FILES)
|
|
self.bans = [
|
|
{"when": "2026-10-19 18:00:00", "ip": "198.51.100.23", "hwid": "4f1c9a0e7b2d4c6e8a1f3b5d7c9e0a2b",
|
|
"players": "Griefer", "reason": "team killing", "until": None},
|
|
# (a timed ban that has ended: bans.txt keeps it, the server lets its machine in)
|
|
{"when": "2026-10-18 09:30:00", "ip": "198.51.100.40", "hwid": "", "players": "Spammer",
|
|
"reason": "chat spam (by the moderator)", "until": int(time.time()) - 3600},
|
|
]
|
|
self.rotation = []
|
|
self.rotation_index = 0
|
|
self.rotation_text = ("bloodgulch:ctf, sidewinder:team_slayer+score=50+time=15, prisoner:slayer, "
|
|
"beavercreek:ctf+moving_hill=true, custom_maps\\hugeass:btb_slayer")
|
|
self.rotation = self.parse_rotation(self.rotation_text)[0]
|
|
self.games_hosted = 0
|
|
self.api_requests = 0
|
|
self.state = "starting"
|
|
self.state_ticks = 0
|
|
self.countdown = -1
|
|
self.game_seconds = 0
|
|
self.team_scores = [0, 0]
|
|
self.current = self.rotation[0]
|
|
self.interposed = None
|
|
self.score_changed = False
|
|
self.masters = [
|
|
{"url": "https://master1.example.org", "listed": True, "probe": "ok", "code": "", "message": "",
|
|
"updated": int(time.time())},
|
|
{"url": "https://master2.example.net", "listed": False, "probe": "pending", "code": "", "message": "",
|
|
"updated": int(time.time())},
|
|
]
|
|
self.invite = "halo://join/" + secrets.token_hex(32)
|
|
self.script_tick = 0
|
|
# a stop that waits (sv_stop, sv_restart: game or empty): {"after", "restart", "since"}, or None
|
|
self.drain = None
|
|
|
|
# ----- the parts
|
|
|
|
def setting(self, name):
|
|
return next((setting for setting in self.settings if setting["name"] == name), None)
|
|
|
|
def value(self, name):
|
|
return self.setting(name)["value"]
|
|
|
|
def owner_password(self):
|
|
return getattr(self.options, "owner_password", "") or ""
|
|
|
|
def instance(self):
|
|
"""server.instance as the masters get it: else a slug of the server's name"""
|
|
name = getattr(self.options, "instance", "") or self.value("server.name")
|
|
slug = re.sub(r"-+", "-", re.sub(r"[ .]", "-", "".join(character for character in name.lower()
|
|
if character.isalnum() or character in " .-_")))
|
|
return slug.strip("-_")[:32].strip("-") or "main"
|
|
|
|
def identity(self):
|
|
"""the status's and server_started's identity (server_status.c's server_status_identity)"""
|
|
return {"pid": os.getpid(), "started": int(self.started), "instance": self.instance(),
|
|
"region": self.value("server.region"), "tunnel_port": 2302, "publisher_key_id": PUBLISHER_KEY_ID,
|
|
"web_port": getattr(self.options, "port", 0)}
|
|
|
|
def addresses_for(self, role):
|
|
return ROLES.index(role) >= ROLES.index("owner") or self.value("server.log_addresses") or \
|
|
self.options.log_addresses
|
|
|
|
def log(self, level, cat, text):
|
|
"""a line of the log, and its event: a log event has the log's own
|
|
sequence (seq and n are the line's number) and takes no number of the
|
|
events'; chat's lines are the chat group's"""
|
|
with self.lock:
|
|
self.log_n += 1
|
|
line = {"n": self.log_n, "time": now_ms(), "level": level, "cat": cat, "text": text}
|
|
self.log_lines.append(line)
|
|
if self.options.verbose:
|
|
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(line["time"] / 1000))
|
|
print(f"{stamp} {level.upper()} {cat}: {text}", flush=True)
|
|
event = {"type": "event", "seq": line["n"], "time": line["time"], "event": "log", "n": line["n"],
|
|
"level": level, "cat": cat, "text": text}
|
|
for connection in list(self.connections):
|
|
connection.offer("chat" if cat == "chat" else "log", event)
|
|
|
|
def emit(self, _event, _group, _addressed=None, **fields):
|
|
"""an event to the ring and to every subscriber; _addressed, the fields
|
|
the readers who see players' addresses get besides (the owner, or
|
|
every role with server.log_addresses)"""
|
|
with self.lock:
|
|
self.seq += 1
|
|
event = {"type": "event", "seq": self.seq, "time": now_ms(), "event": _event}
|
|
event.update(fields)
|
|
addressed = dict(event, **_addressed) if _addressed else None
|
|
self.events.append((_group, event, addressed))
|
|
for connection in list(self.connections):
|
|
connection.offer(_group, addressed if addressed and self.addresses_for(connection.role or "viewer")
|
|
else event)
|
|
return event
|
|
|
|
def playing(self):
|
|
return self.state == "playing"
|
|
|
|
def players_json(self, role):
|
|
addresses = self.addresses_for(role)
|
|
return [player.json(addresses, self.playing()) for player in sorted(self.players.values(), key=lambda p: p.slot)]
|
|
|
|
def entry_json(self, entry):
|
|
return {"text": entry["text"], "map": entry["map"], "gametype": entry["gametype"], "rules": entry["rules"],
|
|
"rejected": entry["rejected"]}
|
|
|
|
def next_entry(self):
|
|
playable = [index for index, entry in enumerate(self.rotation) if not entry["rejected"]]
|
|
if not playable:
|
|
return None
|
|
later = [index for index in playable if index > self.rotation_index]
|
|
return (later or playable)[0]
|
|
|
|
def team_scores_json(self):
|
|
return list(self.team_scores) if self.current["teams"] else None
|
|
|
|
def status(self, role):
|
|
entry = self.current
|
|
next_index = self.next_entry()
|
|
admin = ROLES.index(role) >= ROLES.index("admin")
|
|
frame = round(3.5 + self.random.random() * 2, 1)
|
|
mean = round(1 + self.random.random() * 0.6, 2)
|
|
return {
|
|
"ok": True, "time": now_ms(), "name": self.value("server.name"), "state": self.state,
|
|
"build": BUILD, "network_version": NETWORK_VERSION, "uptime": int(time.time() - self.started),
|
|
"games_hosted": self.games_hosted, "map": entry["map"], "level": entry["level"],
|
|
"gametype": entry["gametype"], "variant": entry["variant"], "teams": entry["teams"],
|
|
"players": len(self.players), "max_players": self.value("server.max_players"),
|
|
"machines": len({player.machine for player in self.players.values()}),
|
|
"countdown": self.countdown, "game_seconds": self.game_seconds,
|
|
"team_scores": self.team_scores_json(),
|
|
"listing": {"online": True, "public": bool(self.value("server.public")),
|
|
"password": bool(self.value("server.password")), "invite": self.invite if admin else ""},
|
|
"rotation": {"index": self.rotation_index, "count": len(self.rotation),
|
|
"next": self.entry_json(self.rotation[next_index]) if next_index is not None else None},
|
|
"frame_ms_max": frame, "frame_ms_avg": mean, "tick_ms_avg": mean,
|
|
"masters": [dict(master) for master in self.masters],
|
|
"update": {"available": False, "build": ""},
|
|
"drain": dict(self.drain) if self.drain else None,
|
|
**self.identity(),
|
|
"capabilities": list(CAPABILITIES),
|
|
}
|
|
|
|
# ----- the rotation (server_rotation.c's grammar)
|
|
|
|
def find_map(self, kind, name):
|
|
"""(level, kind) of a map there is, or None"""
|
|
if kind in ("name", "path") and name in dict(MAPS):
|
|
return f"levels\\test\\{name}\\{name}", "xbox"
|
|
if kind in ("name", "custom") and name in dict(CUSTOM_MAPS):
|
|
return f"custom_maps\\{name}", "custom"
|
|
return None
|
|
|
|
def parse_entry(self, text):
|
|
"""an entry of the rotation, its rejected the reason it cannot be
|
|
played; None for an empty one or a comment"""
|
|
text = text.strip(" \t\r\n")
|
|
if not text or text.startswith("#"):
|
|
return None
|
|
entry = {"text": text, "map": "", "gametype": "slayer", "rules": {}, "rejected": None, "level": "",
|
|
"engine_base": "slayer", "teams": False, "variant": ""}
|
|
if len(text) > ENTRY_TEXT:
|
|
entry["text"] = text[:ENTRY_TEXT - 3] + "..."
|
|
entry["rejected"] = f"longer than {ENTRY_TEXT} characters"
|
|
return entry
|
|
if any(not 0x20 <= ord(character) < 0x7F for character in text):
|
|
entry["rejected"] = "has a character that is not printable ASCII"
|
|
return entry
|
|
map_text, colon, after = text.partition(":")
|
|
gametype_text, _, rules_text = after.partition("+")
|
|
has_rules = "+" in after
|
|
map_text = map_text.strip().replace("/", "\\")
|
|
plain = re.compile(r"[A-Za-z0-9_\-][A-Za-z0-9_.\-]*")
|
|
if map_text[:12].lower() == "custom_maps\\":
|
|
kind, name = "custom", map_text[12:]
|
|
if not plain.fullmatch(name):
|
|
entry["rejected"] = f"\"{name}\" is not a map's name"
|
|
return entry
|
|
elif "\\" in map_text:
|
|
kind, name = "path", map_text.rsplit("\\", 1)[1]
|
|
if map_text.startswith("\\") or any(not part or set(part) == {"."} or not re.fullmatch(r"[A-Za-z0-9_.\-]+", part)
|
|
for part in map_text.split("\\")):
|
|
entry["rejected"] = f"\"{map_text}\" is not a level's path"
|
|
return entry
|
|
else:
|
|
kind, name = "name", map_text
|
|
if not plain.fullmatch(name):
|
|
entry["rejected"] = f"\"{name}\" is not a map's name"
|
|
return entry
|
|
name = name.lower()
|
|
entry["map"] = name
|
|
if name == "ui":
|
|
entry["rejected"] = "ui is the main menu's map"
|
|
return entry
|
|
if colon:
|
|
gametype = gametype_text.strip()
|
|
if not gametype or len(gametype) >= 32 or not plain.fullmatch(gametype[5:] if gametype.startswith("file=")
|
|
else gametype):
|
|
entry["rejected"] = f"\"{gametype}\" is not a gametype's name"
|
|
return entry
|
|
entry["gametype"] = gametype.lower()
|
|
for rule in (rules_text.split("+") if has_rules else []):
|
|
key, equals, value = rule.partition("=")
|
|
key, value = key.strip(), value.strip()
|
|
if not equals:
|
|
entry["rejected"] = f"the rule \"{rule.strip()}\" has no value (key=value)"
|
|
return entry
|
|
field = find_rule(key)
|
|
if not field:
|
|
entry["rejected"] = f"no rule is named \"{key}\""
|
|
return entry
|
|
try:
|
|
entry["rules"][field["key"]] = rule_value(field, value)
|
|
except ValueError as failure:
|
|
entry["rejected"] = str(failure)
|
|
return entry
|
|
found = self.find_map(kind, name)
|
|
if not found:
|
|
entry["rejected"] = {
|
|
"name": f"there is no map {name} (maps/{name}.map, custom_maps/{name}.map)",
|
|
"custom": f"there is no Custom Edition map {name} (custom_maps/{name}.map)",
|
|
"path": f"there is no map file maps/{name}.map for {map_text}"}[kind]
|
|
return entry
|
|
entry["level"] = found[0] if kind != "path" else map_text
|
|
gametype = GAMETYPE_ALIASES.get(entry["gametype"], entry["gametype"])
|
|
if gametype in BUILTINS:
|
|
base, variant = gametype, BUILTINS[gametype][2]
|
|
elif gametype.startswith("file="):
|
|
entry["rejected"] = f"gametypes/{gametype[5:]}.gametype is missing, or not a saved gametype"
|
|
return entry
|
|
elif gametype in self.gametype_files:
|
|
try:
|
|
base, variant, _ = validate_gametype(self.gametype_files[gametype])
|
|
except GametypeError as failure:
|
|
entry["rejected"] = f"gametypes/{gametype}.toml" + (f" line {failure.line}" if failure.line else "") + \
|
|
f": {failure.message}"
|
|
return entry
|
|
variant = variant or BUILTINS[base][2]
|
|
else:
|
|
entry["rejected"] = f"no gametype is named {gametype} (a built-in's, OpenCE's, or gametypes/{gametype}.toml)"
|
|
return entry
|
|
engine, teams, _ = BUILTINS[base]
|
|
entry["engine_base"], entry["variant"], entry["teams"] = base, variant, teams
|
|
for key, value in entry["rules"].items():
|
|
field = SCHEMA_BY_KEY[key]
|
|
if field["engine"] not in ("all", engine):
|
|
entry["rejected"] = f"{key} is a {field['table']} gametype's rule, and this one is {engine}"
|
|
return entry
|
|
if key == "teams":
|
|
entry["teams"] = bool(value)
|
|
return entry
|
|
|
|
def parse_rotation(self, text):
|
|
"""(entries, truncated): separated by commas, ';' or lines, a '#'
|
|
comment to its line's end; 4096 characters and 64 entries at most"""
|
|
truncated = False
|
|
if len(text) > ROTATION_TEXT:
|
|
cut = ROTATION_TEXT
|
|
while cut > 0 and text[cut] not in ",\n;":
|
|
cut -= 1
|
|
text, truncated = text[:cut], True
|
|
entries = []
|
|
start = 0
|
|
while start < len(text):
|
|
end = start
|
|
while end < len(text) and text[end] in " \t\r":
|
|
end += 1
|
|
# (a comment runs to its line's end, commas and all)
|
|
if end < len(text) and text[end] == "#":
|
|
newline = text.find("\n", end)
|
|
start = len(text) if newline < 0 else newline + 1
|
|
continue
|
|
while end < len(text) and text[end] not in ",\n;":
|
|
end += 1
|
|
entry = self.parse_entry(text[start:end])
|
|
if entry is not None:
|
|
if len(entries) >= 64:
|
|
truncated = True
|
|
else:
|
|
entries.append(entry)
|
|
start = end + 1
|
|
return entries, truncated
|
|
|
|
def set_rotation(self, text):
|
|
"""the rotation from a text: (True, []) or (False, rejected) with
|
|
nothing changed"""
|
|
entries, truncated = self.parse_rotation(text)
|
|
rejected = [{"index": index, "entry": entry["text"], "reason": entry["rejected"]}
|
|
for index, entry in enumerate(entries) if entry["rejected"]]
|
|
if rejected or not entries or truncated:
|
|
return False, rejected
|
|
self.rotation = entries
|
|
self.rotation_text = ", ".join(entry["text"] for entry in entries)
|
|
self.rotation_index = len(entries) - 1
|
|
return True, []
|
|
|
|
def rotation_json(self):
|
|
next_index = self.next_entry()
|
|
return {"ok": True, "index": self.rotation_index, "entries": [self.entry_json(e) for e in self.rotation],
|
|
"next": self.entry_json(self.rotation[next_index]) if next_index is not None else None}
|
|
|
|
# ----- players
|
|
|
|
def free_slot(self):
|
|
slot = 0
|
|
while slot in self.players:
|
|
slot += 1
|
|
return slot
|
|
|
|
def add_player(self, name=None):
|
|
# (a drain refuses new machines: the game is closed)
|
|
if len(self.players) >= self.value("server.max_players") or self.drain:
|
|
return None
|
|
if name is None:
|
|
taken = {player.name for player in self.players.values()}
|
|
free = [candidate for candidate in self.names if candidate not in taken]
|
|
if not free:
|
|
return None
|
|
name = free[0]
|
|
self.names.remove(name)
|
|
self.names.append(name)
|
|
slot = self.free_slot()
|
|
teams = self.current["teams"]
|
|
red = sum(1 for player in self.players.values() if player.team == 0)
|
|
blue = sum(1 for player in self.players.values() if player.team == 1)
|
|
player = Player(slot, name, self.next_machine, (0 if red <= blue else 1) if teams else -1)
|
|
self.next_machine += 1
|
|
self.players[slot] = player
|
|
self.log("info", "player", f"{name} joined (slot {slot}, machine {player.machine})")
|
|
self.emit("player_joined", "player", {"address": player.address, "hwid": player.hwid}, slot=slot, name=name,
|
|
team=player.team, machine=player.machine)
|
|
return player
|
|
|
|
def remove_player(self, player, reason):
|
|
"""a player gone: reason left, kicked or banned"""
|
|
self.players.pop(player.slot, None)
|
|
self.log("info", "player", f"{player.name} left (slot {player.slot})")
|
|
self.emit("player_left", "player", {"address": player.address, "hwid": player.hwid}, slot=player.slot,
|
|
name=player.name, reason=reason)
|
|
|
|
def find_player(self, command, text):
|
|
"""a player by slot or the start of their name, or Failed"""
|
|
if not text:
|
|
raise Failed(f"{command}: give a player's slot (sv_players) or name")
|
|
if re.fullmatch(r"\d+", text):
|
|
player = self.players.get(int(text))
|
|
if not player:
|
|
raise Failed(f"{command}: no player in slot {int(text)} (sv_players lists them)")
|
|
return player
|
|
found = [player for player in self.players.values() if player.name.lower().startswith(text.lower())]
|
|
exact = [player for player in found if len(player.name) == len(text)]
|
|
if len(exact) == 1:
|
|
return exact[0]
|
|
if len(exact) > 1:
|
|
raise Failed(f"{command}: {len(exact)} players are named \"{text}\": give a slot (sv_players)")
|
|
if len(found) > 1:
|
|
raise Failed(f"{command}: {len(found)} players' names begin with \"{text}\": give more of it, or a slot")
|
|
if not found:
|
|
raise Failed(f"{command}: no player's name begins with \"{text}\"")
|
|
return found[0]
|
|
|
|
# ----- the game, one after another
|
|
|
|
def game_event(self, name, reason=None, variant=False):
|
|
"""server_director.c's server_game_event: the map, gametype, teams,
|
|
team scores and the players as viewers see them"""
|
|
entry = self.current
|
|
fields = {"map": entry["map"], "gametype": entry["gametype"]}
|
|
if variant:
|
|
fields["variant"] = entry["variant"]
|
|
fields["teams"] = entry["teams"]
|
|
if reason:
|
|
fields.update(reason=reason, duration=self.game_seconds)
|
|
if entry["teams"]:
|
|
fields["team_scores"] = list(self.team_scores)
|
|
fields["players"] = [player.json(False, self.playing())
|
|
for player in sorted(self.players.values(), key=lambda p: p.slot)]
|
|
self.emit(name, "game", **fields)
|
|
|
|
def open_lobby(self, entry):
|
|
self.current = entry
|
|
self.state = "lobby"
|
|
self.state_ticks = 0
|
|
self.game_seconds = 0
|
|
self.team_scores = [0, 0]
|
|
self.countdown = -1
|
|
for player in self.players.values():
|
|
player.score = player.kills = player.deaths = player.assists = 0
|
|
if entry["teams"] and player.team < 0:
|
|
player.team = player.slot % 2
|
|
elif not entry["teams"]:
|
|
player.team = -1
|
|
self.log("info", "lobby", f"next game: {entry['gametype']} on {entry['map']}")
|
|
self.emit("lobby_opened", "game", map=entry["map"], gametype=entry["gametype"], variant=entry["variant"],
|
|
teams=entry["teams"], index=self.rotation_index)
|
|
|
|
def start_loading(self):
|
|
self.state = "loading"
|
|
self.state_ticks = 0
|
|
self.countdown = -1
|
|
|
|
def start_game(self):
|
|
self.state = "playing"
|
|
self.state_ticks = 0
|
|
self.games_hosted += 1
|
|
entry = self.current
|
|
self.log("info", "game", f"started: {entry['variant']} on {entry['map']}, {len(self.players)} players")
|
|
self.game_event("game_started", variant=True)
|
|
|
|
def end_game(self, reason, postgame=True):
|
|
self.log("info", "game", f"ended ({reason}) after {self.game_seconds} s")
|
|
self.game_event("game_ended", reason)
|
|
seconds = min(self.value("server.postgame_seconds"), 10) if postgame else 0
|
|
self.emit("postgame", "game", seconds=seconds)
|
|
if not postgame:
|
|
self.advance()
|
|
return
|
|
self.state = "postgame"
|
|
self.state_ticks = 0
|
|
self.countdown = -1
|
|
|
|
def advance(self):
|
|
if self.interposed is not None:
|
|
entry, self.interposed = self.interposed, None
|
|
self.open_lobby(entry)
|
|
self.start_loading()
|
|
return
|
|
index = self.next_entry()
|
|
if index is None:
|
|
self.state = "stopping"
|
|
return
|
|
self.rotation_index = index
|
|
self.open_lobby(self.rotation[index])
|
|
|
|
def step(self):
|
|
"""one simulated second"""
|
|
self.state_ticks += 1
|
|
for player in self.players.values():
|
|
player.ping = max(8, min(400, player.ping + self.random.randint(-6, 6)))
|
|
if player.muted_until and time.time() >= player.muted_until:
|
|
player.muted, player.muted_until = False, None
|
|
if self.state == "starting":
|
|
self.state = "waiting"
|
|
self.emit("server_started", "server", name=self.value("server.name"), build=BUILD,
|
|
network_version=NETWORK_VERSION, **self.identity(),
|
|
rotation=[entry["text"] for entry in self.rotation])
|
|
self.log("info", "server", f"ready (tunnel 2302/udp, web 127.0.0.1:{self.options.port}, listing public)")
|
|
for entry in self.rotation:
|
|
if entry["rejected"]:
|
|
self.log("warn", "server", f"rotation: {entry['text']} left out: {entry['rejected']}")
|
|
self.rotation_index = 0
|
|
if self.options.still:
|
|
for _ in range(5):
|
|
self.add_player()
|
|
self.open_lobby(self.rotation[self.rotation_index])
|
|
self.start_game()
|
|
else:
|
|
for _ in range(3):
|
|
self.add_player()
|
|
self.open_lobby(self.rotation[self.rotation_index])
|
|
return
|
|
if self.drain and self.drain_due():
|
|
self.stop(5 if self.drain["restart"] else 0, "drained")
|
|
return
|
|
if self.state == "lobby":
|
|
minimum = self.value("server.min_players")
|
|
if len(self.players) >= minimum and self.countdown < 0:
|
|
self.countdown = min(self.value("server.lobby_seconds"), 10)
|
|
self.emit("countdown", "game", seconds=self.countdown)
|
|
elif len(self.players) < minimum and self.countdown >= 0:
|
|
self.countdown = -1
|
|
self.emit("countdown", "game", seconds=-1)
|
|
elif self.countdown > 0:
|
|
self.countdown -= 1
|
|
if self.countdown % 5 == 0:
|
|
self.emit("countdown", "game", seconds=self.countdown)
|
|
elif self.countdown == 0:
|
|
self.start_loading()
|
|
if not self.options.still and self.state_ticks == 2 and len(self.players) < 6:
|
|
self.add_player()
|
|
elif self.state == "loading":
|
|
if self.state_ticks >= 2:
|
|
self.start_game()
|
|
elif self.state == "playing":
|
|
self.game_seconds += 1
|
|
if not self.options.still:
|
|
self.script()
|
|
if self.score_changed:
|
|
self.score_changed = False
|
|
fields = {"team_scores": list(self.team_scores)} if self.current["teams"] else {}
|
|
self.emit("score", "game", players=self.players_json("viewer"), **fields)
|
|
elif self.state == "postgame":
|
|
if self.state_ticks >= 10:
|
|
self.advance()
|
|
|
|
def script(self):
|
|
"""what the players do: kills, chat, joins and leaves, a map shared,
|
|
a speed hack and its kick, and the end of the game"""
|
|
self.script_tick += 1
|
|
tick = self.game_seconds
|
|
players = list(self.players.values())
|
|
if len(players) >= 2 and self.random.random() < 0.6:
|
|
killer, victim = self.random.sample(players, 2)
|
|
killer.kills += 1
|
|
killer.score += 1
|
|
victim.deaths += 1
|
|
if killer.team >= 0:
|
|
self.team_scores[killer.team] += 1
|
|
self.emit("kill", "game", killer=killer.slot, victim=victim.slot,
|
|
weapon=self.random.choice(["assault rifle", "pistol", "sniper rifle", "rocket launcher", None]),
|
|
friendly_fire=False)
|
|
self.score_changed = True
|
|
if players and tick % 7 == 3:
|
|
speaker = self.random.choice(players)
|
|
text = self.random.choice(CHAT_LINES)
|
|
self.log("info", "chat", f"{speaker.name}: {text}")
|
|
self.emit("chat", "chat", channel=self.random.choice(["everyone", "everyone", "team"]), slot=speaker.slot,
|
|
name=speaker.name, text=text)
|
|
if tick in (10, 30):
|
|
self.add_player()
|
|
if self.current["level"].startswith("custom_maps") and tick in (11, 12, 13):
|
|
state, progress = {11: ("asked", 0), 12: ("downloading", 50), 13: ("downloaded", 100)}[tick]
|
|
self.emit("map_share", "player", machine=self.next_machine - 1, map=self.current["map"], state=state,
|
|
progress=progress)
|
|
if tick == 35 and players:
|
|
cheater = players[-1]
|
|
self.log("warn", "moderation", f"speed hack: machine {cheater.machine} ({cheater.name}) at 1.40x")
|
|
self.emit("speed_hack", "moderation", machine=cheater.machine, names=cheater.name, rate=1.4,
|
|
ahead_ticks=42, action="warned")
|
|
if tick == 45 and players:
|
|
cheater = players[-1]
|
|
self.log("info", "moderation", f"{cheater.name} kicked (speed hack)")
|
|
self.emit("kick", "moderation", name=cheater.name, by="owner", reason="speed hack")
|
|
self.remove_player(cheater, "kicked")
|
|
if tick == 52 and len(players) > 3:
|
|
self.remove_player(players[0], "left")
|
|
if tick == 20 and self.masters[1]["probe"] == "pending":
|
|
self.masters[1].update(probe="failed", code="unreachable", updated=int(time.time()),
|
|
message="the probe got no answer on 2302/udp")
|
|
master = self.masters[1]
|
|
self.log("warn", "master", f"{master['url']}: {master['message']}")
|
|
self.emit("master", "master", url=master["url"], listed=False, probe="failed", code="unreachable",
|
|
message=master["message"])
|
|
if self.script_tick % 60 == 0:
|
|
self.log("info", "health", f"players={len(self.players)} frame_ms_max=4.1 frame_ms_avg=1.20 cpu=2.1% "
|
|
"rss=96MB")
|
|
limit = self.current["rules"].get("score_to_win") or 25
|
|
best = max([player.score for player in self.players.values()] + self.team_scores + [0])
|
|
if best >= limit:
|
|
self.end_game("score")
|
|
elif tick >= 90:
|
|
self.end_game("time")
|
|
|
|
# ----- commands
|
|
|
|
def run(self, line, role, by):
|
|
"""a command line as server_commands.c runs it: (succeeded, its
|
|
lines); a failure's first line says why"""
|
|
with self.lock:
|
|
self.log("info", "console", f"{by}: " + ("(a password's command)" if line_is_secret(line) else line))
|
|
try:
|
|
ok, lines = True, self._run(line.lstrip(" \t"), role, by)
|
|
except Failed as failure:
|
|
ok, lines = False, [failure.line]
|
|
if self.options.big_replies and ok:
|
|
lines = lines + [f"{index:07d} " + "x" * 120 for index in range(9000)]
|
|
return ok, lines
|
|
|
|
def _run(self, line, role, by):
|
|
words = split_words(line)
|
|
if not words:
|
|
raise Failed("give a command (help lists them)")
|
|
command = find_command(words[0])
|
|
if not command:
|
|
if ROLES.index(role) < ROLES.index("owner"):
|
|
raise Failed(f"{words[0]}: not a command of the server's, and the game's console needs the owner role "
|
|
"(the server's console)")
|
|
return [f"(simulated: the game's console runs {words[0]})"]
|
|
if ROLES.index(role) < ROLES.index(command[2]):
|
|
raise Failed(f"{command[0]}: needs the {command[2]} role")
|
|
handler = getattr(self, "c_" + command[0])
|
|
return handler(line, words, role)
|
|
|
|
def moderation_event(self, event, name, role, reason=None, until=None):
|
|
fields = {"name": name, "by": role}
|
|
if reason:
|
|
fields["reason"] = reason
|
|
if until:
|
|
fields["until"] = until
|
|
self.emit(event, "moderation", **fields)
|
|
|
|
def c_help(self, line, words, role):
|
|
if len(words) >= 2:
|
|
command = find_command(words[1])
|
|
if not command:
|
|
raise Failed(f"help: no command is named {words[1]}")
|
|
lines = [f"{command[0]} {command[3]}", f" {command[4]} ({command[2]})"]
|
|
if command[1]:
|
|
lines.append(f" also {command[1]}")
|
|
return lines
|
|
return ["the server's commands (help <command> says more; the game's console commands run too):"] + \
|
|
[f" {name:<18} {description}" for name, _, needed, _, description in COMMANDS
|
|
if ROLES.index(needed) <= ROLES.index(role)]
|
|
|
|
def c_sv_status(self, line, words, role):
|
|
status = self.status(role)
|
|
if len(words) >= 2 and words[1] == "--json":
|
|
return [json.dumps(status, separators=(",", ":"))]
|
|
uptime = status["uptime"]
|
|
lines = [f"{status['name']}: {status['state']}, up {uptime // 3600}h{uptime // 60 % 60:02d}m, "
|
|
f"{self.games_hosted} game{'' if self.games_hosted == 1 else 's'} played; OpenCE {BUILD}, network "
|
|
f"version {NETWORK_VERSION}",
|
|
f"game: {self.current['gametype']} on {self.current['map']}, {len(self.players)} of "
|
|
f"{self.value('server.max_players')} players"]
|
|
entry = status["rotation"]["next"]
|
|
if entry:
|
|
lines.append(f"next: {entry['gametype']} on {entry['map']}")
|
|
lines.append(f"listing: {'public' if status['listing']['public'] else 'by invite only'}"
|
|
f"{', with a password' if status['listing']['password'] else ''}")
|
|
if ROLES.index(role) >= ROLES.index("admin"):
|
|
lines.append(f"invite: {self.invite}")
|
|
return lines
|
|
|
|
def c_sv_players(self, line, words, role):
|
|
players = self.players_json(role)
|
|
if len(words) >= 2 and words[1] == "--json":
|
|
return [json.dumps({"players": players}, separators=(",", ":"))]
|
|
lines = []
|
|
for p in players:
|
|
ping = "-0" if p["ping"] is None else str(p["ping"])
|
|
head = f"{p['slot']:3} {p['name']:<12} team {p['team']} machine {p['machine']} ping {ping}"
|
|
if "address" in p:
|
|
head += f" {p['address']} hwid {p['hwid'] or 'none'}"
|
|
lines.append(head)
|
|
if self.playing():
|
|
lines.append(f" score {p['score']} kills {p['kills']} deaths {p['deaths']} assists {p['assists']}")
|
|
return lines or ["no players"]
|
|
|
|
def c_sv_rotation(self, line, words, role):
|
|
lines = []
|
|
for index, entry in enumerate(self.rotation):
|
|
lines.append(f"{'>' if index == self.rotation_index else ' '}{index:2} {entry['text']}"
|
|
+ (f" (left out: {entry['rejected']}" if entry["rejected"] else ""))
|
|
if entry["rejected"]:
|
|
lines.append(" )")
|
|
return lines or ["the rotation is empty"]
|
|
|
|
def c_sv_maps(self, line, words, role):
|
|
return [f"{map_['name']:<24} {map_['kind']}" for map_ in self.maps()]
|
|
|
|
def c_sv_gametypes(self, line, words, role):
|
|
return ["built in: " + ", ".join(BUILTINS),
|
|
"and gametypes/<name>.toml files, and file=<name> for gametypes/<name>.gametype"]
|
|
|
|
def ban_line(self, ban):
|
|
fields = [ban["when"], f"ip={ban['ip']}", f"hwid={ban['hwid']}", f"players={ban['players']}",
|
|
f"reason={ban['reason']}"] + ([f"until={ban['until']}"] if ban["until"] else [])
|
|
return "\t".join(fields)
|
|
|
|
def c_sv_bans(self, line, words, role):
|
|
return [f"{index:3} {'(ended) ' if ban['until'] and ban['until'] <= time.time() else ''}{self.ban_line(ban)}"
|
|
for index, ban in enumerate(self.bans)] or ["no bans"]
|
|
|
|
def c_sv_settings(self, line, words, role):
|
|
return [f"{setting['name']:<32} " + (("(set)" if setting["value"] else "(none)") if setting["secret"]
|
|
else self.setting_text(setting))
|
|
+ (" (set by its environment variable)" if setting["pinned"] else "") for setting in self.settings]
|
|
|
|
def c_sv_version(self, line, words, role):
|
|
return [f"OpenCE {BUILD}, network version {NETWORK_VERSION}"]
|
|
|
|
def c_sv_log(self, line, words, role):
|
|
count = 20
|
|
if len(words) >= 2:
|
|
if not re.fullmatch(r"\d{1,3}", words[1]) or not 1 <= int(words[1]) <= 200:
|
|
raise Failed("sv_log: give how many lines (1 to 200)")
|
|
count = int(words[1])
|
|
return [f"{line_['cat']}: {line_['text']}" for line_ in list(self.log_lines)[-count:]]
|
|
|
|
def c_sv_masters(self, line, words, role):
|
|
return [json.dumps({"masters": self.masters}, separators=(",", ":"))]
|
|
|
|
def c_sv_publisher(self, line, words, role):
|
|
return ["publisher key id 3f9a2c7d1e5b8a04, public key ed25519:" + "ab" * 32]
|
|
|
|
def notice(self, text):
|
|
"""(in the lobby a notice reaches no machine: said back)"""
|
|
if not self.playing():
|
|
return [f"(in the lobby: no machine sees a notice until a game runs) {text[:NOTICE_SIZE - 1]}"]
|
|
return []
|
|
|
|
def c_say(self, line, words, role):
|
|
text = printable_text(rest_after_words(line, 1), NOTICE_SIZE)
|
|
if not text:
|
|
raise Failed("say: give the text")
|
|
if ROLES.index(role) < ROLES.index("owner"):
|
|
minute = int(time.time() // 60)
|
|
if minute != self.say_minute:
|
|
self.say_minute, self.say_count = minute, 0
|
|
self.say_count += 1
|
|
if self.say_count > 20:
|
|
raise Failed("say: 20 lines a minute at most")
|
|
lines = self.notice(text)
|
|
self.log("info", "chat", f"server: {text}")
|
|
return lines
|
|
|
|
def c_tell(self, line, words, role):
|
|
if len(words) < 3:
|
|
raise Failed("tell: give a player and the text")
|
|
self.find_player("tell", words[1])
|
|
return self.notice(printable_text(rest_after_words(line, 2), NOTICE_SIZE))
|
|
|
|
def c_kick(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("kick: give a player's slot (sv_players) or name")
|
|
player = self.find_player("kick", words[1])
|
|
self.log("info", "moderation", f"{player.name} kicked ({role})")
|
|
self.moderation_event("kick", player.name, role)
|
|
self.remove_player(player, "kicked")
|
|
return []
|
|
|
|
def c_ban(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("ban: give a player's slot (sv_players) or name, then a duration (30m, 2h, 7d, forever) and "
|
|
"a reason")
|
|
seconds, reason_words = 0, 2
|
|
if len(words) >= 3:
|
|
seconds = parse_duration(words[2])
|
|
if seconds >= 0:
|
|
reason_words = 3
|
|
else:
|
|
seconds = 0
|
|
if ROLES.index(role) < ROLES.index("admin") and (seconds == 0 or seconds > 86400):
|
|
raise Failed("ban: a moderator's ban is for a day at most (give a duration: 30m, 2h, 1d)")
|
|
player = self.find_player("ban", words[1])
|
|
reason = printable_text(rest_after_words(line, reason_words), 96).replace("\t", " ")
|
|
until = int(time.time()) + seconds if seconds else None
|
|
shown = (reason or "banned by the server") + ("" if role == "owner" else f" (by the {role})")
|
|
self.bans.append({"when": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), "ip": player.address,
|
|
"hwid": player.hwid, "players": player.name, "reason": shown, "until": until})
|
|
lines = []
|
|
if until:
|
|
lines.append(f"{player.name} banned for {seconds // 60} minutes")
|
|
self.log("info", "moderation", f"{player.name} banned until {until} ({role})")
|
|
else:
|
|
self.log("info", "moderation", f"{player.name} banned ({role})")
|
|
self.moderation_event("ban", player.name, role, reason or None, until)
|
|
self.remove_player(player, "banned")
|
|
return lines
|
|
|
|
def c_unban(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("unban: give a ban's number (sv_bans), an address or a hardware id")
|
|
target = words[1]
|
|
for index, ban in enumerate(self.bans):
|
|
if (re.fullmatch(r"\d+", target) and int(target) == index) or target in (ban["ip"], ban["hwid"]):
|
|
del self.bans[index]
|
|
self.log("info", "moderation", f"ban {index} taken out ({role})")
|
|
self.moderation_event("unban", ban["players"] or f"ban {index}", role)
|
|
return [f"unbanned: {self.ban_line(ban)}"]
|
|
raise Failed(f"unban: no ban {target} (sv_bans lists them)")
|
|
|
|
def ban_address(self, ip, hwid, names, seconds, reason, role):
|
|
"""server_commands.c's ban_address: a line of bans.txt for an address,
|
|
a hardware id or both, and each player present of them dropped as ban
|
|
drops one; the new ban's number, or CommandError (400)"""
|
|
ip, hwid = ip or "", hwid or ""
|
|
if not ip and not hwid:
|
|
raise CommandError("schema", "give an address (a.b.c.d) or a hardware id")
|
|
if ip:
|
|
match = re.fullmatch(r"(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})", ip)
|
|
if not match or any(int(part) > 255 for part in match.groups()) or \
|
|
all(int(part) == 0 for part in match.groups()):
|
|
raise CommandError("schema", f"{ip} is not an IPv4 address (a.b.c.d)")
|
|
ip = ".".join(str(int(part)) for part in match.groups())
|
|
if hwid:
|
|
if not re.fullmatch(r"[0-9A-Fa-f]{1,32}", hwid):
|
|
raise CommandError("schema", "a hardware id is up to 32 hex digits")
|
|
hwid = hwid.lower()
|
|
names = printable_text(names or "", 64)
|
|
reason = printable_text(reason or "", 96)
|
|
until = int(time.time()) + seconds if seconds else None
|
|
shown = (reason or "banned by the server") + ("" if role == "owner" else f" (by the {role})")
|
|
self.bans.append({"when": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), "ip": ip or "none",
|
|
"hwid": hwid or "none", "players": names, "reason": shown, "until": until})
|
|
number = len(self.bans) - 1
|
|
dropped = [player for player in self.players.values() if (ip and player.address == ip) or
|
|
(hwid and player.hwid == hwid)]
|
|
self.log("info", "moderation", f"ban {number} added ({role})")
|
|
self.moderation_event("ban", names or ", ".join(player.name for player in dropped), role, reason or None,
|
|
until)
|
|
for player in dropped:
|
|
self.remove_player(player, "banned")
|
|
return number
|
|
|
|
def c_sv_ban_address(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("sv_ban_address: give an address (a.b.c.d) or a hardware id, then a duration (30m, 2h, 7d, "
|
|
"forever) and a reason")
|
|
seconds, reason_words = 0, 2
|
|
if len(words) >= 3:
|
|
seconds = parse_duration(words[2])
|
|
if seconds >= 0:
|
|
reason_words = 3
|
|
else:
|
|
seconds = 0
|
|
address = "." in words[1]
|
|
try:
|
|
number = self.ban_address(words[1] if address else "", "" if address else words[1], "", seconds,
|
|
rest_after_words(line, reason_words), role)
|
|
except CommandError as failure:
|
|
raise Failed(f"sv_ban_address: {failure.message}") from None
|
|
return [f"ban {number} added" + (f", for {seconds // 60} minutes" if seconds else "")]
|
|
|
|
def c_mute(self, line, words, role):
|
|
mute = words[0].lower() != "unmute"
|
|
command = "mute" if mute else "unmute"
|
|
if len(words) < 2:
|
|
raise Failed(f"{command}: give a player's slot (sv_players) or name")
|
|
minutes = 0
|
|
if mute and len(words) >= 3:
|
|
if not re.fullmatch(r"\d{1,4}", words[2]) or not 1 <= int(words[2]) <= 1440:
|
|
raise Failed("mute: give the minutes (1 to 1440)")
|
|
minutes = int(words[2])
|
|
player = self.find_player(command, words[1])
|
|
if not mute and not player.muted:
|
|
raise Failed(f"unmute: {player.name} is not muted")
|
|
player.muted = mute
|
|
player.muted_until = time.time() + minutes * 60 if mute and minutes else None
|
|
self.moderation_event(command, player.name, role)
|
|
return []
|
|
|
|
c_unmute = c_mute
|
|
|
|
def c_sv_map(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("sv_map: give a map, and a gametype: sv_map bloodgulch ctf")
|
|
text = f"{words[1]}:{words[2]}" if len(words) >= 3 and ":" not in words[1] else words[1]
|
|
entry = self.parse_entry(text)
|
|
if entry is None:
|
|
raise Failed("sv_map: give a map, and a gametype: sv_map bloodgulch ctf")
|
|
if entry["rejected"]:
|
|
raise Failed(f"sv_map: {entry['rejected']}")
|
|
self.interposed = entry
|
|
if self.state == "playing":
|
|
self.end_game("next", postgame=False)
|
|
elif self.state in ("lobby", "postgame", "waiting"):
|
|
self.advance()
|
|
return [f"next: {text}"]
|
|
|
|
def c_sv_map_next(self, line, words, role):
|
|
if self.state != "playing":
|
|
raise Failed(f"{words[0]}: no game is being played")
|
|
self.end_game("next", postgame=False)
|
|
return []
|
|
|
|
def c_sv_end_game(self, line, words, role):
|
|
if self.state != "playing":
|
|
raise Failed("sv_end_game: no game is being played")
|
|
self.end_game("command")
|
|
return []
|
|
|
|
def c_sv_rotation_set(self, line, words, role):
|
|
text = rest_after_words(line, 1)
|
|
if not text:
|
|
raise Failed("sv_rotation_set: give the entries, separated by commas")
|
|
ok, rejected = self.set_rotation(text)
|
|
if not ok:
|
|
raise Failed("sv_rotation_set: not changed: " + json.dumps({"rejected": rejected}, separators=(",", ":")))
|
|
return []
|
|
|
|
def c_sv_rotation_add(self, line, words, role):
|
|
text = rest_after_words(line, 1)
|
|
if len(words) >= 3 and ":" not in words[1]:
|
|
text = f"{words[1]}:{words[2]}"
|
|
entry = self.parse_entry(text) if text else None
|
|
if entry is None:
|
|
raise Failed(f"{words[0]}: give an entry: map:gametype")
|
|
ok, _ = self.set_rotation(", ".join([item["text"] for item in self.rotation] + [entry["text"]]))
|
|
if not ok:
|
|
raise Failed(f"{words[0]}: {entry['text']} cannot be played" + (f": {entry['rejected']}"
|
|
if entry["rejected"] else ""))
|
|
return []
|
|
|
|
def c_sv_rotation_del(self, line, words, role):
|
|
if len(words) < 2 or not re.fullmatch(r"\d+", words[1]) or int(words[1]) >= len(self.rotation):
|
|
raise Failed(f"{words[0]}: give an entry's number (sv_rotation lists them)")
|
|
texts = [entry["text"] for entry in self.rotation]
|
|
del texts[int(words[1])]
|
|
if not self.set_rotation(", ".join(texts))[0]:
|
|
raise Failed(f"{words[0]}: the rotation would have nothing playable")
|
|
return []
|
|
|
|
def c_sv_rotation_begin(self, line, words, role):
|
|
index = 0
|
|
if len(words) >= 2:
|
|
if not re.fullmatch(r"\d+", words[1]) or int(words[1]) > 63:
|
|
raise Failed(f"{words[0]}: give an entry's number (sv_rotation lists them)")
|
|
index = int(words[1])
|
|
if index >= len(self.rotation) or self.rotation[index]["rejected"]:
|
|
raise Failed(f"{words[0]}: entry {index} cannot be played")
|
|
self.rotation_index = (index - 1) % len(self.rotation)
|
|
return [f"the next game is entry {index}"]
|
|
|
|
def c_sv_set(self, line, words, role):
|
|
if len(words) < 2:
|
|
raise Failed("sv_set: give a setting and its value (sv_settings lists them)")
|
|
value = printable_text(rest_after_words(line, 2), 300)
|
|
try:
|
|
self.set_setting(words[1], value, role)
|
|
except CommandError as failure:
|
|
raise Failed(f"sv_set: {failure.message}") from None
|
|
setting = self.setting(words[1])
|
|
return [f"{words[1]} = " + (("(set)" if value else "(none)") if setting["secret"] else value)]
|
|
|
|
def c_sv_name(self, line, words, role):
|
|
name = words[0].lower()
|
|
setting_name = SETTING_ALIASES[name]
|
|
value = printable_text(rest_after_words(line, 1), 300)
|
|
if setting_name == "server.name" and len(value) > 15:
|
|
raise Failed(f"{name}: a name has 15 characters at most")
|
|
if setting_name == "server.max_players" and (not re.fullmatch(r"-?\d+", value) or
|
|
not 2 <= int(value) <= 127):
|
|
raise Failed(f"{name}: give a number from 2 to 127")
|
|
try:
|
|
self.set_setting(setting_name, value, role)
|
|
except CommandError as failure:
|
|
raise Failed(f"{name}: {failure.message}") from None
|
|
setting = self.setting(setting_name)
|
|
return [f"{setting_name} = " + (("(set)" if value else "(none)") if setting["secret"] else value)]
|
|
|
|
c_sv_maxplayers = c_sv_password = c_sv_public = c_sv_motd = c_sv_name
|
|
|
|
def c_sv_reload(self, line, words, role):
|
|
self.emit("reloaded", "server", rotation_changed=False, rejected=[
|
|
{"index": index, "entry": entry["text"], "reason": entry["rejected"]}
|
|
for index, entry in enumerate(self.rotation) if entry["rejected"]])
|
|
self.log("info", "server", "reloaded")
|
|
return []
|
|
|
|
def stop(self, code, reason):
|
|
"""the server stops (server_director.c's server_director_quit): the
|
|
shutdown event; the simulator then exits with the code
|
|
(--exit-on-stop), else goes on as before"""
|
|
self.drain = None
|
|
self.log("info", "server", f"stopping ({reason})")
|
|
self.emit("shutdown", "server", reason=reason, code=code)
|
|
if getattr(self.options, "exit_on_stop", False):
|
|
self.state = "stopping"
|
|
timer = threading.Timer(0.5, lambda: os._exit(code))
|
|
timer.daemon = True
|
|
timer.start()
|
|
|
|
def drain_due(self):
|
|
"""a drain's moment (server_director.c's server_drain_update): for
|
|
empty, nobody on the server; for game, no game played nor coming (a
|
|
lobby with nobody, or no countdown)"""
|
|
if self.drain["after"] == "empty":
|
|
return not self.players
|
|
return self.state in ("waiting", "starting") or (self.state == "lobby" and (not self.players or
|
|
self.countdown < 0))
|
|
|
|
def c_sv_stop(self, line, words, role):
|
|
restart = words[0].lower() == "sv_restart"
|
|
after, notice_words = "now", 1
|
|
if len(words) >= 2:
|
|
after = words[1].lower()
|
|
if after not in DRAINS:
|
|
raise Failed(f"{words[0].lower()}: now, game, empty or cancel, then a notice for the players")
|
|
notice_words = 2
|
|
notice = printable_text(rest_after_words(line, notice_words), NOTICE_SIZE)
|
|
lines = []
|
|
if notice:
|
|
lines += self.notice(notice)
|
|
self.log("info", "chat", f"server: {notice}")
|
|
code = 5 if restart else 0
|
|
if after == "cancel":
|
|
if not self.drain:
|
|
raise Failed(f"{words[0].lower()}: no stop is waiting")
|
|
self.emit("drain", "server", after="cancel", restart=self.drain["restart"], notice=notice)
|
|
self.drain = None
|
|
self.log("info", "server", "the stop is cancelled: listed (as server.public says) and open again")
|
|
return lines + ["the stop is cancelled: the server is listed and open again"]
|
|
self.emit("drain", "server", after=after, restart=restart, notice=notice)
|
|
if after == "now":
|
|
self.stop(code, "sv_restart" if restart else "sv_stop")
|
|
return lines + [f"stopping (exit code {code})"]
|
|
since = self.drain["since"] if self.drain else int(time.time())
|
|
self.drain = {"after": after, "restart": restart, "since": since}
|
|
self.log("info", "server", f"stopping (exit code {code}) once "
|
|
f"{'the game is over' if after == 'game' else 'nobody plays'}: the listings withdrawn, new players "
|
|
"refused")
|
|
return lines + [f"stopping once {'this game is over' if after == 'game' else 'nobody plays'} (exit code {code}); "
|
|
"the listings withdrawn, new players refused"]
|
|
|
|
c_sv_restart = c_sv_stop
|
|
|
|
def c_quit(self, line, words, role):
|
|
self.stop(0, "quit")
|
|
return ["(simulated: the server would stop)"]
|
|
|
|
# ----- settings
|
|
|
|
@staticmethod
|
|
def setting_text(setting):
|
|
value = setting["value"]
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
return str(value)
|
|
|
|
def set_setting(self, name, value, role):
|
|
"""a setting changed from its text (server_commands.c's
|
|
server_commands_set_setting): CommandError with the status the web's
|
|
PUT gives it (404, 409, 403, else 400)"""
|
|
setting = self.setting(name)
|
|
if not setting:
|
|
raise CommandError("not_found", f"{name} is not a setting sv_set changes (sv_settings lists them)", 404)
|
|
if setting["pinned"]:
|
|
raise CommandError("pinned", f"{name} is set by {setting['env']} for this run", 409)
|
|
if name in OWNER_SETTINGS and ROLES.index(role) < ROLES.index("owner"):
|
|
raise CommandError("forbidden", f"{name} needs the owner role (the server's console)", 403)
|
|
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
|
|
raise CommandError("schema", f"{name}'s value has a control character")
|
|
kind = setting["type"]
|
|
if kind == "integer":
|
|
if not re.fullmatch(r"-?\d{1,10}", value) or abs(int(value)) > 2147483647:
|
|
raise CommandError("schema", f"{name} takes a whole number")
|
|
typed = int(value)
|
|
if (setting["min"] is not None and typed < setting["min"]) or \
|
|
(setting["max"] is not None and typed > setting["max"]):
|
|
raise CommandError("schema", f"{name} takes {setting['min']} to {setting['max']}")
|
|
elif kind == "boolean":
|
|
if value.lower() not in ("true", "false", "1", "0", "on", "off"):
|
|
raise CommandError("schema", f"{name} takes true or false")
|
|
typed = value.lower() in ("true", "1", "on")
|
|
else:
|
|
typed = value
|
|
if setting["choices"] and value not in setting["choices"]:
|
|
raise CommandError("schema", f"{name} takes {', '.join(setting['choices'])}")
|
|
if name in ("server.web_password", "server.web_moderator_password") and value and len(value) < 8:
|
|
raise CommandError("schema", "a web password has 8 characters or more")
|
|
if len(value) > 256:
|
|
raise CommandError("schema", "the value is longer than 256 characters")
|
|
setting["value"] = typed
|
|
|
|
def settings_json(self):
|
|
result = []
|
|
for setting in self.settings:
|
|
value = dict(setting)
|
|
if setting["secret"]:
|
|
value["set"] = bool(setting["value"])
|
|
value["value"] = ""
|
|
value["default"] = ""
|
|
result.append(value)
|
|
return result
|
|
|
|
def maps(self):
|
|
"""/api/maps' maps: the name, level, kind and title (the map's name
|
|
as the game shows it; else its name)"""
|
|
return [{"name": name, "level": f"levels\\test\\{name}\\{name}", "kind": "xbox", "title": title}
|
|
for name, title in MAPS] + \
|
|
[{"name": name, "level": f"custom_maps\\{name}", "kind": "custom", "title": title or name}
|
|
for name, title in CUSTOM_MAPS]
|
|
|
|
def gametypes_json(self):
|
|
files = []
|
|
for name, text in sorted(self.gametype_files.items()):
|
|
try:
|
|
base, shown, _ = validate_gametype(text)
|
|
files.append({"name": name, "base": base, "description": shown, "error": None})
|
|
except GametypeError as failure:
|
|
files.append({"name": name, "base": "", "description": "", "error": failure.message})
|
|
return {"ok": True, "builtin": list(BUILTINS), "schema": {"fields": SCHEMA}, "files": files}
|
|
|
|
# ----- sign-in
|
|
|
|
def password_role(self, password, local=True):
|
|
"""the role a password gives: the owner's from loopback alone ("forbidden"
|
|
from elsewhere), else the admin's, the moderator's or the viewer's"""
|
|
owner = self.owner_password()
|
|
forbidden = False
|
|
if owner and password and hmac.compare_digest(password.encode(), owner.encode()):
|
|
if local:
|
|
return "owner"
|
|
forbidden = True
|
|
role = None
|
|
for candidate, wanted in (("admin", self.options.password), ("moderator", self.options.moderator_password),
|
|
("viewer", self.options.viewer_password)):
|
|
if wanted and hmac.compare_digest(password.encode(), wanted.encode()):
|
|
role = role or candidate
|
|
return role or ("forbidden" if forbidden else None)
|
|
|
|
def address_record(self, address):
|
|
return self.addresses.setdefault(address, {"window": 0.0, "requests": 0, "failures": [], "locked": 0.0})
|
|
|
|
def allows_request(self, address, authenticated):
|
|
"""(--request-limit) the address's allowance: 60 requests a minute
|
|
without a session, 600 with one"""
|
|
if not self.options.request_limit:
|
|
return True
|
|
with self.lock:
|
|
record = self.address_record(address)
|
|
at = time.time()
|
|
if at - record["window"] >= 60:
|
|
record["window"], record["requests"] = at, 0
|
|
record["requests"] += 1
|
|
return record["requests"] <= (600 if authenticated else 60)
|
|
|
|
def check_password(self, address, password, local=True):
|
|
"""the role a password gives, or CommandError (denied, locked,
|
|
rate_limited, forbidden: the owner's password from beyond loopback,
|
|
counted as a wrong one); counts failures and locks an address out"""
|
|
with self.lock:
|
|
at = time.time()
|
|
record = self.address_record(address)
|
|
if record["locked"] > at:
|
|
raise CommandError("locked", "too many tries: wait", 429, retry_after=int(record["locked"] - at) + 1)
|
|
while self.checks and self.checks[0] < at - 60:
|
|
self.checks.popleft()
|
|
if len(self.checks) >= 30:
|
|
raise CommandError("rate_limited", "too many tries: wait", 429,
|
|
retry_after=int(self.checks[0] + 60 - at) + 1)
|
|
self.checks.append(at)
|
|
role = self.password_role(password if isinstance(password, str) else "", local)
|
|
if role and role != "forbidden":
|
|
record["failures"] = []
|
|
return role
|
|
record["failures"] = [stamp for stamp in record["failures"] if stamp > at - 600] + [at]
|
|
if len(record["failures"]) >= 5:
|
|
record["locked"] = at + 600
|
|
record["failures"] = []
|
|
self.log("warn", "server", f"web: {address} locked out for 10 minutes after 5 wrong passwords")
|
|
if role == "forbidden":
|
|
self.log("warn", "server", "web: the owner's password was given from another address: refused (it "
|
|
"signs in from this machine alone)")
|
|
raise CommandError("forbidden", "the owner's password signs in from the server's own machine alone",
|
|
403)
|
|
raise CommandError("denied", "wrong password", 401)
|
|
|
|
def new_session(self, role, by):
|
|
with self.lock:
|
|
self.prune_sessions()
|
|
if len(self.sessions) >= MAXIMUM_SESSIONS:
|
|
oldest = min(self.sessions.values(), key=lambda session: session.used)
|
|
del self.sessions[oldest.token]
|
|
session = Session(role, by)
|
|
self.sessions[session.token] = session
|
|
return session
|
|
|
|
def prune_sessions(self):
|
|
at = time.time()
|
|
for token in [token for token, session in self.sessions.items() if not session.valid(at)]:
|
|
del self.sessions[token]
|
|
|
|
def session(self, token):
|
|
with self.lock:
|
|
session = self.sessions.get(token or "")
|
|
if session and session.valid(time.time()):
|
|
session.used = time.time()
|
|
return session
|
|
return None
|
|
|
|
|
|
# ---------- HTTP
|
|
|
|
|
|
def forwarded_address(text):
|
|
"""X-Forwarded-For's last entry (the one the trusted proxy appended):
|
|
an IPv4 or IPv6 address, with or without a port; None if it is none"""
|
|
entry = text.split(",")[-1].strip()
|
|
match = re.fullmatch(r"\[([0-9A-Fa-f:.]+)\](?::\d{1,5})?", entry)
|
|
if match:
|
|
entry = match.group(1)
|
|
elif re.fullmatch(r"[\d.]+:\d{1,5}", entry):
|
|
entry = entry.rsplit(":", 1)[0]
|
|
try:
|
|
return str(ipaddress.ip_address(entry))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
class Request:
|
|
def __init__(self, method, target, version, headers, body, address, trusted_proxy=""):
|
|
self.method = method
|
|
self.target = target
|
|
self.path, _, self.query_text = target.partition("?")
|
|
self.version = version
|
|
self.headers = headers
|
|
self.body = body
|
|
self.address = address
|
|
self.trusted_proxy = trusted_proxy
|
|
# (behind a trusted proxy, the client's address is its word: the entry it appended)
|
|
self.real_address = address
|
|
if trusted_proxy and self.header("x-forwarded-for") and address == trusted_proxy:
|
|
self.real_address = forwarded_address(self.header("x-forwarded-for")) or address
|
|
self.query = {}
|
|
for pair in self.query_text.split("&"):
|
|
if pair:
|
|
key, _, value = pair.partition("=")
|
|
self.query[key] = value
|
|
|
|
def header(self, name):
|
|
return self.headers.get(name.lower(), "")
|
|
|
|
def cookie(self, name):
|
|
for part in self.header("cookie").split(";"):
|
|
key, _, value = part.strip().partition("=")
|
|
if key == name:
|
|
return value
|
|
return ""
|
|
|
|
def behind_proxy(self):
|
|
return self.real_address != self.address
|
|
|
|
@property
|
|
def local(self):
|
|
"""the connection is this machine's: its own address in 127.0.0.0/8, and
|
|
not the trusted proxy's (the owner's role is never given through it)"""
|
|
try:
|
|
loopback = ipaddress.ip_address(self.address) in ipaddress.ip_network("127.0.0.0/8")
|
|
except ValueError:
|
|
loopback = False
|
|
return loopback and not (self.trusted_proxy and self.address == self.trusted_proxy)
|
|
|
|
def host(self):
|
|
if self.header("x-forwarded-host") and self.behind_proxy():
|
|
return self.header("x-forwarded-host")
|
|
return self.header("host")
|
|
|
|
def origin_matches(self):
|
|
"""the request's Origin is the server's own (its host; the scheme is
|
|
the proxy's); null only when the browser says same-origin"""
|
|
origin = self.header("origin")
|
|
if origin == "null":
|
|
return self.header("sec-fetch-site") == "same-origin"
|
|
scheme, separator, host = origin.partition("://")
|
|
return bool(separator and self.host() and host.lower() == self.host().lower())
|
|
|
|
|
|
class Response:
|
|
def __init__(self, status, body=None, headers=None, content_type="application/json", close=False):
|
|
self.status = status
|
|
self.headers = list(headers or [])
|
|
self.close = close
|
|
self.content_type = content_type
|
|
if isinstance(body, (dict, list)):
|
|
self.body = json_bytes(body)
|
|
elif isinstance(body, str):
|
|
self.body = body.encode("utf-8")
|
|
else:
|
|
self.body = body or b""
|
|
|
|
def bytes(self, head_only=False):
|
|
reason = http.HTTPStatus(self.status).phrase
|
|
lines = [f"HTTP/1.1 {self.status} {reason}"]
|
|
names = {name.lower() for name, _ in self.headers}
|
|
headers = list(self.headers)
|
|
if self.status != 304:
|
|
headers.append(("Content-Type", self.content_type))
|
|
for name, value in SECURITY_HEADERS:
|
|
if name.lower() not in names:
|
|
headers.append((name, value))
|
|
headers.append(("Content-Length", str(len(self.body))))
|
|
if self.close:
|
|
headers.append(("Connection", "close"))
|
|
lines += [f"{name}: {value}" for name, value in headers]
|
|
return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + (b"" if head_only else self.body)
|
|
|
|
|
|
def error(status, code, message, headers=None, close=False, **extra):
|
|
"""{"ok":false,"error":{"code","message",…}}: a refusal's other fields
|
|
(field, line, rejected, name, retry_after) are the error's"""
|
|
body = {"ok": False, "error": {"code": code, "message": message}}
|
|
body["error"].update(extra)
|
|
headers = list(headers or [])
|
|
if extra.get("retry_after"):
|
|
headers.append(("Retry-After", str(extra["retry_after"])))
|
|
return Response(status, body, headers, close=close)
|
|
|
|
|
|
def command_error(failure):
|
|
response = error(failure.status, failure.code, failure.message, **failure.extra)
|
|
if failure.lines:
|
|
response.body = json_bytes(dict(json.loads(response.body), lines=failure.lines))
|
|
return response
|
|
|
|
|
|
def command_answer(ok, lines):
|
|
"""a command's answer as server_http.c's ticket_command gives it: ok and
|
|
its lines, else the code its first line says (needs the … role: 403
|
|
forbidden; no player, no ban, not a command: 404 not_found; set by its
|
|
environment variable: 409 pinned; else 400 failed)"""
|
|
if ok:
|
|
return Response(200, {"ok": True, "lines": lines})
|
|
first = lines[0] if lines else "the command failed"
|
|
status, code = 400, "failed"
|
|
if "needs the " in first and " role" in first:
|
|
status, code = 403, "forbidden"
|
|
elif "not a command of the server's" in first or "no player" in first or ": no ban " in first:
|
|
status, code = 404, "not_found"
|
|
elif "set by HALO_" in first or "is set by " in first:
|
|
status, code = 409, "pinned"
|
|
return Response(status, {"ok": False, "error": {"code": code, "message": first}, "lines": lines})
|
|
|
|
|
|
ROUTES = []
|
|
|
|
|
|
def route(method, pattern, role, game=True):
|
|
"""a route: its method, path, role, and whether the game's thread
|
|
answers it (a request that waits for it while a map loads is answered
|
|
503 loading)"""
|
|
def wrap(function):
|
|
ROUTES.append((method, re.compile(pattern + r"\Z"), role, game, function))
|
|
return function
|
|
return wrap
|
|
|
|
|
|
class Api:
|
|
def __init__(self, sim):
|
|
self.sim = sim
|
|
|
|
def handle(self, request):
|
|
sim = self.sim
|
|
method = "GET" if request.method == "HEAD" else request.method
|
|
if method not in ("GET", "POST", "PUT", "DELETE"):
|
|
return error(405, "method", "GET, POST, PUT or DELETE", [("Allow", "GET, HEAD, POST, PUT, DELETE")])
|
|
if request.header("origin") and not request.origin_matches():
|
|
return error(403, "origin", "a request from another site")
|
|
if request.path == "/healthz":
|
|
return self.call(request, method, None, False, h_healthz, ())
|
|
if request.path == "/api/login":
|
|
if method != "POST":
|
|
return error(405, "method", "/api/login takes POST", [("Allow", "POST")])
|
|
if not sim.allows_request(request.real_address, False):
|
|
return error(429, "rate_limited", "too many requests: wait a minute", retry_after=60)
|
|
return self.call(request, method, None, False, h_login, ())
|
|
session, kind = self.find_session(request)
|
|
if not sim.allows_request(request.real_address, session is not None):
|
|
return error(429, "rate_limited", "too many requests: wait a minute", retry_after=60)
|
|
if method == "GET" and request.path in ASSETS:
|
|
return self.asset(request)
|
|
if not request.path.startswith("/api/") and request.path != "/metrics":
|
|
if method != "GET":
|
|
return error(405, "method", "the page takes GET", [("Allow", "GET, HEAD")])
|
|
return error(404, "not_found", f"nothing at {request.path}")
|
|
allowed = []
|
|
for route_method, pattern, role, game, function in ROUTES:
|
|
match = pattern.match(request.path)
|
|
if not match:
|
|
continue
|
|
if route_method != method:
|
|
allowed.append(route_method)
|
|
continue
|
|
if request.path.startswith("/api/"):
|
|
with sim.lock:
|
|
sim.api_requests += 1
|
|
count = sim.api_requests
|
|
if sim.options.drop and count % 10 == 0:
|
|
return None
|
|
return self.call(request, method, role, game, function, match.groups(), session, kind)
|
|
if allowed:
|
|
if "GET" in allowed:
|
|
allowed.insert(allowed.index("GET") + 1, "HEAD")
|
|
return error(405, "method", f"{request.path} takes {', '.join(allowed)}", [("Allow", ", ".join(allowed))])
|
|
if not session:
|
|
return error(401, "denied", "sign in first")
|
|
return error(404, "not_found", "nothing is there")
|
|
|
|
def find_session(self, request):
|
|
"""the request's session and how it came (an owner's session is used
|
|
from this machine alone, where it was begun)"""
|
|
bearer = re.match(r"Bearer\s+(\S+)", request.header("authorization"))
|
|
if bearer:
|
|
session, kind = self.sim.session(bearer.group(1)), "bearer"
|
|
else:
|
|
session, kind = self.sim.session(request.cookie("opence_session")), "cookie"
|
|
if session and session.role == "owner" and not request.local:
|
|
session = None
|
|
return session, kind
|
|
|
|
def call(self, request, method, role, game, function, groups, session=None, kind=None):
|
|
sim = self.sim
|
|
mutation = method in ("POST", "PUT", "DELETE")
|
|
if role is not None:
|
|
if not session:
|
|
return error(401, "denied", "sign in first")
|
|
if mutation and kind == "cookie":
|
|
if not request.header("origin"):
|
|
return error(403, "origin", "a change from the page needs its Origin")
|
|
if not hmac.compare_digest(request.header("x-csrf-token").encode(), session.csrf.encode()):
|
|
return error(403, "csrf", "the X-CSRF-Token header is missing or wrong")
|
|
if ROLES.index(session.role) < ROLES.index(role):
|
|
return error(403, "forbidden", f"needs the {role} role")
|
|
body = {}
|
|
if mutation and request.body.strip():
|
|
try:
|
|
body = json.loads(request.body.decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError):
|
|
return error(400, "bad_request", "the body is not JSON")
|
|
if not isinstance(body, dict):
|
|
return error(400, "bad_request", "the body is not a JSON object")
|
|
if game and sim.state == "loading":
|
|
return error(503, "loading", "the game did not answer in time (a map loading?)", retry_after=5)
|
|
try:
|
|
with sim.lock:
|
|
return function(self, request, session, body, *groups)
|
|
except CommandError as failure:
|
|
return command_error(failure)
|
|
|
|
def asset(self, request):
|
|
if not self.sim.options.web_ui:
|
|
return error(404, "not_found", "the page is off (server.web_ui)")
|
|
name, content_type = ASSETS[request.path]
|
|
try:
|
|
data = (self.sim.options.ui_dir / name).read_bytes()
|
|
except OSError:
|
|
return error(404, "not_found", f"no {name} in {self.sim.options.ui_dir}")
|
|
tag = '"' + hashlib.sha256(data).hexdigest()[:16] + '"'
|
|
headers = [("ETag", tag), ("Cache-Control", "no-cache")]
|
|
if request.header("if-none-match") == tag:
|
|
return Response(304, b"", headers)
|
|
return Response(200, data, headers, content_type)
|
|
|
|
|
|
def text_field(body, name, message, maximum=None):
|
|
"""a text the body must hold (not empty), cut to maximum"""
|
|
value = body.get(name)
|
|
if not isinstance(value, str) or not value:
|
|
raise CommandError("schema", message, field=name)
|
|
return value[:maximum] if maximum else value
|
|
|
|
|
|
@route("GET", r"/healthz", None, game=False)
|
|
def h_healthz(api, request, session, body):
|
|
state = api.sim.state
|
|
if state in ("starting", "stopping"):
|
|
return Response(503, {"state": "starting" if state == "starting" else state})
|
|
return Response(200, {"state": state})
|
|
|
|
|
|
@route("GET", r"/metrics", None, game=False)
|
|
def h_metrics(api, request, session, body):
|
|
sim = api.sim
|
|
status = sim.status("viewer")
|
|
lines = [
|
|
"# HELP opence_players Players in the game.", "# TYPE opence_players gauge", f"opence_players {status['players']}",
|
|
"# TYPE opence_max_players gauge", f"opence_max_players {status['max_players']}",
|
|
"# TYPE opence_machines gauge", f"opence_machines {status['machines']}",
|
|
"# TYPE opence_uptime_seconds counter", f"opence_uptime_seconds {status['uptime']}",
|
|
"# TYPE opence_games_hosted_total counter", f"opence_games_hosted_total {status['games_hosted']}",
|
|
"# TYPE opence_frame_ms_max gauge", f"opence_frame_ms_max {status['frame_ms_max']}",
|
|
"# TYPE opence_tick_ms_avg gauge", f"opence_tick_ms_avg {status['tick_ms_avg']}",
|
|
"# TYPE opence_build_info gauge",
|
|
f'opence_build_info{{build="{BUILD}",network_version="{NETWORK_VERSION}"}} 1',
|
|
]
|
|
return Response(200, "\n".join(lines) + "\n", content_type="text/plain; version=0.0.4; charset=utf-8")
|
|
|
|
|
|
@route("POST", r"/api/login", None, game=False)
|
|
def h_login(api, request, session, body):
|
|
sim = api.sim
|
|
password = body.get("password")
|
|
if not isinstance(password, str):
|
|
raise CommandError("schema", "password is needed", field="password")
|
|
by = body.get("by") if isinstance(body.get("by"), str) else ""
|
|
role = sim.check_password(request.real_address, password, request.local)
|
|
session = sim.new_session(role, by[:32] or f"web {role}")
|
|
sim.log("info", "server", f"web: {session.by} signed in ({role})")
|
|
cookie = f"opence_session={session.token}; HttpOnly; SameSite=Strict; Path=/"
|
|
# (Secure only behind the trusted proxy, whose X-Forwarded-Proto is believed)
|
|
if request.header("x-forwarded-proto").lower() == "https" and request.behind_proxy():
|
|
cookie += "; Secure"
|
|
return Response(200, {"ok": True, "token": session.token, "role": role, "csrf": session.csrf,
|
|
"expires": session.expires}, [("Set-Cookie", cookie)])
|
|
|
|
|
|
@route("POST", r"/api/logout", "viewer", game=False)
|
|
def h_logout(api, request, session, body):
|
|
api.sim.sessions.pop(session.token, None)
|
|
return Response(200, {"ok": True}, [("Set-Cookie", "opence_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")])
|
|
|
|
|
|
@route("GET", r"/api/session", "viewer", game=False)
|
|
def h_session(api, request, session, body):
|
|
return Response(200, {"ok": True, "role": session.role, "csrf": session.csrf, "expires": session.expires})
|
|
|
|
|
|
@route("GET", r"/api/status", "viewer", game=False)
|
|
def h_status(api, request, session, body):
|
|
return Response(200, api.sim.status(session.role))
|
|
|
|
|
|
@route("GET", r"/api/players", "viewer", game=False)
|
|
def h_players(api, request, session, body):
|
|
return Response(200, {"ok": True, "players": api.sim.players_json(session.role)})
|
|
|
|
|
|
@route("POST", r"/api/players/(\d{1,3})/(kick|ban|mute|unmute|tell)", "moderator")
|
|
def h_player_action(api, request, session, body, slot, action):
|
|
sim = api.sim
|
|
name = body.get("name")
|
|
if not isinstance(name, str) or not name:
|
|
raise CommandError("schema", "name is needed (the player's, checked)", field="name")
|
|
player = sim.players.get(int(slot))
|
|
if not player:
|
|
raise CommandError("not_found", f"no player in slot {int(slot)}", 404)
|
|
if player.name != name:
|
|
raise CommandError("changed", f"slot {int(slot)} is {player.name} now, not {name}: nothing was done", 409,
|
|
name=player.name)
|
|
if action == "ban":
|
|
duration = body.get("duration") if isinstance(body.get("duration"), str) else ""
|
|
reason = body.get("reason") if isinstance(body.get("reason"), str) else ""
|
|
line = f"ban {player.slot} {duration or 'forever'} {line_text(reason)}"
|
|
elif action == "mute":
|
|
minutes = body.get("minutes")
|
|
if minutes is not None and (isinstance(minutes, bool) or not isinstance(minutes, (int, float)) or
|
|
not 0 <= minutes <= 1440):
|
|
raise CommandError("schema", "minutes is 1 to 1440", field="minutes")
|
|
line = f"mute {player.slot}" + (f" {int(minutes)}" if minutes and minutes >= 1 else "")
|
|
elif action == "tell":
|
|
line = f"tell {player.slot} {line_text(text_field(body, 'text', 'text is needed'))}"
|
|
else:
|
|
line = f"{action} {player.slot}"
|
|
return command_answer(*sim.run(line, session.role, session.by))
|
|
|
|
|
|
@route("POST", r"/api/say", "moderator")
|
|
def h_say(api, request, session, body):
|
|
text = text_field(body, "text", "text is needed")
|
|
return command_answer(*api.sim.run("say " + line_text(text), session.role, session.by))
|
|
|
|
|
|
@route("GET", r"/api/rotation", "viewer")
|
|
def h_rotation(api, request, session, body):
|
|
return Response(200, api.sim.rotation_json())
|
|
|
|
|
|
@route("PUT", r"/api/rotation", "admin")
|
|
def h_rotation_put(api, request, session, body):
|
|
entries = body.get("entries")
|
|
refusal = CommandError("schema", "entries is a list of 1 to 64 entries (map:gametype), with no commas",
|
|
field="entries")
|
|
if not isinstance(entries, list) or not 1 <= len(entries) <= 64:
|
|
raise refusal
|
|
if not all(isinstance(entry, str) and entry and "," not in entry and "\n" not in entry and len(entry) <= 255
|
|
for entry in entries):
|
|
raise refusal
|
|
text = ", ".join(entries)
|
|
if len(text) > ROTATION_TEXT:
|
|
raise CommandError("schema", f"the rotation is {ROTATION_TEXT} characters at most", field="entries")
|
|
ok, rejected = api.sim.set_rotation(text)
|
|
if not ok:
|
|
raise CommandError("rejected", "some entries cannot be played: nothing changed", rejected=rejected)
|
|
api.sim.log("info", "server", f"rotation set by {session.by}: {api.sim.rotation_text}")
|
|
return Response(200, api.sim.rotation_json())
|
|
|
|
|
|
@route("POST", r"/api/rotation/next", "moderator")
|
|
def h_rotation_next(api, request, session, body):
|
|
return command_answer(*api.sim.run("sv_map_next", session.role, session.by))
|
|
|
|
|
|
@route("POST", r"/api/rotation/play", "moderator")
|
|
def h_rotation_play(api, request, session, body):
|
|
map_text = body.get("map")
|
|
if not isinstance(map_text, str) or not map_text or " " in map_text or '"' in map_text:
|
|
raise CommandError("schema", "map is needed (no spaces)", field="map")
|
|
gametype = body.get("gametype") if isinstance(body.get("gametype"), str) else ""
|
|
if " " in gametype or '"' in gametype:
|
|
raise CommandError("schema", "a gametype holds no space", field="gametype")
|
|
return command_answer(*api.sim.run(f"sv_map {map_text} {gametype}", session.role, session.by))
|
|
|
|
|
|
@route("GET", r"/api/gametypes", "viewer")
|
|
def h_gametypes(api, request, session, body):
|
|
return Response(200, api.sim.gametypes_json())
|
|
|
|
|
|
def gametype_name(name):
|
|
if not gametype_file_name_valid(name):
|
|
raise CommandError("not_found", "a gametype file's name is a-z, 0-9, _ and -, 32 at most", 404)
|
|
return name
|
|
|
|
|
|
@route("GET", r"/api/gametypes/([^/]*)", "viewer")
|
|
def h_gametype(api, request, session, body, name):
|
|
text = api.sim.gametype_files.get(gametype_name(name))
|
|
if text is None:
|
|
raise CommandError("not_found", "no such gametype file", 404)
|
|
return Response(200, {"ok": True, "name": name, "text": text})
|
|
|
|
|
|
@route("PUT", r"/api/gametypes/([^/]*)", "admin")
|
|
def h_gametype_put(api, request, session, body, name):
|
|
gametype_name(name)
|
|
text = body.get("text")
|
|
if not isinstance(text, str):
|
|
raise CommandError("schema", "text is needed (the file's TOML)", field="text")
|
|
try:
|
|
validate_gametype(text)
|
|
except GametypeError as failure:
|
|
raise CommandError("invalid", failure.message, line=failure.line) from None
|
|
api.sim.gametype_files[name] = text
|
|
api.sim.log("info", "server", f"gametypes/{name}.toml written by {session.by}")
|
|
return Response(200, {"ok": True})
|
|
|
|
|
|
@route("DELETE", r"/api/gametypes/([^/]*)", "admin")
|
|
def h_gametype_delete(api, request, session, body, name):
|
|
if gametype_name(name) not in api.sim.gametype_files:
|
|
raise CommandError("not_found", "no such gametype file", 404)
|
|
del api.sim.gametype_files[name]
|
|
api.sim.log("info", "server", f"gametypes/{name}.toml deleted by {session.by}")
|
|
return Response(200, {"ok": True})
|
|
|
|
|
|
@route("GET", r"/api/maps", "viewer")
|
|
def h_maps(api, request, session, body):
|
|
return Response(200, {"ok": True, "maps": api.sim.maps()})
|
|
|
|
|
|
@route("GET", r"/api/bans", "admin")
|
|
def h_bans(api, request, session, body):
|
|
return Response(200, {"ok": True, "bans": [dict(ban, n=index) for index, ban in enumerate(api.sim.bans)]})
|
|
|
|
|
|
@route("DELETE", r"/api/bans/(\d{1,5})", "admin")
|
|
def h_ban_delete(api, request, session, body, number):
|
|
return command_answer(*api.sim.run(f"unban {int(number)}", session.role, session.by))
|
|
|
|
|
|
@route("POST", r"/api/bans", "admin")
|
|
def h_ban_add(api, request, session, body):
|
|
"""a ban of an address or hardware id (sv_ban_address): {"ok":true,"n":…}"""
|
|
sim = api.sim
|
|
fields = {}
|
|
for name in ("ip", "hwid", "names", "duration", "reason"):
|
|
value = body.get(name)
|
|
if value is not None and not isinstance(value, str):
|
|
raise CommandError("schema", f"{name} is a text", field=name)
|
|
fields[name] = value or ""
|
|
seconds = 0
|
|
if fields["duration"]:
|
|
seconds = parse_duration(fields["duration"])
|
|
if seconds < 0:
|
|
raise CommandError("schema", "duration is 30m, 2h, 7d, 1w or forever (ten years at most)",
|
|
field="duration")
|
|
sim.log("info", "console", f"{session.by}: (a ban of an address or hardware id)")
|
|
number = sim.ban_address(fields["ip"], fields["hwid"], fields["names"], seconds, fields["reason"], session.role)
|
|
return Response(200, {"ok": True, "n": number})
|
|
|
|
|
|
@route("POST", r"/api/stop", "owner")
|
|
def h_stop(api, request, session, body):
|
|
"""sv_stop or sv_restart, now, after the game or once empty, or the stop
|
|
waiting cancelled: {"ok":true,"lines":[…]}"""
|
|
for name in ("cancel", "restart"):
|
|
if name in body and not isinstance(body[name], bool):
|
|
raise CommandError("schema", "cancel and restart are true or false, notice a text", field=name)
|
|
if "notice" in body and not isinstance(body["notice"], str):
|
|
raise CommandError("schema", "cancel and restart are true or false, notice a text", field="notice")
|
|
if body.get("cancel"):
|
|
return command_answer(*api.sim.run("sv_stop cancel", session.role, session.by))
|
|
after = body.get("after")
|
|
if after not in ("now", "game", "empty"):
|
|
raise CommandError("schema", "after is now, game or empty", field="after")
|
|
line = f"{'sv_restart' if body.get('restart') else 'sv_stop'} {after} {line_text(body.get('notice') or '')}"
|
|
return command_answer(*api.sim.run(line.rstrip(), session.role, session.by))
|
|
|
|
|
|
@route("GET", r"/api/settings", "admin")
|
|
def h_settings(api, request, session, body):
|
|
return Response(200, {"ok": True, "settings": api.sim.settings_json()})
|
|
|
|
|
|
@route("PUT", r"/api/settings", "admin")
|
|
def h_settings_put(api, request, session, body):
|
|
"""each setting from its JSON value (a string's text, else the JSON's own:
|
|
true, 16, null for empty), as the session's role: the owner's settings
|
|
refused to an admin"""
|
|
applied, errors = [], []
|
|
for name, value in body.items():
|
|
if isinstance(value, str):
|
|
text = value
|
|
elif value is None:
|
|
text = ""
|
|
else:
|
|
text = json.dumps(value)
|
|
try:
|
|
api.sim.set_setting(name, text, session.role)
|
|
applied.append(name)
|
|
api.sim.log("info", "server", f"{name} changed by {session.by}")
|
|
except CommandError as failure:
|
|
# (server_http.c names each refusal by its status: 409 pinned, 404
|
|
# not_found, 403 forbidden (an owner's setting), every other schema)
|
|
code = {409: "pinned", 404: "not_found", 403: "forbidden"}.get(failure.status, "schema")
|
|
errors.append({"name": name, "code": code, "message": failure.message})
|
|
return Response(200, {"ok": True, "applied": applied, "errors": errors})
|
|
|
|
|
|
@route("POST", r"/api/command", "viewer")
|
|
def h_command(api, request, session, body):
|
|
line = body.get("line")
|
|
if not isinstance(line, str) or not line or len(line) > COMMAND_LINE:
|
|
raise CommandError("schema", f"line is a command ({COMMAND_LINE} characters at most)", field="line")
|
|
return command_answer(*api.sim.run(line, session.role, session.by))
|
|
|
|
|
|
def query_number(request, name, default):
|
|
text = request.query.get(name, "")
|
|
if not text:
|
|
return default
|
|
if not re.fullmatch(r"-?\d{1,12}", text):
|
|
raise CommandError("bad_request", f"{name} is a number")
|
|
return int(text)
|
|
|
|
|
|
@route("GET", r"/api/log", "viewer", game=False)
|
|
def h_log(api, request, session, body):
|
|
sim = api.sim
|
|
after = max(0, query_number(request, "after", 0))
|
|
limit = max(1, min(1024, query_number(request, "limit", 256)))
|
|
lines = list(sim.log_lines)
|
|
if after > sim.log_n:
|
|
after = 0
|
|
oldest = lines[0]["n"] if lines else sim.log_n + 1
|
|
missed = max(0, oldest - after - 1) if after else 0
|
|
chosen = [line for line in lines if line["n"] > after][:limit]
|
|
cursor = chosen[-1]["n"] if chosen else after
|
|
if session.role not in ("admin", "owner"):
|
|
chosen = [line for line in chosen if line["cat"] != "chat"]
|
|
return Response(200, {"ok": True, "lines": chosen, "next": cursor, "missed": missed})
|
|
|
|
|
|
@route("GET", r"/api/events", "viewer", game=False)
|
|
def h_events(api, request, session, body):
|
|
sim = api.sim
|
|
after = max(0, query_number(request, "after", 0))
|
|
limit = max(1, min(EVENT_RING, query_number(request, "limit", 256)))
|
|
if after > sim.seq:
|
|
after = 0
|
|
ring = list(sim.events)
|
|
oldest = ring[0][1]["seq"] if ring else sim.seq + 1
|
|
admin = ROLES.index(session.role) >= ROLES.index("admin")
|
|
addresses = sim.addresses_for(session.role)
|
|
taken = [(group, addressed if addressed and addresses else event) for group, event, addressed in ring
|
|
if event["seq"] > after][:limit]
|
|
chosen = [event for group, event in taken if group != "chat" or admin]
|
|
missed = max(0, oldest - after - 1) if after else 0
|
|
return Response(200, {"ok": True, "events": chosen, "next": taken[-1][1]["seq"] if taken else after,
|
|
"missed": missed})
|
|
|
|
|
|
# ---------- WebSocket
|
|
|
|
|
|
GROUPS = {"player", "chat", "game", "moderation", "status", "log", "master", "server"}
|
|
|
|
|
|
class WsClose(Exception):
|
|
def __init__(self, code, reason=""):
|
|
super().__init__(reason)
|
|
self.code = code
|
|
self.reason = reason
|
|
|
|
|
|
class WsConnection:
|
|
def __init__(self, sim, sock, buffer, address, local=False):
|
|
self.sim = sim
|
|
self.sock = sock
|
|
self.buffer = buffer
|
|
self.address = address
|
|
# (the connection is this machine's: the owner's password and sessions are taken)
|
|
self.local = local
|
|
self.role = None
|
|
self.by = ""
|
|
self.groups = set()
|
|
self.queue = deque()
|
|
self.ready = threading.Condition()
|
|
self.send_lock = threading.Lock()
|
|
self.closed = False
|
|
self.overflowed = False
|
|
self.opened = time.time()
|
|
self.bucket = 64.0
|
|
self.bucket_time = time.time()
|
|
|
|
# ----- out
|
|
|
|
def offer(self, group, event):
|
|
if self.role is None or (group not in self.groups and group != "server"):
|
|
return
|
|
if group == "chat" and ROLES.index(self.role) < ROLES.index("admin"):
|
|
return
|
|
self.put(event)
|
|
|
|
def put(self, message):
|
|
with self.ready:
|
|
if self.closed or self.overflowed:
|
|
return
|
|
if len(self.queue) >= WS_QUEUE:
|
|
self.overflowed = True
|
|
self.queue.clear()
|
|
self.queue.append({"type": "overflow"})
|
|
self.queue.append(WsClose(1013, "overflow"))
|
|
else:
|
|
self.queue.append(message)
|
|
self.ready.notify()
|
|
|
|
def writer(self):
|
|
try:
|
|
while True:
|
|
with self.ready:
|
|
while not self.queue and not self.closed:
|
|
self.ready.wait(1)
|
|
if self.closed and not self.queue:
|
|
return
|
|
message = self.queue.popleft()
|
|
if isinstance(message, WsClose):
|
|
self.close(message.code, message.reason)
|
|
return
|
|
self.send_text(json_bytes(message))
|
|
if self.sim.options.slow_consumer:
|
|
time.sleep(0.05)
|
|
except OSError:
|
|
self.closed = True
|
|
|
|
def send_frame(self, opcode, payload, fin=True):
|
|
first = (0x80 if fin else 0) | opcode
|
|
size = len(payload)
|
|
if size < 126:
|
|
head = struct.pack("!BB", first, size)
|
|
elif size < 65536:
|
|
head = struct.pack("!BBH", first, 126, size)
|
|
else:
|
|
head = struct.pack("!BBQ", first, 127, size)
|
|
with self.send_lock:
|
|
self.sock.sendall(head + payload)
|
|
|
|
def send_text(self, payload):
|
|
if not self.sim.options.fragment or len(payload) < 2:
|
|
self.send_frame(0x1, payload)
|
|
return
|
|
size = max(1, min(4096, len(payload) // 3 + 1))
|
|
parts = [payload[index:index + size] for index in range(0, len(payload), size)]
|
|
if len(parts) == 1:
|
|
parts = [payload[:1], payload[1:]]
|
|
for index, part in enumerate(parts):
|
|
self.send_frame(0x1 if index == 0 else 0x0, part, fin=index == len(parts) - 1)
|
|
if index == 0:
|
|
self.send_frame(0x9, b"fragment")
|
|
|
|
def close(self, code, reason=""):
|
|
with self.ready:
|
|
if self.closed:
|
|
return
|
|
self.closed = True
|
|
self.ready.notify_all()
|
|
try:
|
|
self.send_frame(0x8, struct.pack("!H", code) + reason.encode()[:120])
|
|
self.sock.shutdown(socket.SHUT_WR)
|
|
except OSError:
|
|
pass
|
|
|
|
# ----- in
|
|
|
|
def read(self, count):
|
|
while len(self.buffer) < count:
|
|
chunk = self.sock.recv(65536)
|
|
if not chunk:
|
|
raise ConnectionError("closed")
|
|
self.buffer += chunk
|
|
data, self.buffer = self.buffer[:count], self.buffer[count:]
|
|
return data
|
|
|
|
def frame(self):
|
|
first, second = self.read(2)
|
|
fin, opcode = first & 0x80, first & 0x0F
|
|
if first & 0x70:
|
|
raise WsClose(1002, "reserved bits")
|
|
if not second & 0x80:
|
|
raise WsClose(1002, "a client's frames are masked")
|
|
size = second & 0x7F
|
|
if size == 126:
|
|
size = struct.unpack("!H", self.read(2))[0]
|
|
elif size == 127:
|
|
size = struct.unpack("!Q", self.read(8))[0]
|
|
if opcode >= 0x8 and (size > 125 or not fin):
|
|
raise WsClose(1002, "a bad control frame")
|
|
if size > WS_FRAME:
|
|
raise WsClose(1009, "64 KB a frame at most")
|
|
mask = self.read(4)
|
|
payload = bytearray(self.read(size))
|
|
for index in range(size):
|
|
payload[index] ^= mask[index % 4]
|
|
return bool(fin), opcode, bytes(payload)
|
|
|
|
def messages(self):
|
|
"""the client's text messages, whole; answers its pings and closes"""
|
|
pieces, total = None, 0
|
|
while True:
|
|
fin, opcode, payload = self.frame()
|
|
if opcode == 0x8:
|
|
code = struct.unpack("!H", payload[:2])[0] if len(payload) >= 2 else 1000
|
|
raise WsClose(1000 if code in (1000, 1001) else 1000, "goodbye")
|
|
if opcode == 0x9:
|
|
self.send_frame(0xA, payload)
|
|
continue
|
|
if opcode == 0xA:
|
|
continue
|
|
if opcode == 0x2:
|
|
raise WsClose(1003, "text frames only")
|
|
if opcode == 0x1:
|
|
if pieces is not None:
|
|
raise WsClose(1002, "a new message inside a fragmented one")
|
|
pieces, total = [], 0
|
|
elif opcode == 0x0:
|
|
if pieces is None:
|
|
raise WsClose(1002, "a continuation without a message")
|
|
else:
|
|
raise WsClose(1002, "an unknown opcode")
|
|
pieces.append(payload)
|
|
total += len(payload)
|
|
if total > WS_MESSAGE:
|
|
raise WsClose(1009, "256 KB a message at most")
|
|
if fin:
|
|
data, pieces = b"".join(pieces), None
|
|
try:
|
|
yield data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise WsClose(1007, "not UTF-8") from None
|
|
|
|
def run(self, auto_session):
|
|
sim = self.sim
|
|
threading.Thread(target=self.writer, daemon=True).start()
|
|
if sim.options.drop:
|
|
threading.Timer(sim.options.drop_after, self.cut).start()
|
|
self.put({"type": "hello", "protocol": 1, "server": sim.value("server.name"), "build": BUILD,
|
|
"network_version": NETWORK_VERSION, "capabilities": list(CAPABILITIES)})
|
|
if auto_session:
|
|
self.role, self.by = auto_session.role, auto_session.by
|
|
self.put({"type": "auth", "ok": True, "role": self.role})
|
|
timer = threading.Timer(10, self.auth_timeout)
|
|
timer.daemon = True
|
|
timer.start()
|
|
try:
|
|
for text in self.messages():
|
|
self.message(text)
|
|
except WsClose as close:
|
|
self.close(close.code, close.reason)
|
|
except (ConnectionError, OSError, struct.error, ValueError):
|
|
pass
|
|
finally:
|
|
timer.cancel()
|
|
with self.ready:
|
|
self.closed = True
|
|
self.ready.notify_all()
|
|
with sim.lock:
|
|
if self in sim.connections:
|
|
sim.connections.remove(self)
|
|
|
|
def cut(self):
|
|
"""(--drop) the connection lost without a close frame"""
|
|
with self.ready:
|
|
self.closed = True
|
|
self.ready.notify_all()
|
|
try:
|
|
self.sock.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
|
|
def auth_timeout(self):
|
|
if self.role is None and not self.closed:
|
|
self.put({"type": "auth", "ok": False, "error": {"code": "denied", "message": "no auth in 10 seconds"}})
|
|
self.put(WsClose(1008, "no auth"))
|
|
|
|
def message(self, text):
|
|
sim = self.sim
|
|
try:
|
|
message = json.loads(text)
|
|
except ValueError:
|
|
message = None
|
|
if not isinstance(message, dict):
|
|
self.error(None, "bad_request", "not a JSON object")
|
|
return
|
|
kind = message.get("type")
|
|
identifier = message.get("id")
|
|
if not isinstance(identifier, (str, int, float)) or isinstance(identifier, bool):
|
|
identifier = None
|
|
if not isinstance(kind, str):
|
|
self.error(identifier, "bad_request", "a message has a type")
|
|
return
|
|
if kind == "ping":
|
|
self.put({"type": "pong"})
|
|
return
|
|
if kind == "auth":
|
|
if self.role is not None:
|
|
self.error(identifier, "bad_request", "signed in already")
|
|
return
|
|
try:
|
|
if isinstance(message.get("token"), str):
|
|
session = sim.session(message["token"])
|
|
if not session or (session.role == "owner" and not self.local):
|
|
raise CommandError("denied", "no such session", 401)
|
|
role, by = session.role, session.by
|
|
else:
|
|
role = sim.check_password(self.address, message.get("password"), self.local)
|
|
by = message.get("by") if isinstance(message.get("by"), str) else ""
|
|
by = by[:32] or f"rcon {role}"
|
|
except CommandError as failure:
|
|
refusal = {"code": failure.code, "message": failure.message}
|
|
if failure.extra.get("retry_after"):
|
|
refusal["retry_after"] = failure.extra["retry_after"]
|
|
self.put({"type": "auth", "ok": False, "error": refusal})
|
|
self.put(WsClose(1008, failure.code))
|
|
return
|
|
self.role, self.by = role, by
|
|
sim.log("info", "server", f"rcon: {by} signed in" + (f" from {self.address}" if sim.value(
|
|
"server.log_addresses") else "") + f" ({role})")
|
|
self.put({"type": "auth", "ok": True, "role": role})
|
|
return
|
|
if kind == "command":
|
|
self.command(message, identifier)
|
|
return
|
|
if self.role is None:
|
|
self.error(identifier, "denied", "sign in first")
|
|
return
|
|
if kind == "subscribe":
|
|
groups = message.get("events")
|
|
if not isinstance(groups, list) or not all(isinstance(group, str) and group in GROUPS for group in groups):
|
|
self.error(identifier, "bad_request", "events is a list of event groups")
|
|
return
|
|
after = message.get("after")
|
|
with sim.lock:
|
|
self.groups = set(groups)
|
|
if isinstance(after, (int, float)) and not isinstance(after, bool) and after >= 0:
|
|
admin = ROLES.index(self.role) >= ROLES.index("admin")
|
|
addresses = sim.addresses_for(self.role)
|
|
for group, event, addressed in list(sim.events):
|
|
if event["seq"] > after and (group in self.groups or group == "server") and \
|
|
(group != "chat" or admin):
|
|
self.put(dict(addressed if addressed and addresses else event, replay=True))
|
|
if "status" in self.groups:
|
|
self.put(sim.emit_status_for(self))
|
|
if sim.options.slow_consumer:
|
|
for index in range(WS_QUEUE + 100):
|
|
sim.log("info", "console", f"slow-consumer filler {index}")
|
|
return
|
|
self.error(identifier, "bad_request", f"no message type {kind[:32]}")
|
|
|
|
def error(self, identifier, code, message):
|
|
"""server_http.c's ws_error: its id only when the message had one"""
|
|
answer = {"type": "error"}
|
|
if identifier is not None:
|
|
answer["id"] = identifier
|
|
answer["error"] = {"code": code, "message": message}
|
|
self.put(answer)
|
|
|
|
def refused(self, identifier, code, message):
|
|
"""a command refused before it ran (server_http.c's ws_reply_refused)"""
|
|
self.put({"type": "reply", "id": identifier, "ok": False, "lines": [],
|
|
"error": {"code": code, "message": message}})
|
|
|
|
def command_line(self, message):
|
|
"""the command's line: "line", or "command" and "args" (a word with a
|
|
space, or an empty one, in quotes); None if there is none"""
|
|
if "line" in message:
|
|
line = message["line"]
|
|
return line if isinstance(line, str) and line else None
|
|
command = message.get("command")
|
|
if not isinstance(command, str) or not command:
|
|
return None
|
|
line = command
|
|
for word in message.get("args") if isinstance(message.get("args"), list) else []:
|
|
if not isinstance(word, str):
|
|
return None
|
|
line += f' "{word}"' if " " in word or not word else f" {word}"
|
|
return line
|
|
|
|
def command(self, message, identifier):
|
|
sim = self.sim
|
|
if self.role is None:
|
|
self.refused(identifier, "denied", "sign in first")
|
|
return
|
|
at = time.time()
|
|
self.bucket = min(64.0, self.bucket + (at - self.bucket_time) * 32)
|
|
self.bucket_time = at
|
|
if self.bucket < 1:
|
|
self.refused(identifier, "rate_limited", "32 commands a second at most")
|
|
return
|
|
self.bucket -= 1
|
|
line = self.command_line(message)
|
|
if line is None or len(line) > COMMAND_LINE:
|
|
self.refused(identifier, "bad_request", f"line is a command ({COMMAND_LINE} characters at most)")
|
|
return
|
|
if sim.state == "loading":
|
|
self.refused(identifier, "loading", "the game did not answer in time (a map loading?)")
|
|
return
|
|
# (the real server's answer: its lines and ok, a failure's first line saying why, no error object)
|
|
ok, lines = sim.run(line, self.role, self.by)
|
|
self.put({"type": "reply", "id": identifier, "lines": lines, "ok": ok})
|
|
|
|
|
|
def emit_status_for(sim, connection):
|
|
"""the status event as the connection's role sees it: its seq the latest
|
|
event's (a status event takes no number of its own)"""
|
|
with sim.lock:
|
|
return {"type": "event", "seq": sim.seq, "time": now_ms(), "event": "status",
|
|
"status": sim.status(connection.role)}
|
|
|
|
|
|
Sim.emit_status_for = emit_status_for
|
|
|
|
|
|
def status_ticker(sim):
|
|
"""the status event, every 2 seconds, to each connection subscribed to it
|
|
(the invite by each one's role)"""
|
|
while True:
|
|
time.sleep(2)
|
|
with sim.lock:
|
|
subscribed = [connection for connection in sim.connections
|
|
if connection.role is not None and "status" in connection.groups]
|
|
for connection in subscribed:
|
|
connection.put(sim.emit_status_for(connection))
|
|
|
|
|
|
def director(sim):
|
|
while True:
|
|
time.sleep(sim.options.tick)
|
|
with sim.lock:
|
|
sim.step()
|
|
|
|
|
|
class Handler(socketserver.BaseRequestHandler):
|
|
def handle(self):
|
|
sim = self.server.sim
|
|
sock = self.request
|
|
address = self.client_address[0]
|
|
buffer = b""
|
|
while True:
|
|
start = time.monotonic()
|
|
try:
|
|
while b"\r\n\r\n" not in buffer:
|
|
if len(buffer) > MAXIMUM_HEAD:
|
|
sock.sendall(error(413, "too_large", "8 KB of head at most", close=True).bytes())
|
|
return
|
|
remaining = HEAD_SECONDS - (time.monotonic() - start)
|
|
if remaining <= 0:
|
|
return
|
|
sock.settimeout(remaining)
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
return
|
|
buffer += chunk
|
|
head, buffer = buffer.split(b"\r\n\r\n", 1)
|
|
if len(head) > MAXIMUM_HEAD:
|
|
sock.sendall(error(413, "too_large", "8 KB of head at most", close=True).bytes())
|
|
return
|
|
request = self.parse(head, address)
|
|
if isinstance(request, Response):
|
|
sock.sendall(request.bytes())
|
|
return
|
|
length_text = request.header("content-length") or "0"
|
|
if not length_text.isdigit():
|
|
sock.sendall(error(400, "bad_request", "a bad Content-Length", close=True).bytes())
|
|
return
|
|
length = int(length_text)
|
|
if length > MAXIMUM_BODY:
|
|
sock.sendall(error(413, "too_large", "64 KB of body at most", close=True).bytes())
|
|
return
|
|
while len(buffer) < length:
|
|
remaining = HEAD_SECONDS - (time.monotonic() - start)
|
|
if remaining <= 0:
|
|
return
|
|
sock.settimeout(remaining)
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
return
|
|
buffer += chunk
|
|
request.body, buffer = buffer[:length], buffer[length:]
|
|
sock.settimeout(None)
|
|
except (OSError, socket.timeout):
|
|
return
|
|
if request.path == "/ws" and "websocket" in request.header("upgrade").lower():
|
|
self.websocket(request, buffer)
|
|
return
|
|
if request.path == "/ws":
|
|
response = error(400, "bad_request", "/ws is the RCON: a WebSocket (Upgrade: websocket)")
|
|
else:
|
|
try:
|
|
response = Api(sim).handle(request)
|
|
except Exception as failure: # (a bug of the simulator's: answered, and shown)
|
|
print(f"server_sim: {request.method} {request.path}: {failure!r}", file=sys.stderr, flush=True)
|
|
response = error(500, "internal", str(failure))
|
|
if response is None:
|
|
return
|
|
keep = request.version == "HTTP/1.1" and request.header("connection").lower() != "close" or \
|
|
request.version == "HTTP/1.0" and request.header("connection").lower() == "keep-alive"
|
|
response.close = response.close or not keep
|
|
try:
|
|
# (HEAD: GET's answer, its head alone)
|
|
sock.sendall(response.bytes(head_only=request.method == "HEAD"))
|
|
except OSError:
|
|
return
|
|
if response.close:
|
|
return
|
|
|
|
def parse(self, head, address):
|
|
try:
|
|
text = head.decode("latin-1")
|
|
except UnicodeDecodeError:
|
|
return error(400, "bad_request", "not a request", close=True)
|
|
lines = text.split("\r\n")
|
|
parts = lines[0].split(" ")
|
|
if len(parts) != 3 or parts[2] not in ("HTTP/1.0", "HTTP/1.1") or not parts[1].startswith("/"):
|
|
return error(400, "bad_request", "HTTP/1.0 or 1.1 requests only", close=True)
|
|
method, target, version = parts
|
|
headers = {}
|
|
for line in lines[1:]:
|
|
name, colon, value = line.partition(":")
|
|
if not colon or not name or name != name.strip():
|
|
return error(400, "bad_request", "a bad header line", close=True)
|
|
name = name.lower()
|
|
value = value.strip()
|
|
if name in headers:
|
|
headers[name] += ("; " if name == "cookie" else ", ") + value
|
|
else:
|
|
headers[name] = value
|
|
if "transfer-encoding" in headers:
|
|
return error(400, "bad_request", "Transfer-Encoding is not taken (send Content-Length)", close=True)
|
|
if "expect" in headers:
|
|
return error(400, "bad_request", "Expect is not taken", close=True)
|
|
path = target.partition("?")[0]
|
|
if ".." in path or "//" in path or "\\" in path or "%" in path:
|
|
return error(400, "bad_request", "a path with .., //, \\ or % escapes", close=True)
|
|
return Request(method, target, version, headers, b"", address, self.server.sim.options.trusted_proxy)
|
|
|
|
def websocket(self, request, buffer):
|
|
sim = self.server.sim
|
|
sock = self.request
|
|
key = request.header("sec-websocket-key")
|
|
protocols = [item.strip() for item in request.header("sec-websocket-protocol").split(",")]
|
|
origin = request.header("origin")
|
|
api = Api(sim)
|
|
|
|
def refuse(response):
|
|
response.close = True
|
|
sock.sendall(response.bytes())
|
|
|
|
# (an upgrade counts against its address's allowance as a request does)
|
|
if not sim.allows_request(request.real_address, api.find_session(request)[0] is not None):
|
|
return refuse(error(429, "rate_limited", "too many requests: wait a minute", retry_after=60))
|
|
if request.method != "GET" or "upgrade" not in request.header("connection").lower():
|
|
return refuse(error(400, "bad_request", "/ws is the RCON: a WebSocket (Upgrade: websocket)"))
|
|
if request.header("sec-websocket-version") != "13":
|
|
return refuse(error(426, "bad_request", "WebSocket version 13 only", [("Sec-WebSocket-Version", "13")]))
|
|
try:
|
|
if len(base64.b64decode(key, validate=True)) != 16:
|
|
raise ValueError
|
|
except ValueError:
|
|
return refuse(error(400, "bad_request", "a bad Sec-WebSocket-Key"))
|
|
if SUBPROTOCOL not in protocols:
|
|
return refuse(error(400, "bad_request", f"the subprotocol is {SUBPROTOCOL}"))
|
|
if origin and not request.origin_matches():
|
|
return refuse(error(403, "origin", "a connection from another site"))
|
|
session = None
|
|
bearer = re.match(r"Bearer\s+(\S+)", request.header("authorization"))
|
|
if bearer:
|
|
session = sim.session(bearer.group(1))
|
|
elif origin and request.cookie("opence_session"):
|
|
session = sim.session(request.cookie("opence_session"))
|
|
if session and session.role == "owner" and not request.local:
|
|
session = None
|
|
connection = WsConnection(sim, sock, buffer, request.real_address, request.local)
|
|
with sim.lock:
|
|
if len(sim.connections) >= WS_CONNECTIONS:
|
|
return refuse(error(503, "busy", f"{WS_CONNECTIONS} remote consoles at once at most", retry_after=5))
|
|
sim.connections.append(connection)
|
|
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
|
|
sock.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Accept: {accept}\r\nSec-WebSocket-Protocol: {SUBPROTOCOL}\r\n\r\n").encode())
|
|
connection.run(session)
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
request_queue_size = 64
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--port", type=int, default=8099, help="the port (0: a free one)")
|
|
parser.add_argument("--bind", default="127.0.0.1", help="the address (loopback by default)")
|
|
parser.add_argument("--password", default="opence-admin", help="the admin password")
|
|
parser.add_argument("--moderator-password", default="opence-mod", help="the moderator password")
|
|
parser.add_argument("--viewer-password", default="", help="a viewer password (the simulator's own)")
|
|
parser.add_argument("--owner-password", default="opence-owner",
|
|
help="the owner's password (server.web_owner_password): signs in from loopback alone; "
|
|
"empty for none")
|
|
parser.add_argument("--instance", default="", help="server.instance (else a slug of the server's name)")
|
|
parser.add_argument("--exit-on-stop", action="store_true",
|
|
help="a stop (quit, sv_stop, sv_restart, a drain's end) exits with its code (0 or 5)")
|
|
parser.add_argument("--ui-dir", type=Path, default=UI_DIR, help="where the page's files are")
|
|
parser.add_argument("--no-web-ui", dest="web_ui", action="store_false", help="no page (server.web_ui = false)")
|
|
parser.add_argument("--tick", type=float, default=1.0, help="real seconds a simulated second takes")
|
|
parser.add_argument("--still", action="store_true",
|
|
help="no scripted events: five players in a game that lasts until a command ends it")
|
|
parser.add_argument("--seed", type=int, default=1, help="the script's random seed")
|
|
parser.add_argument("--log-addresses", action="store_true", help="players' addresses for every role")
|
|
parser.add_argument("--slow-consumer", action="store_true", help="WebSockets written slowly, a burst after "
|
|
"each subscription: their queues overflow")
|
|
parser.add_argument("--drop", action="store_true", help="WebSockets cut a few seconds after they open, one "
|
|
"API request in ten left unanswered")
|
|
parser.add_argument("--drop-after", type=float, default=5.0, help="seconds before --drop cuts a WebSocket")
|
|
parser.add_argument("--big-replies", action="store_true", help="command replies of more than 1 MiB")
|
|
parser.add_argument("--fragment", action="store_true", help="every message sent in continuation frames")
|
|
parser.add_argument("--verbose", action="store_true", help="the log's lines on standard output")
|
|
parser.add_argument("--trusted-proxy", default="", help="the reverse proxy's address (server.web_trusted_proxy): "
|
|
"its X-Forwarded-For (the last entry), -Host and -Proto are believed")
|
|
parser.add_argument("--request-limit", action="store_true", help="each address's allowance, as the real "
|
|
"server's: 60 requests a minute without a session, 600 with one (WebSocket upgrades too)")
|
|
options = parser.parse_args(argv)
|
|
if options.bind not in ("127.0.0.1", "::1", "localhost"):
|
|
short = [name for name, password in (("admin", options.password), ("moderator", options.moderator_password),
|
|
("viewer", options.viewer_password), ("owner", options.owner_password))
|
|
if password and len(password) < 8]
|
|
if short or not options.password:
|
|
print("server_sim: beyond loopback every web password is 8 characters or more", file=sys.stderr)
|
|
return 3
|
|
try:
|
|
server = Server((options.bind, options.port), Handler)
|
|
except OSError as failure:
|
|
print(f"server_sim: {failure}", file=sys.stderr)
|
|
return 4
|
|
options.port = server.server_address[1]
|
|
sim = Sim(options)
|
|
server.sim = sim
|
|
threading.Thread(target=director, args=(sim,), daemon=True).start()
|
|
threading.Thread(target=status_ticker, args=(sim,), daemon=True).start()
|
|
print(f"listening on http://{options.bind}:{options.port}", flush=True)
|
|
print(f"admin password: {options.password}", flush=True)
|
|
if options.moderator_password:
|
|
print(f"moderator password: {options.moderator_password}", flush=True)
|
|
if options.owner_password:
|
|
print(f"owner password (from loopback): {options.owner_password}", flush=True)
|
|
try:
|
|
server.serve_forever(poll_interval=0.2)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|