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:
Paul Bailey
2026-11-04 13:40:27 +00:00
parent 89683e78fd
commit f62f71658f
11 changed files with 1152 additions and 6 deletions

100
scripts/net/identity.gd Normal file
View File

@@ -0,0 +1,100 @@
extends RefCounted
## Who you are on multiplayer servers: a private key, a display name and how
## your character looks. No accounts and no passwords.
##
## - The first time the game runs it generates an RSA-2048 key pair and keeps
## the private key in `<dir>/key.pem`. It never leaves this machine.
## - Your **player id** is a hash of the public key. Servers recognise you by
## it (your reserved name, your admin rights, later your inventory).
## - When you join, the server sends a random challenge and you sign it.
## Nobody can claim your id without your private key.
## - `<dir>/profile.json` holds your name, appearance and last server.
##
## Godot's Crypto has no Ed25519, hence RSA (docs/MULTIPLAYER_PLAN.md §3).
## Limited appearance for v1: a tunic colour, a hair colour, a beard or not.
const TUNICS := [Color(0.55, 0.22, 0.18), Color(0.22, 0.38, 0.58), Color(0.28, 0.46, 0.24),
Color(0.62, 0.52, 0.28), Color(0.42, 0.28, 0.52), Color(0.35, 0.35, 0.36)]
const HAIR := [Color(0.18, 0.12, 0.08), Color(0.45, 0.28, 0.14), Color(0.78, 0.6, 0.32),
Color(0.62, 0.22, 0.1), Color(0.55, 0.55, 0.55), Color(0.1, 0.1, 0.1)]
const DEFAULT_DIR := "user://identity"
var dir := DEFAULT_DIR
var key: CryptoKey
var public_pem := ""
var player_id := ""
var name := ""
var appearance := {"tunic": 0, "hair": 0, "beard": false}
var last_server := ""
## Loads the identity in `d`, creating the key on first use.
static func open(d: String = DEFAULT_DIR) -> RefCounted:
var me = load("res://scripts/net/identity.gd").new()
me.dir = d
DirAccess.make_dir_recursive_absolute(d)
var key_path := d.path_join("key.pem")
me.key = CryptoKey.new()
if not FileAccess.file_exists(key_path) or me.key.load(key_path) != OK:
me.key = Crypto.new().generate_rsa(2048)
var err: int = me.key.save(key_path)
if err != OK:
push_error("[identity] couldn't save the key to %s (%d)" % [key_path, err])
me.public_pem = me.key.save_to_string(true)
me.player_id = id_for(me.public_pem)
var p: Variant = JSON.parse_string(FileAccess.get_file_as_string(d.path_join("profile.json"))) \
if FileAccess.file_exists(d.path_join("profile.json")) else null
if p is Dictionary:
me.name = String(p.get("name", ""))
me.appearance = sanitize_appearance(p.get("appearance", {}))
me.last_server = String(p.get("last_server", ""))
return me
func save_profile() -> void:
var f := FileAccess.open(dir.path_join("profile.json"), FileAccess.WRITE)
if f == null:
push_error("[identity] couldn't write the profile")
return
f.store_string(JSON.stringify({"name": name, "appearance": appearance, "last_server": last_server}, "\t"))
## Signs a server's challenge with the private key.
func sign(nonce: PackedByteArray) -> PackedByteArray:
return Crypto.new().sign(HashingContext.HASH_SHA256, _sha256(nonce), key)
## A short, stable id from a public key (first 20 hex characters of SHA-256).
static func id_for(pem: String) -> String:
return pem.sha256_text().substr(0, 20)
## Whether `sig` is the owner of `pem` signing `nonce`.
static func verify(pem: String, nonce: PackedByteArray, sig: PackedByteArray) -> bool:
var pk := CryptoKey.new()
if pem.length() > 4096 or pk.load_from_string(pem, true) != OK:
return false
return Crypto.new().verify(HashingContext.HASH_SHA256, _sha256(nonce), sig, pk)
static func _sha256(b: PackedByteArray) -> PackedByteArray:
var h := HashingContext.new()
h.start(HashingContext.HASH_SHA256)
h.update(b)
return h.finish()
## Clamps appearance from a file or the network to the allowed choices.
static func sanitize_appearance(d: Variant) -> Dictionary:
var a: Dictionary = d if d is Dictionary else {}
return {
"tunic": clampi(int(a.get("tunic", 0)), 0, TUNICS.size() - 1),
"hair": clampi(int(a.get("hair", 0)), 0, HAIR.size() - 1),
"beard": bool(a.get("beard", false)),
}
## "" if `n` is an allowed display name, otherwise why not.
static func name_problem(n: String) -> String:
if n != n.strip_edges():
return "Names can't start or end with a space"
if n.length() < 3 or n.length() > 16:
return "Names need 3-16 characters"
if n.contains(" "):
return "Names can't have double spaces"
var re := RegEx.create_from_string("^[A-Za-z0-9 _\\-]+$")
if re.search(n) == null:
return "Names can use letters, digits, spaces, _ and -"
return ""

