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

@ -3,22 +3,65 @@ extends RefCounted
enum Kind {
DIRECT,
DISCOVERY_DIRECT,
DISCOVERY_RELAY,
}
var kind: Kind = Kind.DIRECT
var direct_endpoint: ConnectionEndpoint
var display_description: String = ""
var discovery_room_id: String = ""
static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute:
var route := ConnectionRoute.new()
route.kind = Kind.DIRECT
route.direct_endpoint = endpoint
route.display_description = "direct server" if endpoint != null else ""
return route
static func discovery_direct(
endpoint: ConnectionEndpoint,
room_id: String,
room_name: String,
) -> ConnectionRoute:
var route := ConnectionRoute.new()
route.kind = Kind.DISCOVERY_DIRECT
route.direct_endpoint = endpoint
route.discovery_room_id = room_id
var cleaned_name := room_name.strip_edges()
route.display_description = (
endpoint.normalized_display if endpoint != null else ""
cleaned_name if not cleaned_name.is_empty() else "public room"
)
return route
static func discovery_relay(
endpoint: ConnectionEndpoint,
room_id: String,
room_name: String,
) -> ConnectionRoute:
var route := discovery_direct(endpoint, room_id, room_name)
route.kind = Kind.DISCOVERY_RELAY
return route
func is_valid() -> bool:
return kind == Kind.DIRECT and direct_endpoint != null and direct_endpoint.is_valid()
return (
kind in [Kind.DIRECT, Kind.DISCOVERY_DIRECT, Kind.DISCOVERY_RELAY]
and direct_endpoint != null
and direct_endpoint.is_valid()
and (
kind == Kind.DIRECT
or not discovery_room_id.is_empty()
)
)
func is_discovery_join() -> bool:
return kind in [Kind.DISCOVERY_DIRECT, Kind.DISCOVERY_RELAY]
func is_relay() -> bool:
return kind == Kind.DISCOVERY_RELAY

View file

@ -34,11 +34,12 @@ func connect_to_route(route: ConnectionRoute) -> Error:
)
if error != OK:
transport_error.emit(
"Unable to connect to %s." % endpoint.normalized_display
"Unable to begin the connection to %s."
% route.display_description
)
return error
_peer = enet_peer
_route_description = endpoint.normalized_display
_route_description = route.display_description
return OK

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:

View file

