M2.1: dedicated server, joining, identity handshake, names, lobby
- Net autoload: headless dedicated server (--server) on UDP 24242 via ENet, server.json preferences (flags override per run, never saved), reserved names in names.json - Identity: RSA-2048 key per install (Godot has no Ed25519), player id = key hash, challenge/response so ids can't be faked; profile with name and limited appearance (tunic, hair, beard) - Server refuses bans, allowlist misses, duplicate logins, bad or taken names, wrong passwords, old protocol versions and a full server, each with a clear reason; first player on a new server becomes admin - Title screen: Profile, Join Game, and a lobby with server info, rules and who's online - test_net_lobby: real headless server + bot processes over UDP (38 checks); --exit-with so test servers never outlive the test
This commit is contained in:
369
scripts/net/net.gd
Normal file
369
scripts/net/net.gd
Normal file
@@ -0,0 +1,369 @@
|
||||
extends Node
|
||||
## Autoload "Net": the multiplayer layer (docs/MULTIPLAYER_PLAN.md).
|
||||
##
|
||||
## M2.1 covers the skeleton: a dedicated server, joining it, the identity
|
||||
## handshake, name rules and the player list. Gameplay over the network
|
||||
## follows in M2.2-M2.5; single-player still runs as before.
|
||||
##
|
||||
## Server: `godot --headless --path . -- --server [--port 24242]
|
||||
## [--config user://server/server.json] [--server-name "..."]
|
||||
## [--max-players 8] [--password ...]`
|
||||
## Client: Net.join("host:port", password) from the title screen.
|
||||
##
|
||||
## Handshake (all reliable ENet over one UDP port):
|
||||
## client -> server _c2s_hello {protocol, name, appearance, public_key, password}
|
||||
## server -> client _s2c_challenge nonce (32 random bytes)
|
||||
## client -> server _c2s_proof signature (nonce signed with the private key)
|
||||
## server -> client _s2c_welcome {...} or _s2c_rejected reason
|
||||
## Then the server tells everyone about joins and leaves.
|
||||
|
||||
signal state_changed(state: String) ## offline | connecting | connected | server
|
||||
signal joined ## the server welcomed us
|
||||
signal join_failed(reason: String)
|
||||
signal left(reason: String) ## disconnected after joining
|
||||
signal players_changed
|
||||
|
||||
const Identity = preload("res://scripts/net/identity.gd")
|
||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||
|
||||
const PROTOCOL := 1
|
||||
const DEFAULT_PORT := 24242
|
||||
const HANDSHAKE_TIMEOUT := 10.0
|
||||
const JOIN_TIMEOUT := 12.0
|
||||
|
||||
var state := "offline"
|
||||
var dedicated := false ## started with --server
|
||||
var identity: RefCounted ## identity.gd (clients)
|
||||
var config: RefCounted ## server_config.gd (server)
|
||||
var server_info: Dictionary = {} ## the server's public settings (clients)
|
||||
## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}.
|
||||
var players: Dictionary = {}
|
||||
var local_peer := 0
|
||||
|
||||
## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another
|
||||
## protocol version.
|
||||
var debug_forge := false
|
||||
var debug_protocol := 0
|
||||
var debug_skip_name_check := false ## send a bad name anyway: the server must refuse it
|
||||
|
||||
var _peer: ENetMultiplayerPeer
|
||||
var _pending: Dictionary = {} ## server: peer -> {"t": secs, "hello": {}, "nonce": bytes}
|
||||
var _reject_reason := ""
|
||||
var _join_left := 0.0
|
||||
var _args: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
_args = parse_args(OS.get_cmdline_user_args())
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
if _args.has("server"):
|
||||
dedicated = true
|
||||
_start_dedicated.call_deferred()
|
||||
# `--exit-with <pid>`: quit when that process is gone (test servers and
|
||||
# bots, so a crashed test never leaves them running).
|
||||
if _args.has("exit-with"):
|
||||
var parent := int(_args["exit-with"])
|
||||
var t := Timer.new()
|
||||
t.wait_time = 1.0
|
||||
t.autostart = true
|
||||
t.timeout.connect(func():
|
||||
# (OS.is_process_running only works for our own children.)
|
||||
if OS.get_name() != "Windows" and OS.execute("kill", ["-0", str(parent)]) != 0:
|
||||
print("[net] parent process %d is gone, quitting" % parent)
|
||||
stop()
|
||||
get_tree().quit())
|
||||
add_child(t)
|
||||
|
||||
## "--a=b", "--a b" and bare "--flag" all work.
|
||||
static func parse_args(args: PackedStringArray) -> Dictionary:
|
||||
var out := {}
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
var a: String = args[i]
|
||||
if a.begins_with("--"):
|
||||
var k := a.trim_prefix("--")
|
||||
if k.contains("="):
|
||||
out[k.get_slice("=", 0)] = k.substr(k.find("=") + 1)
|
||||
elif i + 1 < args.size() and not args[i + 1].begins_with("--"):
|
||||
out[k] = args[i + 1]
|
||||
i += 1
|
||||
else:
|
||||
out[k] = true
|
||||
i += 1
|
||||
return out
|
||||
|
||||
## The local identity, loaded on first use (`--identity <dir>` overrides).
|
||||
func get_identity() -> RefCounted:
|
||||
if identity == null:
|
||||
identity = Identity.open(String(_args.get("identity", Identity.DEFAULT_DIR)))
|
||||
return identity
|
||||
|
||||
func _set_state(s: String) -> void:
|
||||
state = s
|
||||
state_changed.emit(s)
|
||||
|
||||
func is_server() -> bool:
|
||||
return state == "server"
|
||||
|
||||
# --- server -------------------------------------------------------------------
|
||||
|
||||
func _start_dedicated() -> void:
|
||||
var err := start_server(String(_args.get("config", ServerConfig.DEFAULT_PATH)), _args)
|
||||
if not err.is_empty():
|
||||
printerr("[server] ", err)
|
||||
get_tree().quit(1)
|
||||
|
||||
## Opens the server. Returns "" on success, otherwise what went wrong.
|
||||
func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
||||
if state != "offline":
|
||||
return "Already %s" % state
|
||||
config = ServerConfig.open(config_path)
|
||||
var map := {"port": "port", "server-name": "server_name", "max-players": "max_players",
|
||||
"password": "password", "world": "world"}
|
||||
for flag in map:
|
||||
if overrides.has(flag):
|
||||
var v: Variant = overrides[flag]
|
||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players"] else String(v)
|
||||
_peer = ENetMultiplayerPeer.new()
|
||||
var port := int(config.v("port"))
|
||||
# A few spare slots so a full server can still say "server is full".
|
||||
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
|
||||
if err != OK:
|
||||
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false # clients only ever talk to the server
|
||||
players.clear()
|
||||
_pending.clear()
|
||||
_set_state("server")
|
||||
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
|
||||
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
|
||||
ProjectSettings.globalize_path(config.path)])
|
||||
return ""
|
||||
|
||||
func stop() -> void:
|
||||
if _peer != null:
|
||||
_peer.close()
|
||||
_peer = null
|
||||
multiplayer.multiplayer_peer = null
|
||||
players.clear()
|
||||
_pending.clear()
|
||||
server_info.clear()
|
||||
local_peer = 0
|
||||
if state != "offline":
|
||||
_set_state("offline")
|
||||
|
||||
func _on_peer_connected(id: int) -> void:
|
||||
if is_server():
|
||||
_pending[id] = {"t": 0.0}
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
if not is_server():
|
||||
return
|
||||
_pending.erase(id)
|
||||
if players.has(id):
|
||||
var p: Dictionary = players[id]
|
||||
players.erase(id)
|
||||
print("[server] %s left (%d online)" % [p.name, players.size()])
|
||||
_s2c_player_left.rpc(id)
|
||||
players_changed.emit()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if is_server():
|
||||
for id in _pending.keys():
|
||||
_pending[id].t += delta
|
||||
if _pending[id].t > HANDSHAKE_TIMEOUT:
|
||||
_reject(id, "Took too long to say hello")
|
||||
elif state == "connecting":
|
||||
_join_left -= delta
|
||||
if _join_left <= 0.0:
|
||||
_fail("No answer from the server")
|
||||
|
||||
func _reject(peer: int, reason: String) -> void:
|
||||
_pending.erase(peer)
|
||||
print("[server] rejected peer %d: %s" % [peer, reason])
|
||||
_s2c_rejected.rpc_id(peer, reason)
|
||||
# Let the message go out before hanging up.
|
||||
get_tree().create_timer(0.4).timeout.connect(func():
|
||||
if _peer != null and is_server():
|
||||
_peer.disconnect_peer(peer))
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _c2s_hello(d: Dictionary) -> void:
|
||||
var peer := multiplayer.get_remote_sender_id()
|
||||
if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"):
|
||||
return
|
||||
if int(d.get("protocol", 0)) != PROTOCOL:
|
||||
_reject(peer, "Different game version (server %d, you %d): update the game" % [PROTOCOL, int(d.get("protocol", 0))])
|
||||
return
|
||||
var pw := String(config.v("password"))
|
||||
if not pw.is_empty() and String(d.get("password", "")) != pw:
|
||||
_reject(peer, "Wrong password")
|
||||
return
|
||||
var nonce := Crypto.new().generate_random_bytes(32)
|
||||
_pending[peer].hello = d
|
||||
_pending[peer].nonce = nonce
|
||||
_s2c_challenge.rpc_id(peer, nonce)
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _c2s_proof(sig: PackedByteArray) -> void:
|
||||
var peer := multiplayer.get_remote_sender_id()
|
||||
if not is_server() or not _pending.has(peer) or not _pending[peer].has("nonce"):
|
||||
return
|
||||
var hello: Dictionary = _pending[peer].hello
|
||||
var pem := String(hello.get("public_key", ""))
|
||||
if not Identity.verify(pem, _pending[peer].nonce, sig):
|
||||
_reject(peer, "Identity check failed")
|
||||
return
|
||||
var id := Identity.id_for(pem)
|
||||
var name := String(hello.get("name", ""))
|
||||
var why := _admission_problem(id, name)
|
||||
if not why.is_empty():
|
||||
_reject(peer, why)
|
||||
return
|
||||
_pending.erase(peer)
|
||||
config.reserve_name(name, id)
|
||||
if config.data.admins.is_empty():
|
||||
config.add_admin(id) # first player on a new server (MULTIPLAYER_PLAN.md §12)
|
||||
print("[server] %s is the first player, so they're now an admin" % name)
|
||||
var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
|
||||
"admin": config.is_admin(id)}
|
||||
players[peer] = p
|
||||
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
|
||||
players.size(), int(config.v("max_players"))])
|
||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players})
|
||||
for other in players:
|
||||
if other != peer:
|
||||
_s2c_player_joined.rpc_id(other, peer, p)
|
||||
players_changed.emit()
|
||||
|
||||
## Why player `id` calling themselves `name` can't come in ("" = they can).
|
||||
func _admission_problem(id: String, name: String) -> String:
|
||||
if id in config.data.banned:
|
||||
return "You're banned from this server"
|
||||
if not config.data.allowlist.is_empty() and not id in config.data.allowlist:
|
||||
return "You're not on this server's allowlist"
|
||||
for other in players.values():
|
||||
if other.id == id:
|
||||
return "You're already connected from another game"
|
||||
var bad := Identity.name_problem(name)
|
||||
if not bad.is_empty():
|
||||
return bad
|
||||
var owner: String = config.name_owner(name)
|
||||
if not owner.is_empty() and owner != id:
|
||||
return "The name '%s' is taken on this server: pick another" % name
|
||||
if players.size() >= int(config.v("max_players")):
|
||||
return "The server is full (%d/%d)" % [players.size(), int(config.v("max_players"))]
|
||||
return ""
|
||||
|
||||
# --- client -------------------------------------------------------------------
|
||||
|
||||
## Splits "host", "host:port" or "[v6]:port". Returns [host, port].
|
||||
static func parse_address(a: String) -> Array:
|
||||
a = a.strip_edges()
|
||||
if a.begins_with("["):
|
||||
var close := a.find("]")
|
||||
var host := a.substr(1, close - 1)
|
||||
var rest := a.substr(close + 1)
|
||||
return [host, int(rest.trim_prefix(":")) if rest.begins_with(":") else DEFAULT_PORT]
|
||||
if a.count(":") == 1:
|
||||
var port := a.get_slice(":", 1)
|
||||
return [a.get_slice(":", 0), int(port) if port.is_valid_int() else DEFAULT_PORT]
|
||||
return [a, DEFAULT_PORT]
|
||||
|
||||
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
||||
func join(address: String, password: String = "") -> void:
|
||||
if state != "offline":
|
||||
stop()
|
||||
var me := get_identity()
|
||||
var bad := Identity.name_problem(me.name)
|
||||
if not bad.is_empty() and not debug_skip_name_check:
|
||||
join_failed.emit("Set your name first (%s)" % bad.to_lower())
|
||||
return
|
||||
var hp := parse_address(address)
|
||||
if String(hp[0]).is_empty():
|
||||
join_failed.emit("Enter a server address")
|
||||
return
|
||||
_peer = ENetMultiplayerPeer.new()
|
||||
var err := _peer.create_client(hp[0], hp[1])
|
||||
if err != OK:
|
||||
_peer = null
|
||||
join_failed.emit("Couldn't start connecting (error %d)" % err)
|
||||
return
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
_reject_reason = ""
|
||||
_pending_password = password
|
||||
_join_left = JOIN_TIMEOUT
|
||||
me.last_server = address.strip_edges()
|
||||
me.save_profile()
|
||||
_set_state("connecting")
|
||||
|
||||
var _pending_password := ""
|
||||
|
||||
func leave() -> void:
|
||||
var was := state
|
||||
stop()
|
||||
if was == "connected":
|
||||
left.emit("You left the server")
|
||||
|
||||
func _on_connected() -> void:
|
||||
var me := get_identity()
|
||||
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
||||
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||
var sig: PackedByteArray = get_identity().sign(nonce)
|
||||
if debug_forge:
|
||||
var other := CryptoKey.new()
|
||||
other = Crypto.new().generate_rsa(2048)
|
||||
sig = Crypto.new().sign(HashingContext.HASH_SHA256, Identity._sha256(nonce), other)
|
||||
_c2s_proof.rpc_id(1, sig)
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_welcome(d: Dictionary) -> void:
|
||||
local_peer = int(d.you)
|
||||
server_info = d.server
|
||||
players.clear()
|
||||
for k in d.players:
|
||||
players[int(k)] = d.players[k]
|
||||
_set_state("connected")
|
||||
joined.emit()
|
||||
players_changed.emit()
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_rejected(reason: String) -> void:
|
||||
_reject_reason = reason
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_player_joined(peer: int, p: Dictionary) -> void:
|
||||
players[peer] = p
|
||||
players_changed.emit()
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_player_left(peer: int) -> void:
|
||||
players.erase(peer)
|
||||
players_changed.emit()
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
stop()
|
||||
join_failed.emit(reason)
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
_fail("Couldn't reach the server")
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
var was := state
|
||||
var reason := _reject_reason
|
||||
stop()
|
||||
if was == "connecting":
|
||||
join_failed.emit(reason if not reason.is_empty() else "The server hung up")
|
||||
else:
|
||||
left.emit(reason if not reason.is_empty() else "The server closed the connection")
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
||||
if _peer != null:
|
||||
_peer.close()
|
||||
Reference in New Issue
Block a user