Add identity-backed server moderation

This commit is contained in:
Alexander Sellite 2026-08-11 22:58:33 -04:00
parent d944367301
commit 41192e4ef5
15 changed files with 896 additions and 32 deletions

View file

@ -46,6 +46,14 @@ derives trusted context from registered peers, authoritative regions, and
server-owned state before mutating inventory, wallet, progression, or shared
world state. See [`decisions/0001-host-authority.md`](decisions/0001-host-authority.md).
Moderation follows the same boundary. Player hosts may grant session-scoped
operator status to an authenticated identity. Dedicated servers derive
operators from their configured fingerprint allowlist. Operator status is
replicated for presentation, but kick, ban, unban, and artwork-reset requests
are always reauthorized against the authenticated sender by the host. Only a
player host can grant or revoke operators; operators cannot moderate the host
or another operator.
Protocol compatibility is defined in `network/network_protocol.gd`. A visible
release version is not a reason to change the protocol number.

View file

@ -308,7 +308,15 @@ func _start_dedicated_server() -> void:
if not _discovery.set_base_url_override(config.discovery_url):
_fail_dedicated_server("The discovery URL is invalid.")
return
if not _network_session.configure_dedicated_operators(
config.operator_fingerprints
):
_fail_dedicated_server("The server operator list is invalid.")
return
_configure_portable_stores()
if not _discovery.configure_dedicated_runtime(config.server_name):
_fail_dedicated_server("The server room name is invalid.")
return
_initialize_application(true)
_player.set_local_control(false)
_player.visible = false
@ -323,7 +331,6 @@ func _start_dedicated_server() -> void:
_fail_dedicated_server("Could not start the dedicated server.")
return
_network_session.set_session_display_name(config.server_name)
_discovery.set_room_name(config.server_name)
if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.")
return

View file

