Phase 8: Headless dedicated server + RCON admin + netfox bootstrap

## Headless Server
- New entry point: scenes/headless_server.tscn + scripts/headless_server.gd
- Loads multiplayer-fps game, strips UI/rendering, auto-hosts ENet server
- Port config via --port arg, SERVER_PORT env, or default 34197
- RCON admin console on port 28960 (configurable via --rcon-port)

## Netfox Headless Bootstrap
- scripts/netfox_headless_bootstrap.gd — preloads all netfox dependency
  scripts in headless mode so class_names register before autoloads parse
- Registered as first autoload in project.godot
- Fixes 'Could not find type' errors for NetfoxLogger, NorayProtocolHandler,
  NetworkClocks, etc.

## RCON Admin
- Ported from old project: scripts/rcon/rcon_server.gd (TCP auth layer)
- scripts/rcon/rcon_command_handler.gd (command dispatch)
- Password via config/rcon_password.cfg, --rcon-password, or RCON_PASSWORD env

## Other Changes
- Added GameEvents stub (lan-bootstrapper dependency)
- Updated project.godot features to 4.7
- Added config/server_config.cfg for operator editing
- Updated RCON paths to new project structure
This commit is contained in:
2026-07-02 21:49:49 -04:00
parent b0c83af092
commit 5b93c514c7
8 changed files with 1135 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
; Tactical Shooter — Default Server Configuration
; Copy to user://server_config.cfg or config/server_config.cfg and edit.
[server]
server_name="Tactical Shooter Server"
description=""
bind_ip="0.0.0.0"
port=34197
max_players=16
password=""
tick_rate=64
[game]
round_time_seconds=600
warmup_time_seconds=60
friendly_fire=false
ff_damage_multiplier=0.5
gravity=-24.0
respawn_time_seconds=5.0
spectate_enabled=true
[movement]
walk_speed=5.0
sprint_speed=7.0
crouch_speed=2.0
jump_velocity=6.0
acceleration=20.0
air_acceleration=4.0
friction=8.0
[match]
win_limit=3
time_limit_seconds=0
map_rotation_mode="sequence"
maps="test_range"
[rcon]
enabled=false
password=""
port=28960
[logging]
log_level="info"
log_file=""
[teams]
team_count=2
players_per_team=8
+2
View File
@@ -23,7 +23,9 @@ config/icon="res://icon.png"
[autoload]
NetfoxBootstrap="*res://scripts/netfox_headless_bootstrap.gd"
Async="*res://examples/shared/scripts/async.gd"
GameEvents="*res://scripts/game_events_stub.gd"
Noray="*res://addons/netfox.noray/noray.gd"
PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd"
WindowTiler="*res://addons/netfox.extras/window-tiler.gd"
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://headless_server"]
[ext_resource type="Script" path="res://scripts/headless_server.gd" id="1"]
[node name="HeadlessServer" type="Node"]
script = ExtResource("1")
+7
View File
@@ -0,0 +1,7 @@
## GameEvents — Stub for headless/server compatibility.
## The forest-brawl GameEvents autoload was removed from project.godot,
## but some shared scripts (lan-bootstrapper.gd) reference it in class-body
## initializers. This stub provides the required singleton without
## pulling in the full forest-brawl game.
extends Node
signal on_event(event_name: String, data: Dictionary)
+256
View File
@@ -0,0 +1,256 @@
## HeadlessServer — Dedicated server entry point for headless mode.
##
## Loads the multiplayer-fps game scene, strips UI/rendering overhead,
## and auto-starts the ENet server. Designed to run with `--headless`.
##
## Usage:
## godot --headless --scene res://scenes/headless_server.tscn
## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197
##
## Architecture:
## This script runs as the main scene root in headless mode.
## 1. Loads multiplayer-fps.tscn (the full game scene)
## 2. Removes Environment (sky/light/camera) and UI (HUD/network popup)
## 3. Starts the ENet server on the configured port
## 4. Calls RoundManager.start_match() to begin gameplay
## 5. Listens for player connections via NetworkEvents
extends Node
## Default server port.
const DEFAULT_PORT: int = 34197
## The multiplayer-fps game scene.
var _game_scene: Node = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
print("[HeadlessServer] Starting dedicated server...")
# Determine port from command-line args or default
var port: int = _parse_port()
print("[HeadlessServer] Port: %d" % port)
# Load the multiplayer-fps game scene
var GameScene := load("res://examples/multiplayer-fps/multiplayer-fps.tscn")
if GameScene == null:
push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn")
get_tree().quit(1)
return
_game_scene = GameScene.instantiate()
add_child(_game_scene)
# Strip UI and rendering overhead
_strip_for_headless()
# Start RCON admin console
_start_rcon()
# Wait one frame for scene to settle, then host
await get_tree().process_frame
await get_tree().process_frame
# Start the ENet server
_start_server(port)
func _exit_tree() -> void:
_stop_server()
# ---------------------------------------------------------------------------
# Strip scene for headless mode
# ---------------------------------------------------------------------------
## Remove nodes that are useless in headless mode: rendering, UI, audio.
func _strip_for_headless() -> void:
if _game_scene == null:
return
# Remove Environment (sky, light, camera, world environment)
var env := _game_scene.get_node_or_null("Environment")
if env:
env.queue_free()
print("[HeadlessServer] Removed Environment node")
# Remove UI (network popup, HUD, crosshair, etc.)
var ui := _game_scene.get_node_or_null("UI")
if ui:
ui.queue_free()
print("[HeadlessServer] Removed UI node")
# Verify critical game logic nodes exist
var network := _game_scene.get_node_or_null("Network")
if network == null:
push_error("[HeadlessServer] Missing Network node in game scene!")
return
for required in ["Player Spawner", "TeamManager", "RoundManager", "EconomyManager", "Bomb"]:
if network.get_node_or_null(required) == null:
push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required)
print("[HeadlessServer] Scene stripped for headless mode")
# ---------------------------------------------------------------------------
# Server startup
# ---------------------------------------------------------------------------
func _start_server(port: int) -> void:
# Create ENet peer directly (same as lan-bootstrapper.gd::host())
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port)
if err != OK:
push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)])
get_tree().quit(1)
return
get_tree().get_multiplayer().multiplayer_peer = peer
get_tree().get_multiplayer().server_relay = true
print("[HeadlessServer] ENet server started on port %d" % port)
# Wait for NetworkEvents to fire on_server_start
await get_tree().process_frame
# The player-spawner.gd handles on_server_start by spawning the host avatar
# and calling round_manager.start_match(). If NetworkEvents fires it, we're set.
# Otherwise, manually start the match.
if NetworkEvents and NetworkEvents.enabled:
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
else:
# Fallback: manually start NetworkTime and signal server start
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
_manual_startup()
func _stop_server() -> void:
var mp := get_tree().get_multiplayer()
if mp and mp.multiplayer_peer:
mp.multiplayer_peer.close()
mp.multiplayer_peer = null
print("[HeadlessServer] Server stopped")
## Fallback startup when NetworkEvents isn't available.
func _manual_startup() -> void:
# Start NetworkTime if available
if Engine.has_singleton("NetworkTime"):
var nt = Engine.get_singleton("NetworkTime")
if nt and nt.has_method("start"):
nt.start()
print("[HeadlessServer] NetworkTime started manually")
# Find and start the match via RoundManager
var network := _game_scene.get_node_or_null("Network") if _game_scene else null
if network == null:
return
var spawner := network.get_node_or_null("Player Spawner")
if spawner:
# Manually call the host handler that spawns the host avatar
if spawner.has_method("_handle_host"):
spawner._handle_host()
print("[HeadlessServer] PlayerSpawner._handle_host() called")
# Manually emit peer join for host (peer 1)
if Engine.has_singleton("NetworkEvents"):
var ne = Engine.get_singleton("NetworkEvents")
if ne and ne.has_signal("on_server_start"):
ne.on_server_start.emit()
print("[HeadlessServer] Emitted NetworkEvents.on_server_start")
if ne and ne.has_signal("on_peer_join"):
ne.on_peer_join.emit(1)
print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)")
# Start the match via RoundManager
var round_mgr := network.get_node_or_null("RoundManager")
if round_mgr:
if round_mgr.has_method("start_match"):
round_mgr.call_deferred("start_match")
print("[HeadlessServer] RoundManager.start_match() called")
elif round_mgr.has_method("start_round"):
round_mgr.call_deferred("start_round")
print("[HeadlessServer] RoundManager.start_round() called")
print("[HeadlessServer] Manual startup complete")
# ---------------------------------------------------------------------------
# RCON admin console
# ---------------------------------------------------------------------------
## Start the RCON TCP admin console.
func _start_rcon() -> void:
var rcon_path := "res://scripts/rcon/rcon_server.gd"
if not ResourceLoader.exists(rcon_path):
print("[HeadlessServer] RCON script not found — skipping")
return
var RconServerClass := load(rcon_path)
if RconServerClass == null:
return
var rcon = RconServerClass.new()
rcon.name = "RconServer"
rcon.enabled = true
rcon.port = _parse_rcon_port()
rcon.password = _parse_rcon_password()
add_child(rcon)
print("[HeadlessServer] RCON admin console started on port %d" % rcon.port)
## Parse RCON port from args/env.
func _parse_rcon_port() -> int:
var args := OS.get_cmdline_args()
for i in range(args.size()):
if args[i] == "--rcon-port" and i + 1 < args.size():
return clampi(args[i + 1].to_int(), 1024, 65535)
if OS.has_environment("RCON_PORT"):
return clampi(OS.get_environment("RCON_PORT").to_int(), 1024, 65535)
return 28960 # default RCON port
## Parse RCON password from args/env/file.
func _parse_rcon_password() -> String:
var args := OS.get_cmdline_args()
for i in range(args.size()):
if args[i] == "--rcon-password" and i + 1 < args.size():
return args[i + 1]
if OS.has_environment("RCON_PASSWORD"):
return OS.get_environment("RCON_PASSWORD")
# Try loading from file
var pw_path := "res://config/rcon_password.cfg"
if ResourceLoader.exists(pw_path):
var f := FileAccess.open(pw_path, FileAccess.READ)
if f:
var line := f.get_line().strip_edges()
f.close()
if not line.is_empty() and not line.begins_with("#"):
return line
return "changeme"
# ---------------------------------------------------------------------------
# Port parsing
# ---------------------------------------------------------------------------
func _parse_port() -> int:
# Check user args (after -- separator): --port 34197
var user_args := OS.get_cmdline_user_args()
for i in range(user_args.size()):
if user_args[i] == "--port" and i + 1 < user_args.size():
var p := user_args[i + 1].to_int()
if p > 0 and p < 65536:
return p
# Check all args as fallback
var args := OS.get_cmdline_args()
for i in range(args.size()):
if args[i] == "--port" and i + 1 < args.size():
var p := args[i + 1].to_int()
if p > 0 and p < 65536:
return p
# Check environment variable
if OS.has_environment("SERVER_PORT"):
var p := OS.get_environment("SERVER_PORT").to_int()
if p > 0 and p < 65536:
return p
return DEFAULT_PORT
+89
View File
@@ -0,0 +1,89 @@
## NetfoxHeadlessBootstrap — Preload netfox dependencies for headless/server mode.
##
## In headless mode (`--headless`), Godot doesn't run EditorPlugin script
## scanning, so `class_name` registrations from addon scripts don't happen
## automatically. This causes parse errors when netfox autoloads reference
## class_names like `NetfoxLogger`, `NorayProtocolHandler`, etc.
##
## This bootstrap MUST be the FIRST autoload in project.godot (before any
## netfox autoload). It preloads all dependency scripts so their class_names
## are registered before netfox's own autoloads are parsed.
##
## Architecture:
## project.godot autoload order:
## 1. NetfoxHeadlessBootstrap (this script) ← preloads all deps
## 2. Async
## 3. Noray
## 4. NetworkTime
## 5. NetworkEvents
## 6. ... (all other netfox autoloads)
##
## This only executes in headless mode. In editor mode, the EditorPlugin
## handles class_name registration normally.
extends Node
func _init() -> void:
# Only needed in headless mode — editor mode handles class_names via EditorPlugin
if DisplayServer.get_name() != "headless":
return
print("[NetfoxHeadlessBootstrap] Headless mode detected — preloading netfox dependencies...")
# Core netfox internals used by all netfox modules
_load_safe("res://addons/netfox.internals/logger.gd") # NetfoxLogger
# Time system — used by NetworkTime autoload
_load_safe("res://addons/netfox/time/network-clocks.gd") # NetworkClocks
_load_safe("res://addons/netfox/time/network-tickrate-handshake.gd") # NetworkTickrateHandshake
_load_safe("res://addons/netfox/network-time-synchronizer.gd") # NetworkTimeSynchronizer
# Noray system — used by Noray autoload
_load_safe("res://addons/netfox.noray/protocol-handler.gd") # NorayProtocolHandler
# Rollback internals — used by NetworkRollback
_load_safe("res://addons/netfox.internals/ring-buffer.gd") # _RingBuffer
_load_safe("res://addons/netfox.internals/history-buffer.gd") # _HistoryBuffer
_load_safe("res://addons/netfox.internals/bimap.gd") # _BiMap
_load_safe("res://addons/netfox.internals/set.gd") # _Set
_load_safe("res://addons/netfox/properties/property-cache.gd") # PropertyCache
_load_safe("res://addons/netfox/properties/property-entry.gd") # PropertyEntry
_load_safe("res://addons/netfox/properties/property-config.gd") # _PropertyConfig
_load_safe("res://addons/netfox/properties/property-snapshot.gd") # _PropertySnapshot
_load_safe("res://addons/netfox/rollback/rollback-synchronizer.gd") # RollbackSynchronizer
_load_safe("res://addons/netfox/rewindable-action.gd") # RewindableAction
_load_safe("res://addons/netfox/tick-interpolator.gd") # TickInterpolator
_load_safe("res://addons/netfox.extras/base-net-input.gd") # BaseNetInput
# Weapon system (for multiplayer-fps)
_load_safe("res://addons/netfox.extras/weapon/network-weapon.gd") # NetworkWeapon
_load_safe("res://addons/netfox.extras/weapon/network-weapon-hitscan-3d.gd") # NetworkWeaponHitscan3D
_load_safe("res://addons/netfox.extras/weapon/network-weapon-proxy.gd") # _NetworkWeaponProxy
# State synchronizer — used for non-rollback state sync
_load_safe("res://addons/netfox/state-synchronizer.gd") # StateSynchronizer
# Schema system — used behind the scenes
_load_safe("res://addons/netfox/schemas/network-schema.gd") # _NetworkSchema
_load_safe("res://addons/netfox/schemas/network-schemas.gd") # NetworkSchemas
_load_safe("res://addons/netfox/schemas/network-schema-serializer.gd") # NetworkSchemaSerializer
# Serializers — needed internally
_load_safe("res://addons/netfox/serializers/base-snapshot-serializer.gd") # _BaseSnapshotSerializer
_load_safe("res://addons/netfox/serializers/tickset-serializer.gd") # _TicksetSerializer
# Servers — some needed for NetworkRollback internals
_load_safe("res://addons/netfox/servers/data/snapshot.gd") # _Snapshot
_load_safe("res://addons/netfox/servers/data/network-identifier.gd") # _NetworkIdentifier
_load_safe("res://addons/netfox/servers/data/network-identity-reference.gd") # _NetworkIdentityReference
_load_safe("res://addons/netfox/servers/data/object-snapshot.gd") # _ObjectSnapshot
_load_safe("res://addons/netfox/servers/data/per-object-history.gd") # _PerObjectHistory
_load_safe("res://addons/netfox/servers/data/property-pool.gd") # _PropertyPool
print("[NetfoxHeadlessBootstrap] ✓ All netfox dependencies preloaded")
## Load a script and catch errors gracefully.
func _load_safe(path: String) -> void:
var script = load(path)
if script == null:
push_warning("[NetfoxHeadlessBootstrap] Failed to load: %s" % path)
+403
View File
@@ -0,0 +1,403 @@
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)
#
# Plugin commands are delegated to PluginManager singleton:
# plugin list, plugin load, plugin unload, plugin reload,
# plugin info, plugin rescan
#
# 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)
"plugin":
return _cmd_plugin(args)
"say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec", "start_match", "end_round":
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",
" start_match Start the match (exit warmup)",
" end_round <team> [reason] Force-end the current round",
" 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",
" plugin <subcommand> [args] Plugin management (list/load/unload/reload/info/rescan)",
" 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 " ".join(args) + "\\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 (via SceneTree)
lines.append(" Nodes: " + str(get_tree().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"):
# Use Callable to avoid compile-time void return detection on dynamic object
var fn = Callable(registry, "set")
var err = fn.call(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"
# ---------------------------------------------------------------------------
# Plugin management — delegates to PluginManager singleton
# ---------------------------------------------------------------------------
func _cmd_plugin(args: PackedStringArray) -> String:
# Try to find PluginManager singleton (autoload)
var pm = get_node("/root/PluginManager")
if not pm:
return "PluginManager not available. Ensure PluginManager is registered as an autoload in project.godot.\r\n"
if not pm.has_method("rcon_command"):
return "PluginManager does not support RCON commands.\r\n"
return pm.rcon_command(args)
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# Plugin management commands — interfaces with PluginManager singleton
# ---------------------------------------------------------------------------
func _cmd_plugins(args: PackedStringArray) -> String:
"""Handle 'plugins' command with subcommands."""
if args.is_empty():
return _cmd_plugins_help()
var subcmd: String = args[0].to_lower()
match subcmd:
"list":
return _cmd_plugins_list()
"load":
if args.size() < 2:
return "Usage: plugins load <name>\r\n"
return _cmd_plugins_load(args[1])
"unload":
if args.size() < 2:
return "Usage: plugins unload <name>\r\n"
return _cmd_plugins_unload(args[1])
"reload":
if args.size() < 2:
return "Usage: plugins reload <name>\r\n"
return _cmd_plugins_reload(args[1])
"info":
if args.size() < 2:
return "Usage: plugins info <name>\r\n"
return _cmd_plugins_info(args[1])
"scan", "rescan":
return _cmd_plugins_rescan()
_:
return "Unknown plugins subcommand: '" + subcmd + "'\r\n" + _cmd_plugins_help()
func _cmd_plugins_help() -> String:
return "\r\n".join([
"=== Plugin Management ===",
" plugins list List all loaded plugins",
" plugins load <name> Load a plugin by name from disk",
" plugins unload <name> Unload a running plugin",
" plugins reload <name> Reload a plugin (hotswap)",
" plugins info <name> Show metadata for a plugin",
" plugins rescan Rescan plugin directory for new manifests",
]) + "\r\n"
func _cmd_plugins_list() -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available (no PluginManager singleton).\r\n"
var plugins = mgr.list_plugins()
if plugins.is_empty():
return "No plugins loaded.\r\n"
var lines: PackedStringArray = []
lines.append("=== Loaded Plugins (" + str(plugins.size()) + ") ===")
lines.append(" %-24s %-10s %s" % ["Name", "Version", "Author"])
lines.append(" " + "-".repeat(50))
for p in plugins:
lines.append(" %-24s %-10s %s" % [p.name, p.version, p.author])
return "\r\n".join(lines) + "\r\n"
func _cmd_plugins_load(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("discover_plugins"):
return "PluginManager does not support targeted loading yet — use 'plugins rescan' to reload all.\r\n"
if mgr.get_plugin(name):
return "Plugin '" + name + "' is already loaded.\r\n"
var result = mgr.discover_plugins()
if mgr.get_plugin(name):
return "Plugin '" + name + "' loaded successfully.\r\n"
else:
return "Failed to load '" + name + "'. Check that plugin.tres exists in user://plugins/" + name.to_lower() + "/\r\n"
func _cmd_plugins_unload(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("unload_plugin"):
return "PluginManager does not support unloading.\r\n"
if mgr.unload_plugin(name):
return "Plugin '" + name + "' unloaded.\r\n"
else:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
func _cmd_plugins_reload(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("reload_plugin"):
return "PluginManager does not support reloading.\r\n"
if mgr.reload_plugin(name):
return "Plugin '" + name + "' reloaded.\r\n"
else:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
func _cmd_plugins_info(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
var plugin = mgr.get_plugin(name)
if not plugin:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
var lines: PackedStringArray = []
lines.append("=== Plugin: " + plugin.plugin_name + " ===")
lines.append(" Name: " + plugin.plugin_name)
lines.append(" Version: " + plugin.plugin_version)
lines.append(" Author: " + plugin.plugin_author)
lines.append(" Description: " + plugin.plugin_description)
lines.append(" Manifest: " + plugin.plugin_manifest_path)
return "\r\n".join(lines) + "\r\n"
func _cmd_plugins_rescan() -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
var result = mgr.discover_plugins()
return "Rescan complete — loaded: " + str(result.get("loaded", 0)) + \
", failed: " + str(result.get("failed", 0)) + \
", skipped: " + str(result.get("skipped", 0)) + "\r\n"
# ---------------------------------------------------------------------------
# Helper to locate PluginManager singleton
# ---------------------------------------------------------------------------
func _get_plugin_manager():
"""Get the PluginManager singleton, or null if not available."""
if Engine.has_singleton("PluginManager"):
return Engine.get_singleton("PluginManager")
if has_node("/root/PluginManager"):
return get_node("/root/PluginManager")
return null
+325
View File
@@ -0,0 +1,325 @@
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://config/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:
# CvarRegistry not available in this project version
pass
# ---------------------------------------------------------------------------
# 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