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

@ -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)