@ -58,6 +58,7 @@ var _session: NetworkSession
var _base_url: String = ""
var _room_name: String = DEFAULT_ROOM_NAME
var _room_name_uses_default: bool = true
var _host_settings_persistence_enabled: bool = true
var _discoverable: bool = false
var _host_status_message: String = ""
var _host_status_is_error: bool = false
@ -218,6 +219,11 @@ func get_public_join_state() -> PublicJoinState:
return _public_join_state
func configure_dedicated_runtime(room_name: String) -> bool:
_host_settings_persistence_enabled = false
return set_room_name(room_name)
func set_room_name(value: String) -> bool:
var cleaned: String = _sanitize_room_name(value)
if cleaned.is_empty():
@ -1047,6 +1053,8 @@ func _load_settings() -> void:
func _save_settings() -> void:
if not _host_settings_persistence_enabled:
return
var config := ConfigFile.new()
config.set_value("host", "room_name", _room_name)
config.set_value(

View file

@ -15,6 +15,9 @@ var _surface_drawing: NetworkSurfaceDrawingService
var _revision := 0
var _host_block_pairs: Dictionary[String, bool] = {}
var _peer_fingerprints: Dictionary[int, String] = {}
var _remote_bans: Array[Dictionary] = []
var _ban_snapshot_requested: bool = false
var _ban_snapshot_loaded: bool = false
func setup(
@ -40,6 +43,7 @@ func setup(
func(_count: int, _maximum: int) -> void: _sync_authenticated_peers()
)
_session.state_changed.connect(_on_session_state_changed)
_session.operator_status_changed.connect(_on_operator_status_changed)
_relationships.relationship_changed.connect(_on_relationship_changed)
_bans.bans_changed.connect(_changed)
_chat.set_relationship_store(_relationships)
@ -64,13 +68,24 @@ func get_entries() -> Array[PlayerListEntry]:
entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint)
entry.display_name = record.display_name
entry.is_host = peer_id == 1
entry.is_operator = _session.is_peer_operator(peer_id)
entry.is_local_player = peer_id == local_id
entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name)
entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
entry.muted = _relationships.is_muted(record.identity_fingerprint)
entry.blocked = blocked
entry.can_kick = _session.is_host() and peer_id != local_id and peer_id != 1
entry.can_kick = (
_session.can_local_moderate()
and peer_id != local_id
and peer_id != 1
and (_session.is_host() or not entry.is_operator)
)
entry.can_ban = entry.can_kick
entry.can_manage_operator = (
_session.can_manage_operators()
and peer_id != local_id
and peer_id != 1
)
entry.revision = _revision
result.append(entry)
result.sort_custom(_entry_before)
@ -89,6 +104,14 @@ func is_local_host() -> bool:
return _session.is_host()
func is_local_moderator() -> bool:
return _session.can_local_moderate()
func can_manage_operators() -> bool:
return _session.can_manage_operators()
func is_open_host() -> bool:
return _session.is_open_host()
@ -102,7 +125,12 @@ func get_relationships() -> Array[Dictionary]:
func get_bans() -> Array[Dictionary]:
return _bans.get_bans(_session.get_host_identity_fingerprint()) if _session.is_host() else []
if _session.is_host():
return _bans.get_bans(_session.get_host_identity_fingerprint())
if not _session.is_local_operator():
return []
_request_ban_snapshot()
return _remote_bans.duplicate(true)
func set_surface_drawing_service(
@ -131,14 +159,12 @@ func get_session_artwork_counts() -> Vector2i:
func reset_session_artwork() -> bool:
if not _session.is_host() or _surface_drawing == null:
if _session.is_host():
return _reset_session_artwork_on_host()
if not _session.is_local_operator():
return false
var ok: bool = _surface_drawing.clear_session_artwork()
moderation_finished.emit(
ok,
"Session artwork cleared." if ok else "Session artwork could not be cleared.",
)
return ok
request_reset_session_artwork.rpc_id(1)
return true
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
@ -154,12 +180,13 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision):
moderation_finished.emit(false, "That player is no longer connected.")
if _session.is_host():
return _kick_on_host(peer_id, fingerprint, revision, true)
if not _session.is_local_operator():
moderation_finished.emit(false, "Only the host or an operator can remove players.")
return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint)
moderation_finished.emit(ok, "Player removed." if ok else "Player could not be removed.")
return ok
request_kick.rpc_id(1, peer_id, fingerprint)
return true
func ban(
@ -168,22 +195,158 @@ func ban(
display_name: String,
revision: int,
) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision):
moderation_finished.emit(false, "That player is no longer connected.")
if _session.is_host():
return _ban_on_host(
peer_id, fingerprint, display_name, revision, true
)
if not _session.is_local_operator():
moderation_finished.emit(false, "Only the host or an operator can ban players.")
return false
var host_fingerprint := _session.get_host_identity_fingerprint()
if not _bans.ban(host_fingerprint, fingerprint, display_name):
moderation_finished.emit(false, "Ban could not be saved.")
return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint, true)
moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
request_ban.rpc_id(1, peer_id, fingerprint)
return true
func unban(fingerprint: String) -> bool:
if not _session.is_host():
if _session.is_host():
var ok: bool = _bans.unban(
_session.get_host_identity_fingerprint(), fingerprint
)
moderation_finished.emit(
ok, "Player unbanned." if ok else "Ban could not be removed."
)
return ok
if not _session.is_local_operator():
moderation_finished.emit(false, "Only the host or an operator can remove bans.")
return false
return _bans.unban(_session.get_host_identity_fingerprint(), fingerprint)
request_unban.rpc_id(1, fingerprint)
return true
func set_operator(
peer_id: int,
fingerprint: String,
enabled: bool,
revision: int,
) -> bool:
if (
not _session.can_manage_operators()
or not _valid_moderation_target(
peer_id, fingerprint, revision, true, true
)
):
moderation_finished.emit(false, "That player is no longer connected.")
return false
var ok: bool = _session.set_peer_operator(peer_id, fingerprint, enabled)
moderation_finished.emit(
ok,
("Player is now an operator." if enabled else "Operator access removed.")
if ok
else "Operator access could not be changed.",
)
return ok
@rpc("any_peer", "call_remote", "reliable", 0)
func request_kick(peer_id: int, fingerprint: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_operator_sender(sender_id):
return
var ok: bool = _kick_on_host(peer_id, fingerprint, -1, false)
_send_moderation_result(
sender_id, ok, "Player removed." if ok else "Player could not be removed."
)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_ban(peer_id: int, fingerprint: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_operator_sender(sender_id):
return
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
var display_name: String = record.display_name if record != null else "Player"
var ok: bool = _ban_on_host(
peer_id, fingerprint, display_name, -1, false
)
_send_moderation_result(
sender_id, ok, "Player banned." if ok else "Player could not be banned."
)
_send_ban_snapshot(sender_id)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_unban(fingerprint: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not _valid_operator_sender(sender_id)
or not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
):
return
var ok: bool = _bans.unban(
_session.get_host_identity_fingerprint(), fingerprint
)
_send_moderation_result(
sender_id, ok, "Player unbanned." if ok else "Ban could not be removed."
)
_send_ban_snapshot(sender_id)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_reset_session_artwork() -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_operator_sender(sender_id):
return
var ok: bool = _reset_session_artwork_on_host(false)
_send_moderation_result(
sender_id,
ok,
"Session artwork cleared."
if ok
else "Session artwork could not be cleared.",
)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_ban_snapshot() -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if _valid_operator_sender(sender_id):
_send_ban_snapshot(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_result(success: bool, message: String) -> void:
if not _session.is_joined_client():
return
moderation_finished.emit(success, message.left(120))
@rpc("authority", "call_remote", "reliable", 0)
func receive_ban_snapshot(records: Array) -> void:
if not _session.is_local_operator() or records.size() > HostBanStore.MAX_BANS:
return
var sanitized: Array[Dictionary] = []
for value: Variant in records:
if typeof(value) != TYPE_DICTIONARY:
return
var record: Dictionary = value
var fingerprint: String = str(record.get("target_fingerprint", ""))
var display_name: String = str(
record.get("last_known_display_name", "Player")
).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
if (
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
or display_name.is_empty()
or typeof(record.get("banned_unix")) != TYPE_INT
):
return
sanitized.append({
"target_fingerprint": fingerprint,
"last_known_display_name": display_name,
"banned_unix": int(record["banned_unix"]),
})
_remote_bans = sanitized
_ban_snapshot_requested = false
_ban_snapshot_loaded = true
_changed()
func is_locally_blocked(fingerprint: String) -> bool:
@ -271,6 +434,22 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
]:
_host_block_pairs.clear()
_peer_fingerprints.clear()
_remote_bans.clear()
_ban_snapshot_requested = false
_ban_snapshot_loaded = false
elif state == NetworkSession.State.JOINED_CLIENT:
_request_ban_snapshot()
_changed()
func _on_operator_status_changed(peer_id: int, enabled: bool) -> void:
if peer_id == _session.get_local_peer_id():
if enabled:
_request_ban_snapshot()
else:
_remote_bans.clear()
_ban_snapshot_requested = false
_ban_snapshot_loaded = false
_changed()
@ -295,16 +474,137 @@ func _peer_for_fingerprint(fingerprint: String) -> int:
return 0
func _valid_moderation_target(peer_id: int, fingerprint: String, revision: int) -> bool:
if not _session.is_host() or revision != _revision or peer_id == 1:
func _valid_moderation_target(
peer_id: int,
fingerprint: String,
revision: int,
require_revision: bool,
can_target_operator: bool,
) -> bool:
if (
not _session.is_host()
or (require_revision and revision != _revision)
or peer_id == 1
or (not can_target_operator and _session.is_peer_operator(peer_id))
):
return false
var record := _session.get_peer_record(peer_id)
return record != null and record.identity_fingerprint == fingerprint
func _valid_operator_sender(peer_id: int) -> bool:
return (
_session.is_host()
and peer_id > 1
and _session.is_authenticated_peer(peer_id)
and _session.is_peer_operator(peer_id)
)
func _kick_on_host(
peer_id: int,
fingerprint: String,
revision: int,
local_host_request: bool,
) -> bool:
if not _valid_moderation_target(
peer_id,
fingerprint,
revision,
local_host_request,
local_host_request,
):
if local_host_request:
moderation_finished.emit(false, "That player is no longer connected.")
return false
var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint)
if local_host_request:
moderation_finished.emit(
ok, "Player removed." if ok else "Player could not be removed."
)
return ok
func _ban_on_host(
peer_id: int,
fingerprint: String,
display_name: String,
revision: int,
local_host_request: bool,
) -> bool:
if not _valid_moderation_target(
peer_id,
fingerprint,
revision,
local_host_request,
local_host_request,
):
if local_host_request:
moderation_finished.emit(false, "That player is no longer connected.")
return false
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
var trusted_display_name: String = (
record.display_name if record != null else display_name
)
var host_fingerprint: String = _session.get_host_identity_fingerprint()
if not _bans.ban(host_fingerprint, fingerprint, trusted_display_name):
if local_host_request:
moderation_finished.emit(false, "Ban could not be saved.")
return false
var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint, true)
if local_host_request:
moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
return true
func _reset_session_artwork_on_host(
emit_local_result: bool = true,
) -> bool:
if not _session.is_host() or _surface_drawing == null:
return false
var ok: bool = _surface_drawing.clear_session_artwork()
if emit_local_result:
moderation_finished.emit(
ok,
"Session artwork cleared."
if ok
else "Session artwork could not be cleared.",
)
return ok
func _send_moderation_result(
peer_id: int,
success: bool,
message: String,
) -> void:
receive_moderation_result.rpc_id(peer_id, success, message.left(120))
func _send_ban_snapshot(peer_id: int) -> void:
if not _valid_operator_sender(peer_id):
return
receive_ban_snapshot.rpc_id(
peer_id,
_bans.get_bans(_session.get_host_identity_fingerprint()),
)
func _request_ban_snapshot() -> void:
if (
_session.is_local_operator()
and not _ban_snapshot_requested
and not _ban_snapshot_loaded
):
_ban_snapshot_requested = true
request_ban_snapshot.rpc_id(1)
func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool:
if a.is_host != b.is_host:
return a.is_host
if a.is_operator != b.is_operator:
return a.is_operator
if a.is_local_player != b.is_local_player:
return a.is_local_player
var compared := a.display_name.naturalnocasecmp_to(b.display_name)

