feat(network): add selectable privacy-preserving discovery

This commit is contained in:
Alexander Sellite 2026-09-01 10:21:26 -04:00
parent 9cf0c8fc9c
commit 4d31fd1517
26 changed files with 959 additions and 153 deletions

View file

@ -6,7 +6,7 @@ signal browse_status_changed(message: String, is_error: bool)
signal host_settings_changed(room_name: String, discoverable: bool)
signal host_status_changed(message: String, is_error: bool)
signal host_state_changed(state: int)
signal public_join_prepared(endpoint: String)
signal public_join_prepared(route: ConnectionRoute)
signal public_join_status_changed(message: String, is_error: bool)
signal public_join_state_changed(state: int)
signal friend_presence_updated(friends: Array[Dictionary])
@ -28,6 +28,9 @@ const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room"
const LEGACY_DEDICATED_DEFAULT_ROOM_NAME: String = (
"straywild Dedicated Server"
)
const HOST_CONNECTION_DIRECT: String = "direct"
const HOST_CONNECTION_RELAY: String = "relay"
const HOST_DISCOVERY_PRIVATE: String = "private"
const ROOM_NAME_SUFFIX: String = "'s Server"
const MAX_ROOM_NAME_LENGTH: int = 48
const HEARTBEAT_INTERVAL_SECONDS: float = 15.0
@ -35,6 +38,7 @@ const REQUEST_TIMEOUT_SECONDS: float = 8.0
const TRAVERSAL_POLL_INTERVAL_SECONDS: float = 1.0
const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35
const TRAVERSAL_PACKET_PREFIX: String = "straywild_TRAVERSAL_V1 "
const RELAY_AUTH_PACKET_PREFIX: String = "straywild_RELAY_AUTH_V1 "
const UPNP_MAPPING_DURATION_SECONDS: int = 3600
const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0
const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0
@ -90,6 +94,7 @@ var _join_request: HTTPRequest
var _traversal_timer: Timer
var _join_probe_timer: Timer
var _host_verified: bool = false
var _host_connection_preference: String = HOST_CONNECTION_DIRECT
var _traversal_host: String = ""
var _traversal_port: int = 0
var _host_verification_token: String = ""
@ -97,6 +102,8 @@ var _join_request_in_flight: bool = false
var _pending_join_endpoint: String = ""
var _pending_join_token: String = ""
var _pending_join_room_id: String = ""
var _pending_join_room_name: String = ""
var _pending_join_transport: String = ""
var _preserve_pending_join_on_inactive: bool = false
var _upnp_thread: Thread
var _upnp_mapping_in_progress: bool = false
@ -381,12 +388,25 @@ func get_host_state() -> HostState:
return _host_state
func get_host_connection_mode() -> String:
return _host_connection_preference
func get_host_discovery_mode() -> String:
return (
_host_connection_preference
if _discoverable
else HOST_DISCOVERY_PRIVATE
)
func get_public_join_state() -> PublicJoinState:
return _public_join_state
func configure_dedicated_runtime(room_name: String) -> bool:
_host_settings_persistence_enabled = false
_host_connection_preference = HOST_CONNECTION_DIRECT
return set_room_name(room_name)
@ -453,6 +473,49 @@ func set_discoverable(enabled: bool) -> bool:
return true
func set_host_connection_mode(connection_mode: String) -> bool:
if connection_mode not in [
HOST_CONNECTION_DIRECT,
HOST_CONNECTION_RELAY,
]:
return false
if (
connection_mode == HOST_CONNECTION_RELAY
and _session != null
and _session.is_dedicated_host()
):
_set_host_state(HostState.ERROR)
_set_host_status(
"Dedicated servers must use direct discovery hosting.", true
)
return false
if _host_connection_preference == connection_mode:
return true
_host_connection_preference = connection_mode
_save_settings()
host_settings_changed.emit(_room_name, _discoverable)
if _discoverable:
_set_host_state(HostState.REGISTERING)
_set_host_status("Updating public connection mode…", false)
_synchronize_host_lease()
return true
func cycle_host_discovery_mode() -> bool:
match get_host_discovery_mode():
HOST_DISCOVERY_PRIVATE:
if not set_host_connection_mode(HOST_CONNECTION_DIRECT):
return false
return set_discoverable(true)
HOST_CONNECTION_DIRECT:
if _session != null and _session.is_dedicated_host():
return set_discoverable(false)
return set_host_connection_mode(HOST_CONNECTION_RELAY)
HOST_CONNECTION_RELAY:
return set_discoverable(false)
return false
func is_public_join_preparing() -> bool:
return _join_request_in_flight
@ -480,13 +543,16 @@ func prepare_public_join(room: Dictionary) -> bool:
"You are already hosting this room.", true
)
return false
var endpoint: String = room_endpoint(room)
var room_id: String = str(room.get("room_id", "")).strip_edges()
if endpoint.is_empty() or room_id.is_empty():
if room_id.is_empty():
_set_public_join_state(PublicJoinState.ERROR)
return false
_pending_join_endpoint = endpoint
_pending_join_endpoint = ""
_pending_join_room_id = room_id
_pending_join_room_name = str(
room.get("room_name", "public room")
).strip_edges()
_pending_join_transport = str(room.get("connection_mode", ""))
_pending_join_token = ""
var url: String = "%s/v1/rooms/%s/join-attempts" % [
_base_url,
@ -535,16 +601,6 @@ func request_rooms() -> bool:
return true
func room_endpoint(room: Dictionary) -> String:
var address: String = str(room.get("address", "")).strip_edges()
var port: int = int(room.get("port", 0))
if address.is_empty() or port < 1 or port > 65535:
return ""
if ":" in address and not address.begins_with("["):
address = "[%s]" % address
return "%s:%d" % [address, port]
func is_own_room(room: Dictionary) -> bool:
return (
not _lease_room_id.is_empty()
@ -640,6 +696,14 @@ func _host_payload() -> Dictionary:
ProjectSettings.get_setting("application/config/version", "unknown")
),
"protocol_version": NetworkProtocol.PROTOCOL_VERSION,
"host_kind": (
"dedicated" if _session.is_dedicated_host() else "player"
),
"connection_mode": (
HOST_CONNECTION_DIRECT
if _session.is_dedicated_host()
else _host_connection_preference
),
}
@ -688,7 +752,7 @@ func _on_host_request_completed(
else HostState.VERIFYING
)
_set_host_status(
"Room is listed publicly."
_host_listing_status(room)
if _host_verified
else "Checking the public route…",
false,
@ -706,7 +770,7 @@ func _on_host_request_completed(
else HostState.VERIFYING
)
_set_host_status(
"Room is listed publicly."
_host_listing_status(room)
if _host_verified
else "Checking the public route…",
false,
@ -755,10 +819,33 @@ func _on_join_request_completed(
return
_pending_join_token = str(response.get("join_token", ""))
_apply_traversal_response(response)
var route_transport := ""
var route_privacy := ""
var route_details: Variant = response.get("route", {})
if typeof(route_details) == TYPE_DICTIONARY:
var direct_route: Dictionary = route_details
route_transport = str(direct_route.get("transport", ""))
route_privacy = str(direct_route.get("ip_privacy", ""))
if route_transport in ["direct", "relay"]:
var address := str(direct_route.get("address", "")).strip_edges()
var port := int(direct_route.get("port", 0))
if not address.is_empty() and port >= 1 and port <= 65535:
if ":" in address and not address.begins_with("["):
address = "[%s]" % address
_pending_join_endpoint = "%s:%d" % [address, port]
if (
_pending_join_token.is_empty()
or _traversal_host.is_empty()
or _traversal_port < 1
or _pending_join_endpoint.is_empty()
or route_transport != _pending_join_transport
or (
route_transport == "direct"
and route_privacy != "peer_visible"
)
or (route_transport == "relay" and route_privacy != "relayed")
or (
route_transport == "direct"
and (_traversal_host.is_empty() or _traversal_port < 1)
)
):
_set_public_join_state(PublicJoinState.ERROR)
public_join_status_changed.emit(
@ -768,7 +855,25 @@ func _on_join_request_completed(
return
_set_public_join_state(PublicJoinState.CONNECTING)
public_join_status_changed.emit("Connecting…", false)
public_join_prepared.emit(_pending_join_endpoint)
var endpoint := EndpointParser.parse(_pending_join_endpoint)
if not endpoint.is_valid():
_set_public_join_state(PublicJoinState.ERROR)
public_join_status_changed.emit("Could not reach this room.", true)
_clear_pending_join()
return
public_join_prepared.emit(
ConnectionRoute.discovery_relay(
endpoint,
_pending_join_room_id,
_pending_join_room_name,
)
if route_transport == "relay"
else ConnectionRoute.discovery_direct(
endpoint,
_pending_join_room_id,
_pending_join_room_name,
)
)
func _on_traversal_timer_timeout() -> void:
@ -856,6 +961,15 @@ func _send_pending_join_probe() -> void:
):
_join_probe_timer.stop()
return
if _pending_join_transport == "relay":
var relay_endpoint := EndpointParser.parse(_pending_join_endpoint)
if relay_endpoint.is_valid():
_session.send_traversal_packet(
relay_endpoint.host,
relay_endpoint.port,
(RELAY_AUTH_PACKET_PREFIX + _pending_join_token).to_utf8_buffer(),
)
return
_send_traversal_probe({
"kind": "join",
"room_id": _pending_join_room_id,
@ -1013,10 +1127,24 @@ func _valid_public_room(room: Dictionary) -> bool:
return (
typeof(room.get("room_id")) == TYPE_STRING
and typeof(room.get("room_name")) == TYPE_STRING
and typeof(room.get("address")) == TYPE_STRING
and typeof(room.get("game_version")) == TYPE_STRING
and str(room.get("game_version")) == expected_version
and _valid_json_integer(room.get("port"), 1, 65535)
and str(room.get("connection_mode", "")) in ["direct", "relay"]
and (
(
str(room.get("connection_mode", "")) == "direct"
and str(room.get("ip_privacy", "")) == "peer_visible"
)
or (
str(room.get("connection_mode", "")) == "relay"
and str(room.get("ip_privacy", "")) == "relayed"
)
)
and str(room.get("host_kind", "")) in ["player", "dedicated"]
and not (
str(room.get("host_kind", "")) == "dedicated"
and str(room.get("connection_mode", "")) == "relay"
)
and _valid_json_integer(room.get("current_players"), 0, 128)
and _valid_json_integer(room.get("max_players"), 1, 128)
and int(room["current_players"]) <= int(room["max_players"])
@ -1370,6 +1498,18 @@ func _request_failure(response: Dictionary) -> String:
return "Room discovery is temporarily unavailable."
func _host_listing_status(room: Dictionary) -> String:
if str(room.get("connection_mode", "")) == "relay":
return (
"Room is listed through the privacy relay. Players see a relay "
+ "address; the relay service still handles network addresses."
)
return (
"Room is listed. Its address is hidden while browsing, but direct "
+ "players can see your public IP after joining."
)
func _discovery_version_mismatch_message(required_version: String) -> String:
var local_version: String = NetworkProtocol.game_version()
var rejection: NetworkProtocol.RejectionCode = (
@ -1493,6 +1633,8 @@ func _clear_pending_join() -> void:
_pending_join_endpoint = ""
_pending_join_token = ""
_pending_join_room_id = ""
_pending_join_room_name = ""
_pending_join_transport = ""
_join_probe_timer.stop()
@ -1571,6 +1713,14 @@ func _load_settings() -> void:
_presence_sharing = bool(config.get_value(
"social", "share_presence", false
))
var saved_connection_mode := str(config.get_value(
"host", "connection_mode", HOST_CONNECTION_DIRECT
))
if saved_connection_mode in [
HOST_CONNECTION_DIRECT,
HOST_CONNECTION_RELAY,
]:
_host_connection_preference = saved_connection_mode
func _save_settings() -> void:
@ -1581,6 +1731,9 @@ func _save_settings() -> void:
config.set_value(
"host", "room_name_uses_default", _room_name_uses_default
)
config.set_value(
"host", "connection_mode", _host_connection_preference
)
config.set_value("social", "share_presence", _presence_sharing)
var error: Error = config.save(SETTINGS_PATH)
if error != OK: