diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 33745bc..4e18113 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/main/main.gd b/main/main.gd index 4647005..29cf997 100644 --- a/main/main.gd +++ b/main/main.gd @@ -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 diff --git a/network/discovery_client.gd b/network/discovery_client.gd index bdb7134..476cff4 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -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( diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index c4307f1..4742d14 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -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) diff --git a/network/network_session.gd b/network/network_session.gd index 1d332da..2043072 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -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 diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd index fbe0d8f..f401da6 100644 --- a/network/player_list_entry.gd +++ b/network/player_list_entry.gd @@ -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 diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 8279bac..17252ba 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -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" ) diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd index 457c382..0618466 100644 --- a/server/dedicated_server_config.gd +++ b/server/dedicated_server_config.gd @@ -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 diff --git a/tests/dedicated_host_session_validation.gd b/tests/dedicated_host_session_validation.gd index 6f1de7d..2d098bd 100644 --- a/tests/dedicated_host_session_validation.gd +++ b/tests/dedicated_host_session_validation.gd @@ -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.") diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index ecc721c..2da24a3 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -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 diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd new file mode 100644 index 0000000..5847aa3 --- /dev/null +++ b/tests/operator_multiplayer_validation.gd @@ -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 diff --git a/tests/operator_multiplayer_validation.gd.uid b/tests/operator_multiplayer_validation.gd.uid new file mode 100644 index 0000000..2951ea6 --- /dev/null +++ b/tests/operator_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://cv17xsf20ue5t diff --git a/ui/players_page.gd b/ui/players_page.gd index 8c93247..380299e 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -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) diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 1b10bfd..4a7ec9b 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -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." diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 4cf1681..df2e261 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -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