369
scripts/net/net.gd Normal file
View 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()

View File

@@ -0,0 +1,106 @@
extends RefCounted
## A dedicated server's preferences (`server.json`) and its reserved names
## (`names.json` alongside). Created with defaults on first run and meant to
## be edited by hand; command-line flags override single values for one run.
## See docs/MULTIPLAYER_PLAN.md §4.
const DEFAULT_PATH := "user://server/server.json"
const DEFAULTS := {
"server_name": "Valheimlike server",
"motd": "Welcome!",
"port": 24242,
"max_players": 8,
"password": "",
"world": "server-world",
"worldlet": "valley",
"seed": 1337,
"pvp": false,
"raiding": false,
"locks": "owner_and_friends",
"death_penalty": "grave",
"sleep": "all_online",
"pause_when_empty": true,
"day_length_minutes": 20,
"wolf_grace_days": 3,
"difficulty": "normal",
"autosave_minutes": 2,
"backups_kept": 10,
"admins": [],
"allowlist": [],
"banned": [],
}
## Settings sent to players when they join (never the password or the lists).
const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks",
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty"]
var path := DEFAULT_PATH
var data: Dictionary = {} ## what's in server.json (saved)
var overrides: Dictionary = {} ## command-line flags for this run (never saved)
## Reserved names: lower-case name -> {"name": as typed, "id": player id}.
var names: Dictionary = {}
static func open(p: String = DEFAULT_PATH) -> RefCounted:
var c = load("res://scripts/net/server_config.gd").new()
c.path = p
DirAccess.make_dir_recursive_absolute(p.get_base_dir())
var loaded: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) if FileAccess.file_exists(p) else null
c.data = DEFAULTS.duplicate(true)
if loaded is Dictionary:
for k in loaded:
c.data[k] = loaded[k]
else:
c.save() # first run: write the defaults so they can be edited
var n: Variant = JSON.parse_string(FileAccess.get_file_as_string(c.names_path())) \
if FileAccess.file_exists(c.names_path()) else null
if n is Dictionary:
c.names = n
return c
func names_path() -> String:
return path.get_base_dir().path_join("names.json")
func save() -> void:
_write(path, data)
func save_names() -> void:
_write(names_path(), names)
static func _write(p: String, d: Dictionary) -> void:
var tmp := p + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[server] couldn't write %s" % p)
return
f.store_string(JSON.stringify(d, "\t"))
f.close()
DirAccess.rename_absolute(tmp, p)
## A setting's value for this run (a flag beats the file).
func v(k: String) -> Variant:
return overrides.get(k, data.get(k))
func public_info() -> Dictionary:
var out := {}
for k in PUBLIC:
out[k] = v(k)
return out
## The player id holding `n`, or "" if it's free.
func name_owner(n: String) -> String:
return String(names.get(n.to_lower(), {}).get("id", ""))
## Gives `n` to `id`, releasing any other name that id held.
func reserve_name(n: String, id: String) -> void:
for k in names.keys():
if names[k].id == id and k != n.to_lower():
names.erase(k)
names[n.to_lower()] = {"name": n, "id": id}
save_names()
func is_admin(id: String) -> bool:
return id in data.admins
func add_admin(id: String) -> void:
if not id in data.admins:
data.admins.append(id)
save()