extends Node # RCON Admin Console — TCP Listener & Auth Layer # ============================================================================ # TCP-based remote administration for the dedicated game server. # Source-RCON-inspired protocol, lightweight custom implementation. # # Protocol: # 1. Client connects, first message is the password # 2. Server responds with "auth_ok\r\n" or "auth_fail\r\n" # 3. After auth, client sends single-line commands, newline-terminated # 4. Server responds with multi-line text, terminated by "END\r\n" # 5. 3 failed auth attempts → disconnect with 5s reconnect penalty # # Integration: # This is the network layer only. Auth and raw I/O live here. # Incoming commands are forwarded to RconCommandHandler for processing. # Connect to child node's `rcon_command` signal from game logic # to handle game-affecting commands (changelevel, kick, ban, etc.). # # Usage (standalone): # var rcon = RconServer.new() # rcon.enabled = true # rcon.port = 28960 # rcon.password = "mysecret" # add_child(rcon) # # Usage (with cvar registry — t_p4_config): # # Config system reads server.cfg and sets RCON cvars via CvarRegistry # # singleton. RconServer auto-detects and reads rcon_enabled, rcon_port, # # and rcon_password from it on _ready(). # # Lifecycle (property-driven): # rcon.enabled = true → calls _start_listening() via setter # rcon.enabled = false → calls _stop_listening() via setter # rcon.port = 28961 → re-binds on new port if currently listening # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- var enabled: bool = false: set = set_enabled var port: int = 28960: set = set_port var password: String = "changeme": set = set_password # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const AUTH_PENDING: int = 0 const AUTHENTICATED: int = 1 const MAX_AUTH_FAILS: int = 3 const AUTH_PENALTY_SECONDS: float = 5.0 const RESP_END: String = "END\r\n" const MAX_BUFFER_SIZE: int = 4096 const HEARTBEAT_INTERVAL: float = 30.0 # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- var _tcp_server: TCPServer = TCPServer.new() var _connections: Dictionary = {} # StreamPeerTCP -> ConnectionState var _banned_ips: Dictionary = {} # "ip" -> float (unban timestamp) var _command_handler: Node = null var _heartbeat_timer: float = 0.0 # --------------------------------------------------------------------------- # Initialization # --------------------------------------------------------------------------- func _ready() -> void: # Try to load password from file _load_password_from_file() # Children added in scene file take priority for child in get_children(): if child is Node and child.has_method("handle_command"): _command_handler = child break # Auto-load command handler if not already added as child if not _command_handler: var handler_path = "res://server/scripts/rcon_command_handler.gd" if ResourceLoader.exists(handler_path): var handler = load(handler_path).new() add_child(handler) _command_handler = handler # Apply overrides from CvarRegistry singleton (t_p4_config integration) _apply_cvar_overrides() if enabled: _start_listening() func _exit_tree() -> void: _stop_listening() # --------------------------------------------------------------------------- # Process loop — poll network, accept connections, handle I/O # --------------------------------------------------------------------------- func _process(delta: float) -> void: if not enabled or not _tcp_server or not _tcp_server.is_listening(): return _heartbeat_timer += delta # --- Accept new connections --- while _tcp_server.is_connection_available(): var stream: StreamPeerTCP = _tcp_server.take_connection() if not stream: continue var ip: String = stream.get_connected_address() # Check if IP is under auth penalty if _is_ip_banned(ip): _send_raw(stream, "Server busy — try again later.\r\n") _send_end(stream) stream.disconnect_from_host() continue _connections[stream] = { "state": AUTH_PENDING, "buffer": "", "fail_count": 0, "ip": ip } # --- Poll existing connections --- var to_remove: Array = [] for stream in _connections: var conn = _connections[stream] var status: int = stream.get_status() if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR: to_remove.append(stream) continue if status != StreamPeerTCP.STATUS_CONNECTED: continue # Read available data var avail: int = stream.get_available_bytes() if avail <= 0: continue var result: Array = stream.get_data(avail) if result[0] != OK: to_remove.append(stream) continue conn["buffer"] += result[1].get_string_from_utf8() # Buffer overflow protection if conn["buffer"].length() > MAX_BUFFER_SIZE: _send_raw(stream, "Buffer overflow — disconnected.\r\n") _send_end(stream) to_remove.append(stream) continue # Process complete lines while "\n" in conn["buffer"]: var idx: int = conn["buffer"].find("\n") var line: String = conn["buffer"].substr(0, idx).strip_edges() conn["buffer"] = conn["buffer"].substr(idx + 1) if line.is_empty(): continue if conn["state"] == AUTH_PENDING: _handle_auth(stream, conn, line) else: _handle_command(stream, line) # Cleanup disconnected clients for stream in to_remove: _cleanup_connection(stream) # --------------------------------------------------------------------------- # Auth handling # --------------------------------------------------------------------------- func _handle_auth(stream: StreamPeerTCP, conn: Dictionary, line: String) -> void: if line == password: conn["state"] = AUTHENTICATED _send_raw(stream, "auth_ok\r\n") _send_end(stream) else: conn["fail_count"] += 1 var remaining: int = MAX_AUTH_FAILS - conn["fail_count"] _send_raw(stream, "auth_fail\r\n") if remaining > 0: _send_raw(stream, str(remaining) + " attempt(s) remaining.\r\n") _send_end(stream) if conn["fail_count"] >= MAX_AUTH_FAILS: # Ban IP temporarily var ip: String = conn["ip"] _banned_ips[ip] = Time.get_unix_time_from_system() + AUTH_PENALTY_SECONDS stream.disconnect_from_host() print("[RCON] Auth failed 3 times from " + ip + " — penalty for " + str(AUTH_PENALTY_SECONDS) + "s") # --------------------------------------------------------------------------- # Command dispatch # --------------------------------------------------------------------------- func _handle_command(stream: StreamPeerTCP, line: String) -> void: var parts: PackedStringArray = line.split(" ", false) if parts.is_empty(): return var cmd: String = parts[0].to_lower() var args: PackedStringArray = parts.slice(1) # Route to command handler if _command_handler and _command_handler.has_method("handle_command"): var response: String = _command_handler.handle_command(cmd, args) _send_raw(stream, response) _send_end(stream) else: _send_raw(stream, "Command handler not available.\r\n") _send_end(stream) # --------------------------------------------------------------------------- # Response helpers # --------------------------------------------------------------------------- func _send_raw(stream: StreamPeerTCP, text: String) -> void: var data: PackedByteArray = text.to_utf8_buffer() stream.put_data(data) func _send_end(stream: StreamPeerTCP) -> void: _send_raw(stream, RESP_END) # --------------------------------------------------------------------------- # IP ban / penalty # --------------------------------------------------------------------------- func _is_ip_banned(ip: String) -> bool: if not _banned_ips.has(ip): return false if Time.get_unix_time_from_system() >= _banned_ips[ip]: _banned_ips.erase(ip) return false return true func _cleanup_connection(stream: StreamPeerTCP) -> void: if _connections.has(stream): _connections.erase(stream) if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: stream.disconnect_from_host() # --------------------------------------------------------------------------- # Server lifecycle — called from setters, _ready(), and external code # --------------------------------------------------------------------------- func _start_listening() -> void: if _tcp_server and _tcp_server.is_listening(): return if not _tcp_server: _tcp_server = TCPServer.new() var err: int = _tcp_server.listen(port) if err != OK: push_error("[RCON] Failed to listen on port " + str(port) + " (error " + str(err) + ")") enabled = false return print("[RCON] Listening on port " + str(port)) func _stop_listening() -> void: if _tcp_server and _tcp_server.is_listening(): _tcp_server.stop() # Disconnect all clients gracefully for stream in _connections: if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: _send_raw(stream, "Server shutting down.\r\n") _send_end(stream) stream.disconnect_from_host() _connections.clear() print("[RCON] Stopped") # --------------------------------------------------------------------------- # Config file loading # --------------------------------------------------------------------------- func _load_password_from_file() -> void: var pw_path: String = "res://server/data/rcon_password.cfg" if not ResourceLoader.exists(pw_path): return var file: FileAccess = FileAccess.open(pw_path, FileAccess.READ) if not file: return var line: String = file.get_line().strip_edges() if not line.is_empty() and not line.begins_with("#"): password = line print("[RCON] Password loaded from " + pw_path) file.close() # --------------------------------------------------------------------------- # Config overrides from cvar registry (if available) # --------------------------------------------------------------------------- func _apply_cvar_overrides() -> void: if not has_node("/root/CvarRegistry"): return var registry = get_node("/root/CvarRegistry") if registry and registry.has_method("get"): var v = registry.get("rcon_enabled") if v != null: enabled = bool(v) v = registry.get("rcon_port") if v != null: port = int(v) v = registry.get("rcon_password") if v != null: password = str(v) # --------------------------------------------------------------------------- # Setters — react to property changes # --------------------------------------------------------------------------- func set_enabled(val: bool) -> void: enabled = val if Engine.is_editor_hint(): return if enabled: _start_listening() else: _stop_listening() func set_port(val: int) -> void: port = clampi(val, 1024, 65535) if enabled and _tcp_server and _tcp_server.is_listening(): _stop_listening() _start_listening() func set_password(val: String) -> void: password = val