View file

@ -32,6 +32,7 @@ signal server_trust_required(
is_changed: bool,
)
signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed(
@ -102,6 +103,10 @@ var _moderation_disconnect_message := ""
var _host_port: int = 0
var _session_display_name: String = "NETfishing Room"
var _dedicated_host: bool = false
var _configured_operator_fingerprints: Dictionary[String, bool] = {}
var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false
func _ready() -> void:
@ -137,6 +142,20 @@ func setup(
_profile_ready = _profile_ready and _player_identity.load_or_create()
func configure_dedicated_operators(
fingerprints: PackedStringArray,
) -> bool:
if state != State.INACTIVE:
return false
var configured: Dictionary[String, bool] = {}
for fingerprint: String in fingerprints:
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
return false
configured[fingerprint] = true
_configured_operator_fingerprints = configured
return true
func start_private_host(
port: int = DEFAULT_PORT,
port_attempts: int = 1,
@ -215,6 +234,9 @@ func _start_host(
multiplayer.multiplayer_peer = peer
_session_id = Crypto.new().generate_random_bytes(16).hex_encode()
_registry.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_spawn_service.clear_remote_players()
if not dedicated:
_register_player_host()
@ -301,6 +323,44 @@ func is_dedicated_host() -> bool:
return is_host() and _dedicated_host
func is_local_operator() -> bool:
return state == State.JOINED_CLIENT and _local_operator
func can_local_moderate() -> bool:
return is_host() or is_local_operator()
func can_manage_operators() -> bool:
return is_host() and not _dedicated_host
func is_peer_operator(peer_id: int) -> bool:
return bool(_operator_peer_ids.get(peer_id, false))
func set_peer_operator(
peer_id: int,
fingerprint: String,
enabled: bool,
) -> bool:
if not can_manage_operators() or peer_id <= 1:
return false
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
if (
record == null
or not record.identity_authenticated
or record.identity_fingerprint != fingerprint
):
return false
if enabled:
_session_operator_fingerprints[fingerprint] = true
else:
_session_operator_fingerprints.erase(fingerprint)
_set_operator_status(peer_id, enabled)
return true
func set_session_display_name(value: String) -> void:
var cleaned: String = value.strip_edges().left(48)
if cleaned.is_empty():
@ -822,6 +882,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id)
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id)
@ -1136,6 +1197,11 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
var operator_enabled: bool = _operator_for_fingerprint(
str(identity["fingerprint"])
)
if operator_enabled:
_operator_peer_ids[sender_id] = true
var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
data["cosmetic_snapshot"]
)
@ -1180,9 +1246,12 @@ func submit_client_hello(data: Dictionary) -> void:
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
receive_peer_spawn.rpc(
_make_spawn_entry(sender_id, display_name, spawn_transform)
)
if operator_enabled:
receive_operator_status.rpc(sender_id, true)
peer_authenticated.emit(sender_id, display_name)
_emit_peer_count()
@ -1334,12 +1403,41 @@ func receive_peer_spawn(entry: Dictionary) -> void:
func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void:
if (
state != State.JOINED_CLIENT
or peer_ids.size() > get_session_max_players()
):
return
_operator_peer_ids.clear()
for peer_id: int in peer_ids:
if peer_id > 1:
_operator_peer_ids[peer_id] = true
_update_local_operator()
for peer_id: int in _operator_peer_ids:
operator_status_changed.emit(peer_id, true)
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_status(peer_id: int, enabled: bool) -> void:
if state != State.JOINED_CLIENT or peer_id <= 1:
return
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
_update_local_operator()
operator_status_changed.emit(peer_id, enabled)
func _apply_spawn_entry(entry: Dictionary) -> void:
if (
typeof(entry.get("peer_id")) != TYPE_INT
@ -1470,6 +1568,36 @@ func _sanitized_capabilities(value: Variant) -> PackedStringArray:
return result
func _operator_for_fingerprint(fingerprint: String) -> bool:
return bool((
_configured_operator_fingerprints
if _dedicated_host
else _session_operator_fingerprints
).get(fingerprint, false))
func _operator_peer_id_snapshot() -> PackedInt32Array:
var result: PackedInt32Array = PackedInt32Array()
for peer_id: int in _operator_peer_ids:
result.append(peer_id)
result.sort()
return result
func _set_operator_status(peer_id: int, enabled: bool) -> void:
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
operator_status_changed.emit(peer_id, enabled)
receive_operator_status.rpc(peer_id, enabled)
func _update_local_operator() -> void:
var local_peer_id: int = multiplayer.get_unique_id()
_local_operator = bool(_operator_peer_ids.get(local_peer_id, false))
func _verify_spawn_identity(entry: Dictionary) -> bool:
var fingerprint := str(entry.get("identity_fingerprint", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
@ -1929,5 +2057,8 @@ func _teardown_peer() -> void:
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_host_port = 0
_dedicated_host = false

View file

@ -6,6 +6,7 @@ var full_fingerprint := ""
var compact_fingerprint := ""
var display_name := ""
var is_host := false
var is_operator := false
var is_local_player := false
var continuity_state := ""
var ping_to_host_ms := -1
@ -13,4 +14,5 @@ var muted := false
var blocked := false
var can_kick := false
var can_ban := false
var can_manage_operator := false
var revision := 0

View file

@ -10,6 +10,7 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
readonly -a QUICK_TESTS=(
"tests/android_readiness_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/fish_catalog_content_validation.gd"
"tests/fish_quality_validation.gd"
"tests/fishing_audio_validation.gd"
@ -32,6 +33,7 @@ readonly -a RUNTIME_TESTS=(
readonly -a HOST_TESTS=(
"tests/art_tools_validation.gd"
"tests/dedicated_host_session_validation.gd"
"tests/economy_regression_validation.gd"
"tests/fish_hotbar_showcase_validation.gd"
"tests/fishing_authority_validation.gd"
@ -44,6 +46,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd"
)

View file

@ -5,6 +5,7 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server"
const DEFAULT_BIND_ADDRESS: String = "*"
const DEFAULT_PORT: int = 7777
const DEFAULT_MAX_PLAYERS: int = 8
const MAX_OPERATORS: int = 64
var server_name: String = DEFAULT_NAME
var bind_address: String = DEFAULT_BIND_ADDRESS
@ -13,6 +14,7 @@ var max_players: int = DEFAULT_MAX_PLAYERS
var public_listing: bool = false
var discovery_url: String = ""
var data_directory: String = ""
var operator_fingerprints: PackedStringArray = PackedStringArray()
var error_message: String = ""
@ -65,6 +67,9 @@ func _load_file(path: String) -> bool:
discovery_url = str(file.get_value(
"discovery", "url", discovery_url
))
operator_fingerprints = _parse_fingerprint_list(file.get_value(
"moderation", "operators", operator_fingerprints
))
return true
@ -88,6 +93,10 @@ func _apply_environment() -> void:
data_directory = _environment_string(
"NETFISHING_DATA_DIR", data_directory
)
if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
operator_fingerprints = _parse_fingerprint_list(
OS.get_environment("NETFISHING_SERVER_OPERATORS")
)
func _apply_arguments(arguments: PackedStringArray) -> void:
@ -106,6 +115,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
data_directory = argument.trim_prefix("--data-dir=")
elif argument.begins_with("--discovery-url="):
discovery_url = argument.trim_prefix("--discovery-url=")
elif argument.begins_with("--operators="):
operator_fingerprints = _parse_fingerprint_list(
argument.trim_prefix("--operators=")
)
elif argument == "--public":
public_listing = true
elif argument == "--private":
@ -117,6 +130,7 @@ func _validate() -> void:
bind_address = bind_address.strip_edges()
discovery_url = discovery_url.strip_edges().trim_suffix("/")
data_directory = data_directory.strip_edges()
operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
if server_name.is_empty():
error_message = "Server name cannot be empty."
elif not _safe_text(server_name):
@ -134,6 +148,17 @@ func _validate() -> void:
or discovery_url.begins_with("http://")
):
error_message = "A public server requires a discovery URL."
elif operator_fingerprints.size() > MAX_OPERATORS:
error_message = "A server may configure at most %d operators." % (
MAX_OPERATORS
)
else:
for fingerprint: String in operator_fingerprints:
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
error_message = (
"Server operator fingerprints must be 64 lowercase hex characters."
)
break
static func _safe_text(value: String) -> bool:
@ -164,3 +189,29 @@ static func _environment_bool(name: String, fallback: bool) -> bool:
static func _parse_int(value: String, fallback: int) -> int:
return int(value) if value.strip_edges().is_valid_int() else fallback
static func _parse_fingerprint_list(value: Variant) -> PackedStringArray:
var values: PackedStringArray = PackedStringArray()
if typeof(value) == TYPE_STRING:
var text: String = str(value)
for separator: String in [";", "\n", "\r", "\t", " "]:
text = text.replace(separator, ",")
values = text.split(",", false)
elif typeof(value) in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
for entry: Variant in value:
if typeof(entry) in [TYPE_STRING, TYPE_STRING_NAME]:
values.append(str(entry))
return _normalized_fingerprints(values)
static func _normalized_fingerprints(
values: PackedStringArray,
) -> PackedStringArray:
var result: PackedStringArray = PackedStringArray()
for value: String in values:
var fingerprint: String = value.strip_edges().to_lower()
if not fingerprint.is_empty() and fingerprint not in result:
result.append(fingerprint)
result.sort()
return result

View file

@ -1,6 +1,9 @@
extends SceneTree
const TEST_PORT: int = 35777
const OPERATOR_FINGERPRINT: String = (
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
func _init() -> void:
@ -35,12 +38,18 @@ func _run() -> void:
host_bans,
true,
)
assert(session.configure_dedicated_operators(
PackedStringArray([OPERATOR_FINGERPRINT])
))
assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1"))
assert(session.is_dedicated_host())
assert(session.get_local_peer_id() == 0)
assert(session.get_player_count() == 0)
assert(session.get_session_max_players() == 5)
assert(session.get_host_port() == TEST_PORT)
assert(bool(session.call(
"_operator_for_fingerprint", OPERATOR_FINGERPRINT
)))
assert(session.set_host_open(true))
assert(session.is_open_host())
session.disconnect_session("Dedicated host validation complete.")

View file

@ -1,6 +1,12 @@
extends SceneTree
const ConfigType = preload("res://server/dedicated_server_config.gd")
const OPERATOR_A: String = (
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
const OPERATOR_B: String = (
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
)
func _initialize() -> void:
@ -25,6 +31,9 @@ func _run() -> void:
file.set_value("server", "public", true)
file.set_value("server", "data_directory", "/tmp/configured-server")
file.set_value("discovery", "url", "https://discovery.netfishing.org/")
file.set_value(
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
)
assert(file.save(path) == OK)
var configured := ConfigType.new()
@ -37,8 +46,35 @@ func _run() -> void:
assert(int(configured.get("max_players")) == 12)
assert(bool(configured.get("public_listing")))
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
assert(
configured.get("operator_fingerprints")
== PackedStringArray([OPERATOR_A, OPERATOR_B])
)
assert(DirAccess.remove_absolute(path) == OK)
var parsed := ConfigType.new()
parsed.set(
"operator_fingerprints",
ConfigType._parse_fingerprint_list(
"%s, %s;%s" % [OPERATOR_B.to_upper(), OPERATOR_A, OPERATOR_A]
),
)
parsed.set("data_directory", "/tmp/parsed-server")
parsed.call("_validate")
assert(parsed.is_valid())
assert(
parsed.get("operator_fingerprints")
== PackedStringArray([OPERATOR_A, OPERATOR_B])
)
var invalid_operator := ConfigType.new()
invalid_operator.set(
"operator_fingerprints", PackedStringArray(["not-a-fingerprint"])
)
invalid_operator.set("data_directory", "/tmp/invalid-operator-server")
invalid_operator.call("_validate")
assert(not invalid_operator.is_valid())
var invalid := ConfigType.new()
invalid.set("public_listing", true)
invalid.set("data_directory", "/tmp/invalid-server")
@ -58,6 +94,27 @@ func _run() -> void:
DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS
< DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS
)
var discovery_settings_path: String = ProjectSettings.globalize_path(
DiscoveryClient.SETTINGS_PATH
)
assert(not FileAccess.file_exists(discovery_settings_path))
assert(discovery.set_room_name("Client Room"))
var client_settings: PackedByteArray = FileAccess.get_file_as_bytes(
discovery_settings_path
)
assert(not client_settings.is_empty())
var dedicated_discovery := DiscoveryClient.new()
dedicated_discovery.call("_load_settings")
assert(
dedicated_discovery.configure_dedicated_runtime("Headless Room")
)
assert(dedicated_discovery.get_room_name() == "Headless Room")
assert(
FileAccess.get_file_as_bytes(discovery_settings_path)
== client_settings
)
dedicated_discovery.free()
assert(DirAccess.remove_absolute(discovery_settings_path) == OK)
assert(
discovery.get_host_state()
== DiscoveryClient.HostState.CLOSED

View file

@ -0,0 +1,232 @@
extends SceneTree
const MainScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18140
const FIRST_BANNED_FINGERPRINT: String = (
"1111111111111111111111111111111111111111111111111111111111111111"
)
const SECOND_BANNED_FINGERPRINT: String = (
"2222222222222222222222222222222222222222222222222222222222222222"
)
var _moderation_results: Array[Dictionary] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Operator multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var service := (
main.get_node("%NetworkPlayerListService")
as NetworkPlayerListService
)
var bans := main.get_node("%HostBanStore") as HostBanStore
assert(session.start_private_host(TEST_PORT))
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
remote_peer_id
)
assert(remote_record != null and remote_record.identity_authenticated)
var host_fingerprint: String = session.get_host_identity_fingerprint()
assert(bans.ban(
host_fingerprint, FIRST_BANNED_FINGERPRINT, "First Banned Player"
))
var entry: PlayerListEntry = _entry_for_peer(service, remote_peer_id)
assert(entry != null and entry.can_manage_operator)
assert(service.set_operator(
remote_peer_id,
remote_record.identity_fingerprint,
true,
entry.revision,
))
assert(session.is_peer_operator(remote_peer_id))
var players_page := main.find_child(
"PlayersPage", true, false
) as PlayersPage
assert(players_page != null)
players_page.call("_refresh")
assert(_has_button_text(players_page, "deop"))
var unban_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < unban_deadline
and bans.is_banned(host_fingerprint, FIRST_BANNED_FINGERPRINT)
):
await process_frame
assert(not bans.is_banned(
host_fingerprint, FIRST_BANNED_FINGERPRINT
))
assert(bans.ban(
host_fingerprint, SECOND_BANNED_FINGERPRINT, "Second Banned Player"
))
entry = _entry_for_peer(service, remote_peer_id)
assert(entry != null and entry.is_operator)
assert(service.set_operator(
remote_peer_id,
remote_record.identity_fingerprint,
false,
entry.revision,
))
assert(not session.is_peer_operator(remote_peer_id))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
assert(bans.is_banned(host_fingerprint, SECOND_BANNED_FINGERPRINT))
print("Operator multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT
)
var session := main.get_node("%NetworkSession") as NetworkSession
var service := (
main.get_node("%NetworkPlayerListService")
as NetworkPlayerListService
)
service.moderation_finished.connect(
func(success: bool, message: String) -> void:
_moderation_results.append({
"success": success,
"message": message,
})
)
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client():
break
assert(session.is_joined_client())
var operator_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
await process_frame
assert(session.is_local_operator())
assert(service.is_local_moderator())
var players_page := main.find_child(
"PlayersPage", true, false
) as PlayersPage
assert(players_page != null)
players_page.call("_refresh")
var tabs := players_page.get("_tabs") as HBoxContainer
assert((tabs.get_child(2) as Button).visible)
players_page.call("_select_tab", 2)
assert(int(players_page.get("_current_tab")) == 2)
var local_entry: PlayerListEntry = _entry_for_peer(
service, session.get_local_peer_id()
)
assert(local_entry != null and local_entry.is_operator)
var ban_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < ban_deadline
and not _has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT)
):
await process_frame
assert(_has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT))
assert(service.unban(FIRST_BANNED_FINGERPRINT))
var result_deadline: int = Time.get_ticks_msec() + 8000
while Time.get_ticks_msec() < result_deadline and _moderation_results.is_empty():
await process_frame
assert(not _moderation_results.is_empty())
assert(bool(_moderation_results.back().get("success", false)))
var deop_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deop_deadline and session.is_local_operator():
await process_frame
assert(not session.is_local_operator())
assert(not service.is_local_moderator())
assert(not (tabs.get_child(2) as Button).visible)
assert(int(players_page.get("_current_tab")) == 0)
service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
await create_timer(1.0).timeout
print("Operator multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0
func _entry_for_peer(
service: NetworkPlayerListService,
peer_id: int,
) -> PlayerListEntry:
for entry: PlayerListEntry in service.get_entries():
if entry.peer_id == peer_id:
return entry
return null
func _has_ban(records: Array[Dictionary], fingerprint: String) -> bool:
for record: Dictionary in records:
if str(record.get("target_fingerprint", "")) == fingerprint:
return true
return false
func _has_button_text(root_node: Node, text: String) -> bool:
for node: Node in root_node.find_children("*", "Button", true, false):
var button := node as Button
if button != null and button.text == text:
return true
return false

View file

@ -0,0 +1 @@
uid://cv17xsf20ue5t

View file

@ -194,7 +194,7 @@ func _set_host_toggle_state(
func _select_tab(index: int) -> void:
if index == 2 and (_service == null or not _service.is_local_host()):
if index == 2 and (_service == null or not _service.is_local_moderator()):
return
_current_tab = index
_refresh()
@ -207,10 +207,12 @@ func _refresh() -> void:
_count_label.text = "%d / %d connected" % [
_service.get_connected_count(), _service.get_max_players(),
]
if _current_tab == 2 and not _service.is_local_moderator():
_current_tab = 0
for index: int in _tabs.get_child_count():
var button := _tabs.get_child(index) as Button
button.button_pressed = index == _current_tab
button.visible = index != 2 or _service.is_local_host()
button.visible = index != 2 or _service.is_local_moderator()
_refresh_host_settings()
for child: Node in _list.get_children():
child.queue_free()
@ -330,7 +332,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void:
func _build_active_rows() -> void:
if _service.is_local_host():
if _service.is_local_moderator():
_build_session_artwork_controls()
var entries := _service.get_entries()
if entries.is_empty():
@ -339,10 +341,12 @@ func _build_active_rows() -> void:
for entry: PlayerListEntry in entries:
var row := _make_row()
var identity := Label.new()
identity.custom_minimum_size.x = 515
identity.custom_minimum_size.x = 430
var markers: Array[String] = []
if entry.is_host:
markers.append("host")
if entry.is_operator:
markers.append("operator")
if entry.is_local_player:
markers.append("You")
identity.text = "%s%s · %s\n%s" % [
@ -351,7 +355,7 @@ func _build_active_rows() -> void:
entry.compact_fingerprint,
entry.continuity_state,
]
identity.tooltip_text = NetworkIdentityCrypto.format_fingerprint(
identity.tooltip_text = "Full identity fingerprint:\n%s" % (
entry.full_fingerprint
)
identity.add_theme_color_override(
@ -381,6 +385,12 @@ func _build_active_rows() -> void:
block.pressed.connect(_confirm_block.bind(entry))
UtilityPageStyle.apply_ocean_button(block)
row.add_child(block)
if entry.can_manage_operator:
var operator := Button.new()
operator.text = "deop" if entry.is_operator else "op"
operator.pressed.connect(_confirm_operator.bind(entry))
UtilityPageStyle.apply_ocean_button(operator)
row.add_child(operator)
var kick := Button.new()
kick.text = "kick"
kick.disabled = not entry.can_kick
@ -539,6 +549,24 @@ func _confirm_ban(entry: PlayerListEntry) -> void:
)
func _confirm_operator(entry: PlayerListEntry) -> void:
var enabled: bool = not entry.is_operator
_confirm(
(
"Grant operator access to %s for this room session?"
if enabled
else "Remove operator access from %s?"
) % entry.display_name,
func() -> void:
_service.set_operator(
entry.peer_id,
entry.full_fingerprint,
enabled,
entry.revision,
)
)
func _confirm_unban(fingerprint: String) -> void:
_confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void:
_service.unban(fingerprint)

View file

@ -156,6 +156,7 @@ func _ready() -> void:
%ChangeDataFolder.pressed.connect(_choose_data_folder)
%ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player"))
%ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player"))
%CopyPlayerFingerprint.pressed.connect(_copy_player_fingerprint)
%ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host"))
%ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host"))
for index: int in _world_options.size():
@ -485,6 +486,16 @@ func _refresh_data_page() -> void:
_data_root.override_active
or (_network_session != null and _network_session.is_session_active())
)
var fingerprint: String = (
_player_identity.fingerprint if _player_identity != null else ""
)
%CopyPlayerFingerprint.disabled = not NetworkIdentityCrypto.valid_fingerprint(
fingerprint
)
%CopyPlayerFingerprint.text = (
"Copy Player Fingerprint · %s"
% NetworkIdentityCrypto.compact_suffix(fingerprint)
)
func _open_data_folder() -> void:
@ -492,6 +503,17 @@ func _open_data_folder() -> void:
_feedback.text = "Could not open the data folder."
func _copy_player_fingerprint() -> void:
var fingerprint: String = (
_player_identity.fingerprint if _player_identity != null else ""
)
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
_feedback.text = "Player identity is unavailable."
return
DisplayServer.clipboard_set(fingerprint)
_feedback.text = "Full player fingerprint copied for server operator setup."
func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active:
_feedback.text = "The data folder is externally managed."

View file

@ -913,6 +913,11 @@ layout_mode = 2
text = "Active identity keys stay on this device.\nEncrypted backups can be stored in your synced data folder.\nDo not play the same profile on two devices at the same time."
autowrap_mode = 2
[node name="CopyPlayerFingerprint" type="Button" parent="DataPage/Paper/Content"]
unique_name_in_owner = true
layout_mode = 2
text = "Copy Player Fingerprint"
[node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"]
layout_mode = 2
columns = 2