t_p4_rcon: RCON admin console — TCP listener + command handler
Implementation: - server/scripts/rcon_server.gd — TCPServer-based listener, auth state machine (3-strike penalty), per-frame I/O polling, StreamPeerTCP per-connection management, IP-based auth timeout - server/scripts/rcon_command_handler.gd — 14-command dispatch table, signal-based dispatch for game-affecting commands (changelevel, kick, ban, say, players, exec), inline handlers for help/echo/status/quit and cvar operations via optional CvarRegistry singleton (t_p4_config) - server/data/rcon_password.cfg — default password file (gitignored) - .gitignore — exclude password file from version control Protocol: Source-RCON-inspired lightweight TCP, newline-delimited commands, END-terminated multi-line responses. First message = password auth. 3 failed auth attempts → 5s reconnect penalty. CvarRegistry integration: auto-detects /root/CvarRegistry singleton on _ready() and reads rcon_enabled, rcon_port, rcon_password if present.
This commit is contained in:
@@ -24,3 +24,6 @@ server/build/
|
|||||||
*.zip
|
*.zip
|
||||||
*.exe
|
*.exe
|
||||||
*.pck
|
*.pck
|
||||||
|
|
||||||
|
# Secrets (operator must create per deployment)
|
||||||
|
server/data/rcon_password.cfg
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# RCON Admin Console — Command Handler
|
||||||
|
# ============================================================================
|
||||||
|
# Processes RCON commands, returns formatted response strings.
|
||||||
|
# Game-affecting commands (changelevel, kick, ban, say, players, etc.)
|
||||||
|
# are dispatched via the `rcon_command` signal so game logic can handle them
|
||||||
|
# without coupling RCON to game-specific systems.
|
||||||
|
#
|
||||||
|
# Commands handled inline (no game dependency):
|
||||||
|
# help, echo/ping, status (engine-level stats), quit
|
||||||
|
# cvarlist, cvar_get, cvar_set (via CvarRegistry singleton if available)
|
||||||
|
#
|
||||||
|
# Commands dispatched via signal:
|
||||||
|
# changelevel, kick, ban, unban, say, msg, players, exec
|
||||||
|
#
|
||||||
|
# Connection pattern for game logic:
|
||||||
|
# func _ready():
|
||||||
|
# var rcon = get_node("/root/RconServer/RconCommandHandler")
|
||||||
|
# if rcon:
|
||||||
|
# rcon.rcon_command.connect(_on_rcon_command)
|
||||||
|
#
|
||||||
|
# func _on_rcon_command(command: String, args: PackedStringArray):
|
||||||
|
# match command:
|
||||||
|
# "changelevel":
|
||||||
|
# $ServerManager.change_level(args[0])
|
||||||
|
# "kick":
|
||||||
|
# $PlayerManager.kick_player(args[0])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
signal rcon_command(command: String, args: PackedStringArray)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — called by rcon_server.gd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func handle_command(cmd: String, args: PackedStringArray) -> String:
|
||||||
|
match cmd:
|
||||||
|
"help":
|
||||||
|
return _cmd_help(args)
|
||||||
|
"echo", "ping":
|
||||||
|
return _cmd_echo(args)
|
||||||
|
"status":
|
||||||
|
return _cmd_status(args)
|
||||||
|
"quit":
|
||||||
|
return _cmd_quit(args)
|
||||||
|
"cvarlist":
|
||||||
|
return _cmd_cvarlist(args)
|
||||||
|
"cvar_get":
|
||||||
|
return _cmd_cvar_get(args)
|
||||||
|
"cvar_set":
|
||||||
|
return _cmd_cvar_set(args)
|
||||||
|
"say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec":
|
||||||
|
return _cmd_dispatch(cmd, args)
|
||||||
|
_:
|
||||||
|
return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\r\n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_help(_args: PackedStringArray) -> String:
|
||||||
|
var lines: PackedStringArray = [
|
||||||
|
"=== RCON Commands ===",
|
||||||
|
" help Show this help",
|
||||||
|
" echo [text] Echo text back (ping test)",
|
||||||
|
" status Show server engine status",
|
||||||
|
" players List connected players",
|
||||||
|
" say <message> Broadcast chat to all players",
|
||||||
|
" msg <player> <message> Send private message",
|
||||||
|
" changelevel <map_name> Load a new map",
|
||||||
|
" kick <player> [reason] Remove a player",
|
||||||
|
" ban <player> [duration] Ban a player from the server",
|
||||||
|
" unban <player_or_steamid> Remove a ban",
|
||||||
|
" exec <config_file> Execute a server config file",
|
||||||
|
" cvarlist [pattern] List cvars (optional filter)",
|
||||||
|
" cvar_get <name> Get a cvar's value",
|
||||||
|
" cvar_set <name> <value> Set a writable cvar",
|
||||||
|
" quit Shut down the server gracefully",
|
||||||
|
]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_echo(args: PackedStringArray) -> String:
|
||||||
|
if args.is_empty():
|
||||||
|
return "Pong\r\n"
|
||||||
|
return args.join(" ") + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_status(_args: PackedStringArray) -> String:
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Server Status ===")
|
||||||
|
|
||||||
|
# Engine version
|
||||||
|
var vi: Dictionary = Engine.get_version_info()
|
||||||
|
lines.append(" Engine: Godot " + str(vi.get("major", "?")) + "." + str(vi.get("minor", "?")) + "." + str(vi.get("patch", "?")))
|
||||||
|
|
||||||
|
# FPS
|
||||||
|
lines.append(" FPS: " + str(Engine.get_frames_per_second()))
|
||||||
|
|
||||||
|
# Physics tick rate
|
||||||
|
lines.append(" Tickrate: " + str(Engine.physics_ticks_per_second) + " Hz")
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
var uptime_ms: int = Time.get_ticks_msec()
|
||||||
|
var total_sec: int = uptime_ms / 1000
|
||||||
|
var h: int = total_sec / 3600
|
||||||
|
var m: int = (total_sec % 3600) / 60
|
||||||
|
var s: int = total_sec % 60
|
||||||
|
lines.append(" Uptime: " + str(h) + "h " + str(m) + "m " + str(s) + "s")
|
||||||
|
|
||||||
|
# Server time
|
||||||
|
var dt: Dictionary = Time.get_datetime_dict_from_system()
|
||||||
|
lines.append(" Time: " + \
|
||||||
|
str(dt.get("year", 0)) + "-" + str(dt.get("month", 1)).pad_zeros(2) + "-" + str(dt.get("day", 1)).pad_zeros(2) + " " + \
|
||||||
|
str(dt.get("hour", 0)).pad_zeros(2) + ":" + str(dt.get("minute", 0)).pad_zeros(2) + ":" + str(dt.get("second", 0)).pad_zeros(2))
|
||||||
|
|
||||||
|
# Performance metrics
|
||||||
|
lines.append(" Objects: " + str(Performance.get_monitor(Performance.OBJECT_COUNT)))
|
||||||
|
var mem_kb: float = Performance.get_monitor(Performance.MEMORY_STATIC)
|
||||||
|
lines.append(" Memory: " + str(snapped(mem_kb / 1024.0, 0.1)) + " MB")
|
||||||
|
|
||||||
|
# Node count
|
||||||
|
lines.append(" Nodes: " + str(Performance.get_monitor(Performance.NODE_COUNT)))
|
||||||
|
|
||||||
|
# RCON info
|
||||||
|
lines.append(" RCON: enabled on port ? (see rcon_server)")
|
||||||
|
|
||||||
|
# Try to get server name from engine
|
||||||
|
var server_name: String = ProjectSettings.get_setting("application/config/name", "Tactical Shooter")
|
||||||
|
lines.append(" Server: " + server_name)
|
||||||
|
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_quit(_args: PackedStringArray) -> String:
|
||||||
|
# Defer shutdown to next frame so the response can be sent
|
||||||
|
get_tree().call_deferred("quit")
|
||||||
|
return "Server shutting down...\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvarlist(args: PackedStringArray) -> String:
|
||||||
|
var pattern: String = args[0] if args.size() > 0 else ""
|
||||||
|
|
||||||
|
if not has_node("/root/CvarRegistry"):
|
||||||
|
if pattern.is_empty() or "rcon" in pattern:
|
||||||
|
return "rcon_enabled: bool\r\nrcon_port: int\r\nrcon_password: string [PW]\r\n(Cvar registry not available — RCON cvars shown)\r\n"
|
||||||
|
return "Cvar registry not available. No cvars match '" + pattern + "'.\r\n"
|
||||||
|
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if not registry or not registry.has_method("list_cvars"):
|
||||||
|
return "Cvar registry available but incompatible.\r\n"
|
||||||
|
|
||||||
|
var cvar_names: Array = registry.list_cvars()
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Cvar List" + (" (filter: '" + pattern + "')" if pattern else "") + " ===")
|
||||||
|
|
||||||
|
var found: int = 0
|
||||||
|
for cvar_name in cvar_names:
|
||||||
|
if not pattern.is_empty() and pattern not in str(cvar_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
var val = registry.get(cvar_name)
|
||||||
|
var flags: int = 0
|
||||||
|
var cvar_type: String = "?"
|
||||||
|
|
||||||
|
if registry.has_method("get_flags"):
|
||||||
|
flags = registry.get_flags(cvar_name)
|
||||||
|
if registry.has_method("get_type"):
|
||||||
|
cvar_type = str(registry.get_type(cvar_name))
|
||||||
|
|
||||||
|
var readonly: String = " [RO]" if (flags & 1) else ""
|
||||||
|
var passwd: String = " [PW]" if str(cvar_name).find("password") >= 0 else ""
|
||||||
|
lines.append(" " + str(cvar_name) + " = " + str(val) + " (" + cvar_type + ")" + readonly + passwd)
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
if found == 0:
|
||||||
|
return "No cvars match '" + pattern + "'.\r\n"
|
||||||
|
|
||||||
|
lines.append(str(found) + " cvars listed.")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvar_get(args: PackedStringArray) -> String:
|
||||||
|
if args.is_empty():
|
||||||
|
return "Usage: cvar_get <name>\r\n"
|
||||||
|
|
||||||
|
var name: String = args[0]
|
||||||
|
|
||||||
|
if has_node("/root/CvarRegistry"):
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if registry and registry.has_method("get"):
|
||||||
|
var val = registry.get(name)
|
||||||
|
if val != null:
|
||||||
|
return name + " = " + str(val) + "\r\n"
|
||||||
|
return "Cvar not found: " + name + "\r\n"
|
||||||
|
|
||||||
|
return "Cvar registry not available.\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvar_set(args: PackedStringArray) -> String:
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: cvar_set <name> <value>\r\n"
|
||||||
|
|
||||||
|
var name: String = args[0]
|
||||||
|
var value: String = args[1]
|
||||||
|
|
||||||
|
if has_node("/root/CvarRegistry"):
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if registry and registry.has_method("set"):
|
||||||
|
var err: int = registry.set(name, value)
|
||||||
|
if err == OK:
|
||||||
|
return name + " = " + value + "\r\n"
|
||||||
|
match err:
|
||||||
|
ERR_INVALID_PARAMETER:
|
||||||
|
return "Cvar not found or read-only: " + name + "\r\n"
|
||||||
|
ERR_INVALID_DATA:
|
||||||
|
return "Invalid value for " + name + ": " + value + "\r\n"
|
||||||
|
_:
|
||||||
|
return "Failed to set " + name + " (error " + str(err) + ")\r\n"
|
||||||
|
|
||||||
|
return "Cvar registry not available.\r\n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delegated commands — emit signal for game logic to handle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_dispatch(cmd: String, args: PackedStringArray) -> String:
|
||||||
|
rcon_command.emit(cmd, args)
|
||||||
|
return "Command '" + cmd + "' dispatched to server.\r\n"
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
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
|
||||||
Reference in New Issue
Block a user