@ -485,17 +485,30 @@ func join_direct(endpoint_text: String) -> bool:
if not endpoint.is_valid():
_fail(endpoint.error_message)
return false
return join_route(ConnectionRoute.direct(endpoint))
func join_route(route: ConnectionRoute) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
or route == null
or not route.is_valid()
):
return false
_operation_generation += 1
var generation: int = _operation_generation
_current_route = ConnectionRoute.direct(endpoint)
_current_route = route
_set_state(
State.CONNECTING,
"Connecting to %s..." % endpoint.normalized_display
"Connecting to %s..." % route.display_description
)
_replace_transport()
var error: Error = _transport.connect_to_route(_current_route)
if error != OK:
_fail("Could not begin the direct connection.")
_fail("Could not begin the connection.")
return false
multiplayer.multiplayer_peer = _transport.get_multiplayer_peer()
_connection_deadline = (
@ -597,11 +610,23 @@ func get_current_endpoint() -> ConnectionEndpoint:
return (
_current_route.direct_endpoint
if _current_route != null
and _current_route.kind == ConnectionRoute.Kind.DIRECT
and _current_route.kind in [
ConnectionRoute.Kind.DIRECT,
ConnectionRoute.Kind.DISCOVERY_DIRECT,
ConnectionRoute.Kind.DISCOVERY_RELAY,
]
else null
)
func is_current_route_discovery() -> bool:
return _current_route != null and _current_route.is_discovery_join()
func is_current_route_relay() -> bool:
return _current_route != null and _current_route.is_relay()
func get_last_server_metadata() -> Dictionary:
return {
"server_display_name": _last_server_display_name,
@ -1226,11 +1251,16 @@ func receive_server_identity_proof(data: Dictionary) -> void:
_pending_server_proof = data.duplicate(true)
_server_identity_fingerprint = fingerprint
_server_identity_public_key = public_pem
var verification := _server_trust.verify(
get_current_endpoint(), fingerprint
var verification := (
_server_trust.verify_identity(fingerprint)
if is_current_route_discovery()
else _server_trust.verify(get_current_endpoint(), fingerprint)
)
if verification == ServerTrustStore.Verification.MATCH:
_server_trust.touch(get_current_endpoint())
if is_current_route_discovery():
_server_trust.touch_identity(fingerprint)
else:
_server_trust.touch(get_current_endpoint())
_send_client_identity_proof()
return
_set_state(
@ -1238,9 +1268,12 @@ func receive_server_identity_proof(data: Dictionary) -> void:
"Confirm this server identity before continuing.",
)
_connection_deadline = 0.0
var expected := str(
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
var trust_record: Dictionary = (
_server_trust.get_identity_record(fingerprint)
if is_current_route_discovery()
else _server_trust.get_record(get_current_endpoint())
)
var expected := str(trust_record.get("fingerprint", ""))
server_trust_required.emit(
get_current_route_display(),
expected,
@ -1255,11 +1288,15 @@ func resolve_server_trust(accepted: bool) -> void:
if not accepted:
cancel_connection()
return
if not _server_trust.trust(
get_current_endpoint(),
str(_pending_server_proof["server_fingerprint"]),
"straywild",
):
var fingerprint := str(_pending_server_proof["server_fingerprint"])
var trusted := (
_server_trust.trust_identity(fingerprint, get_current_route_display())
if is_current_route_discovery()
else _server_trust.trust(
get_current_endpoint(), fingerprint, "straywild"
)
)
if not trusted:
_fail_identity("The server identity could not be pinned.")
return
_set_state(State.AUTHENTICATING, "Authenticating identity...")

View file

@ -196,7 +196,7 @@ func record_successful_connection(
recent = _make_entry(endpoint, SavedServerEntry.Kind.RECENT)
_recent_entries.append(recent)
recent["display_name"] = (
server_name if not server_name.is_empty() else endpoint.normalized_display
server_name if not server_name.is_empty() else "Recent Server"
)
_apply_result_metadata(
recent, now, "SUCCESS", server_name, protocol_version,
@ -358,7 +358,11 @@ func _make_entry(
var now: int = int(Time.get_unix_time_from_system())
return {
"entry_id": Crypto.new().generate_random_bytes(16).hex_encode(),
"display_name": endpoint.host,
"display_name": (
"Saved Server"
if kind == SavedServerEntry.Kind.SAVED
else "Recent Server"
),
"route_kind": "DIRECT",
"host": endpoint.host,
"normalized_host": endpoint.host,

View file

@ -39,6 +39,17 @@ func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification:
)
func verify_identity(fingerprint: String) -> Verification:
_ensure_loaded()
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
return Verification.CHANGED
return (
Verification.MATCH
if _records.has(_identity_key(fingerprint))
else Verification.FIRST_SEEN
)
func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
_ensure_loaded()
if endpoint == null:
@ -46,6 +57,11 @@ func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
return Dictionary(_records.get(endpoint.normalized_display, {})).duplicate(true)
func get_identity_record(fingerprint: String) -> Dictionary:
_ensure_loaded()
return Dictionary(_records.get(_identity_key(fingerprint), {})).duplicate(true)
func trust(
endpoint: ConnectionEndpoint,
fingerprint: String,
@ -74,6 +90,25 @@ func trust(
return _save()
func trust_identity(fingerprint: String, server_name: String = "") -> bool:
_ensure_loaded()
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
return false
var key := _identity_key(fingerprint)
var now := int(Time.get_unix_time_from_system())
var previous: Dictionary = _records.get(key, {})
_records[key] = {
"route_kind": "discovery",
"identity_key": key,
"fingerprint": fingerprint,
"first_seen_unix": int(previous.get("first_seen_unix", now)),
"last_seen_unix": now,
"last_observed_server_name": server_name.left(80),
"trust_state": "pinned",
}
return _save()
func touch(endpoint: ConnectionEndpoint) -> void:
_ensure_loaded()
if endpoint == null:
@ -85,6 +120,15 @@ func touch(endpoint: ConnectionEndpoint) -> void:
_save()
func touch_identity(fingerprint: String) -> void:
_ensure_loaded()
var record: Dictionary = _records.get(_identity_key(fingerprint), {})
if record.is_empty():
return
record["last_seen_unix"] = int(Time.get_unix_time_from_system())
_save()
func _ensure_loaded() -> void:
if _loaded:
return
@ -108,12 +152,21 @@ func _ensure_loaded() -> void:
continue
var record: Dictionary = value
var endpoint := str(record.get("normalized_endpoint", ""))
var identity_key := str(record.get("identity_key", ""))
var fingerprint := str(record.get("fingerprint", ""))
if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint):
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
continue
if not endpoint.is_empty():
_records[endpoint] = record.duplicate(true)
elif identity_key == _identity_key(fingerprint):
_records[identity_key] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
static func _identity_key(fingerprint: String) -> String:
return "identity:%s" % fingerprint
func _save() -> bool:
if _write_blocked:
return false