This commit is contained in:
2025-11-14 18:44:06 +08:00
parent 10156da245
commit 22e867d077
7013 changed files with 2572882 additions and 1804 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2d2959d363903444bae4333db12a9ea1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 447b4ad1a3db7cf4fa5a0709d297ba9b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,345 @@
using System;
using System.Collections;
using System.Threading;
using Mirror;
using UnityEngine;
using Random = UnityEngine.Random;
namespace Edgegap
{
[HelpURL("https://mirror-networking.gitbook.io/docs/manual/transports/edgegap-transports/edgegap-relay")]
public class EdgegapLobbyKcpTransport : EdgegapKcpTransport
{
[Header("Lobby Settings")]
[Tooltip("URL to the Edgegap lobby service, automatically filled in after completing the creation process via button below (or enter manually)")]
public string lobbyUrl;
[Tooltip("How long to wait for the relay to be assigned after starting a lobby")]
public float lobbyWaitTimeout = 60;
public LobbyApi Api;
private LobbyCreateRequest? _request;
private string _lobbyId;
private string _playerId;
private TransportStatus _status = TransportStatus.Offline;
public enum TransportStatus
{
Offline,
CreatingLobby,
StartingLobby,
JoiningLobby,
WaitingRelay,
Connecting,
Connected,
Error,
}
public TransportStatus Status
{
get
{
if (!NetworkClient.active && !NetworkServer.active)
{
return TransportStatus.Offline;
}
if (_status == TransportStatus.Connecting)
{
if (NetworkServer.active)
{
switch (((EdgegapKcpServer)this.server).state)
{
case ConnectionState.Valid:
return TransportStatus.Connected;
case ConnectionState.Invalid:
case ConnectionState.SessionTimeout:
case ConnectionState.Error:
return TransportStatus.Error;
}
}
else if (NetworkClient.active)
{
switch (((EdgegapKcpClient)this.client).connectionState)
{
case ConnectionState.Valid:
return TransportStatus.Connected;
case ConnectionState.Invalid:
case ConnectionState.SessionTimeout:
case ConnectionState.Error:
return TransportStatus.Error;
}
}
}
return _status;
}
}
protected override void Awake()
{
base.Awake();
Api = new LobbyApi(lobbyUrl);
}
private void Reset()
{
this.relayGUI = false;
}
public override void ServerStart()
{
if (!_request.HasValue)
{
throw new Exception("No lobby request set. Call SetServerLobbyParams");
}
_status = TransportStatus.CreatingLobby;
Api.CreateLobby(_request.Value, lobby =>
{
_lobbyId = lobby.lobby_id;
_status = TransportStatus.StartingLobby;
Api.StartLobby(new LobbyIdRequest(_lobbyId), () =>
{
StartCoroutine(WaitForLobbyRelay(_lobbyId, true));
}, error =>
{
_status = TransportStatus.Error;
string errorMsg = $"Could not start lobby: {error}";
Debug.LogError(errorMsg);
OnServerError?.Invoke(0, TransportError.Unexpected, errorMsg);
ServerStop();
});
},
error =>
{
_status = TransportStatus.Error;
string errorMsg = $"Couldn't create lobby: {error}";
Debug.LogError(errorMsg);
OnServerError?.Invoke(0, TransportError.Unexpected, errorMsg);
});
}
public override void ServerStop()
{
base.ServerStop();
Api.DeleteLobby(_lobbyId, () =>
{
// yay
}, error =>
{
OnServerError?.Invoke(0, TransportError.Unexpected, $"Failed to delete lobby: {error}");
});
}
public override void ClientDisconnect()
{
base.ClientDisconnect();
// this gets called for host mode as well
if (!NetworkServer.active)
{
Api.LeaveLobby(new LobbyJoinOrLeaveRequest
{
player = new LobbyJoinOrLeaveRequest.Player
{
id = _playerId
},
lobby_id = _lobbyId
}, () =>
{
// yay
}, error =>
{
string errorMsg = $"Failed to leave lobby: {error}";
OnClientError?.Invoke(TransportError.Unexpected, errorMsg);
Debug.LogError(errorMsg);
});
}
}
public override void ClientConnect(string address)
{
_lobbyId = address;
_playerId = RandomPlayerId();
_status = TransportStatus.JoiningLobby;
Api.JoinLobby(new LobbyJoinOrLeaveRequest
{
player = new LobbyJoinOrLeaveRequest.Player
{
id = _playerId,
},
lobby_id = address
}, () =>
{
StartCoroutine(WaitForLobbyRelay(_lobbyId, false));
}, error =>
{
_status = TransportStatus.Offline;
string errorMsg = $"Failed to join lobby: {error}";
OnClientError?.Invoke(TransportError.Unexpected, errorMsg);
Debug.LogError(errorMsg);
OnClientDisconnected?.Invoke();
});
}
private IEnumerator WaitForLobbyRelay(string lobbyId, bool forServer)
{
_status = TransportStatus.WaitingRelay;
double startTime = NetworkTime.localTime;
bool running = true;
while (running)
{
if (NetworkTime.localTime - startTime >= lobbyWaitTimeout)
{
_status = TransportStatus.Error;
string errorMsg = "Timed out waiting for lobby.";
Debug.LogError(errorMsg);
if (forServer)
{
_status = TransportStatus.Error;
OnServerError?.Invoke(0, TransportError.Unexpected, errorMsg);
ServerStop();
}
else
{
_status = TransportStatus.Error;
OnClientError?.Invoke(TransportError.Unexpected, errorMsg);
ClientDisconnect();
}
yield break;
}
bool waitingForResponse = true;
Api.GetLobby(lobbyId, lobby =>
{
waitingForResponse = false;
if (string.IsNullOrEmpty(lobby.assignment.ip))
{
// no lobby deployed yet, have the outer loop retry
return;
}
relayAddress = lobby.assignment.ip;
foreach (Lobby.Port aport in lobby.assignment.ports)
{
if (aport.protocol == "UDP")
{
if (aport.name == "server")
{
relayGameServerPort = (ushort)aport.port;
}
else if (aport.name == "client")
{
relayGameClientPort = (ushort)aport.port;
}
}
}
bool found = false;
foreach (Lobby.Player player in lobby.players)
{
if (player.id == _playerId)
{
userId = player.authorization_token;
sessionId = lobby.assignment.authorization_token;
found = true;
break;
}
}
running = false;
if (!found)
{
string errorMsg = $"Couldn't find my player ({_playerId})";
Debug.LogError(errorMsg);
if (forServer)
{
_status = TransportStatus.Error;
OnServerError?.Invoke(0, TransportError.Unexpected, errorMsg);
ServerStop();
}
else
{
_status = TransportStatus.Error;
OnClientError?.Invoke(TransportError.Unexpected, errorMsg);
ClientDisconnect();
}
return;
}
_status = TransportStatus.Connecting;
if (forServer)
{
base.ServerStart();
}
else
{
base.ClientConnect("");
}
}, error =>
{
running = false;
waitingForResponse = false;
_status = TransportStatus.Error;
string errorMsg = $"Failed to get lobby info: {error}";
Debug.LogError(errorMsg);
if (forServer)
{
OnServerError?.Invoke(0, TransportError.Unexpected, errorMsg);
ServerStop();
}
else
{
OnClientError?.Invoke(TransportError.Unexpected, errorMsg);
ClientDisconnect();
}
});
while (waitingForResponse)
{
yield return null;
}
yield return new WaitForSeconds(0.2f);
}
}
private static string RandomPlayerId()
{
return $"mirror-player-{Random.Range(1, int.MaxValue)}";
}
public void SetServerLobbyParams(string lobbyName, int capacity)
{
SetServerLobbyParams(new LobbyCreateRequest
{
player = new LobbyCreateRequest.Player
{
id = RandomPlayerId(),
},
annotations = new LobbyCreateRequest.Annotation[]
{
},
capacity = capacity,
is_joinable = true,
name = lobbyName,
tags = new string[]
{
}
});
}
public void SetServerLobbyParams(LobbyCreateRequest request)
{
_playerId = request.player.id;
_request = request;
}
private void OnDestroy()
{
// attempt to clean up lobbies, if active
if (NetworkServer.active)
{
ServerStop();
// Absolutely make sure there's time for the network request to hit edgegap servers.
// sorry. this can go once the lobby service can timeout lobbies itself
Thread.Sleep(300);
}
else if (NetworkClient.active)
{
ClientDisconnect();
// Absolutely make sure there's time for the network request to hit edgegap servers.
// sorry. this can go once the lobby service can timeout lobbies itself
Thread.Sleep(300);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fa9d4c3f48a245ed89f122f44e1e81ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
namespace Edgegap
{
// Implements the edgegap lobby api: https://docs.edgegap.com/docs/lobby/functions
public class LobbyApi
{
[Header("Lobby Config")]
public string LobbyUrl;
public LobbyBrief[] Lobbies;
public LobbyApi(string url)
{
LobbyUrl = url;
}
private static UnityWebRequest SendJson<T>(string url, T data, string method = "POST")
{
string body = JsonUtility.ToJson(data);
UnityWebRequest request = new UnityWebRequest(url, method);
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Accept", "application/json");
request.SetRequestHeader("Content-Type", "application/json");
return request;
}
private static bool CheckErrorResponse(UnityWebRequest request, Action<string> onError)
{
#if UNITY_2020_3_OR_NEWER
if (request.result != UnityWebRequest.Result.Success)
{
// how I hate http libs that think they need to be smart and handle status code errors.
if (request.result != UnityWebRequest.Result.ProtocolError || request.responseCode == 0)
{
onError?.Invoke(request.error);
return true;
}
}
#else
if (request.isNetworkError)
{
onError?.Invoke(request.error);
return true;
}
#endif
if (request.responseCode < 200 || request.responseCode >= 300)
{
onError?.Invoke($"non-200 status code: {request.responseCode}. Body:\n {request.downloadHandler.text}");
return true;
}
return false;
}
public void RefreshLobbies(Action<LobbyBrief[]> onLoaded, Action<string> onError)
{
UnityWebRequest request = UnityWebRequest.Get($"{LobbyUrl}/lobbies");
request.SendWebRequest().completed += operation =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
ListLobbiesResponse lobbies = JsonUtility.FromJson<ListLobbiesResponse>(request.downloadHandler.text);
Lobbies = lobbies.data;
onLoaded?.Invoke(lobbies.data);
}
};
}
public void CreateLobby(LobbyCreateRequest createData, Action<Lobby> onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies", createData);
request.SetRequestHeader("Content-Type", "application/json");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
Lobby lobby = JsonUtility.FromJson<Lobby>(request.downloadHandler.text);
onResponse?.Invoke(lobby);
}
};
}
public void UpdateLobby(string lobbyId, LobbyUpdateRequest updateData, Action<LobbyBrief> onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies/{lobbyId}", updateData, "PATCH");
request.SetRequestHeader("Content-Type", "application/json");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
LobbyBrief lobby = JsonUtility.FromJson<LobbyBrief>(request.downloadHandler.text);
onResponse?.Invoke(lobby);
}
};
}
public void GetLobby(string lobbyId, Action<Lobby> onResponse, Action<string> onError)
{
UnityWebRequest request = UnityWebRequest.Get($"{LobbyUrl}/lobbies/{lobbyId}");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
Lobby lobby = JsonUtility.FromJson<Lobby>(request.downloadHandler.text);
onResponse?.Invoke(lobby);
}
};
}
public void JoinLobby(LobbyJoinOrLeaveRequest data, Action onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies:join", data);
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
onResponse?.Invoke();
}
};
}
public void LeaveLobby(LobbyJoinOrLeaveRequest data, Action onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies:leave", data);
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
onResponse?.Invoke();
}
};
}
public void StartLobby(LobbyIdRequest data, Action onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies:start", data);
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
onResponse?.Invoke();
}
};
}
public void DeleteLobby(string lobbyId, Action onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson($"{LobbyUrl}/lobbies/{lobbyId}", "", "DELETE");
request.SetRequestHeader("Content-Type", "application/json");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
onResponse?.Invoke();
}
};
}
struct CreateLobbyServiceRequest
{
public string name;
}
public struct LobbyServiceResponse
{
public string name;
public string url;
public string status;
}
public static void TrimApiKey(ref string apiKey)
{
if (apiKey == null)
{
return;
}
if (apiKey.StartsWith("token "))
{
apiKey = apiKey.Substring("token ".Length);
}
apiKey = apiKey.Trim();
}
public static void CreateAndDeployLobbyService(string apiKey, string name, Action<LobbyServiceResponse> onResponse, Action<string> onError)
{
TrimApiKey(ref apiKey);
// try to get the lobby first
GetLobbyService(apiKey, name, response =>
{
if (response == null)
{
CreateLobbyService(apiKey, name, onResponse, onError);
}
else if (!string.IsNullOrEmpty(response.Value.url))
{
onResponse(response.Value);
}
else
{
DeployLobbyService(apiKey, name, onResponse, onError);
}
}, onError);
}
private static void CreateLobbyService(string apiKey, string name, Action<LobbyServiceResponse> onResponse, Action<string> onError)
{
UnityWebRequest request = SendJson("https://api.edgegap.com/v1/lobbies", new CreateLobbyServiceRequest
{
name = name
});
request.SetRequestHeader("Authorization", $"token {apiKey}");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
DeployLobbyService(apiKey, name, onResponse, onError);
}
};
}
public static void GetLobbyService(string apiKey, string name, Action<LobbyServiceResponse?> onResponse, Action<string> onError)
{
TrimApiKey(ref apiKey);
var request = UnityWebRequest.Get($"https://api.edgegap.com/v1/lobbies/{name}");
request.SetRequestHeader("Authorization", $"token {apiKey}");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (request.responseCode == 404)
{
onResponse(null);
return;
}
if (CheckErrorResponse(request, onError)) return;
LobbyServiceResponse response = JsonUtility.FromJson<LobbyServiceResponse>(request.downloadHandler.text);
onResponse(response);
}
};
}
public static void TerminateLobbyService(string apiKey, string name, Action<LobbyServiceResponse> onResponse, Action<string> onError)
{
TrimApiKey(ref apiKey);
var request = SendJson("https://api.edgegap.com/v1/lobbies:terminate", new CreateLobbyServiceRequest
{
name = name
});
request.SetRequestHeader("Authorization", $"token {apiKey}");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
LobbyServiceResponse response = JsonUtility.FromJson<LobbyServiceResponse>(request.downloadHandler.text);
onResponse?.Invoke(response);
}
};
}
private static void DeployLobbyService(string apiKey, string name, Action<LobbyServiceResponse> onResponse, Action<string> onError)
{
var request = SendJson("https://api.edgegap.com/v1/lobbies:deploy", new CreateLobbyServiceRequest
{
name = name
});
request.SetRequestHeader("Authorization", $"token {apiKey}");
request.SendWebRequest().completed += (op) =>
{
using (request)
{
if (CheckErrorResponse(request, onError)) return;
LobbyServiceResponse response = JsonUtility.FromJson<LobbyServiceResponse>(request.downloadHandler.text);
onResponse?.Invoke(response);
}
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 64510fc75d0d75f4185fec1cf4d12206
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,141 @@
using System;
using System.Threading;
using UnityEditor;
using UnityEngine;
#if UNITY_EDITOR
namespace Edgegap
{
public class LobbyServiceCreateDialogue : EditorWindow
{
public Action<string> onLobby;
public bool waitingCreate;
public bool waitingStatus;
private string _name;
private string _key;
private string _lastStatus;
private void Awake()
{
minSize = maxSize = new Vector2(450, 300);
titleContent = new GUIContent("Edgegap Lobby Service Setup");
}
#if !UNITY_SERVER || UNITY_EDITOR
private void OnGUI()
{
if (waitingCreate)
{
EditorGUILayout.LabelField("Waiting for lobby to create . . . ");
return;
}
if (waitingStatus)
{
EditorGUILayout.LabelField("Waiting for lobby to deploy . . . ");
EditorGUILayout.LabelField($"Latest status: {_lastStatus}");
return;
}
_key = EditorGUILayout.TextField("Edgegap API key", _key);
LobbyApi.TrimApiKey(ref _key);
EditorGUILayout.HelpBox(new GUIContent("Your API key won't be saved."));
if (GUILayout.Button("I have no api key?"))
{
Application.OpenURL("https://app.edgegap.com/user-settings?tab=tokens");
}
EditorGUILayout.Separator();
EditorGUILayout.HelpBox("There's currently a bug where lobby names longer than 5 characters can fail to deploy correctly and will return a \"503 Service Temporarily Unavailable\"\nIt's recommended to limit your lobby names to 4-5 characters for now", UnityEditor.MessageType.Warning);
_name = EditorGUILayout.TextField("Lobby Name", _name);
EditorGUILayout.HelpBox(new GUIContent("The lobby name is your games identifier for the lobby service"));
if (GUILayout.Button("Create"))
{
if (string.IsNullOrWhiteSpace(_key) || string.IsNullOrWhiteSpace(_name))
{
EditorUtility.DisplayDialog("Error", "Key and Name can't be empty.", "Ok");
}
else
{
waitingCreate = true;
Repaint();
LobbyApi.CreateAndDeployLobbyService(_key.Trim(), _name.Trim(), res =>
{
waitingCreate = false;
waitingStatus = true;
_lastStatus = res.status;
RefreshStatus();
Repaint();
}, error =>
{
EditorUtility.DisplayDialog("Failed to create lobby", $"The following error happened while trying to create (&deploy) the lobby service:\n\n{error}", "Ok");
waitingCreate = false;
});
return;
}
}
if (GUILayout.Button("Cancel"))
Close();
EditorGUILayout.HelpBox(new GUIContent("Note: If you forgot your lobby url simply re-create it with the same name!\nIt will re-use the existing lobby service"));
EditorGUILayout.Separator();
EditorGUILayout.Separator();
if (GUILayout.Button("Terminate existing deploy"))
{
if (string.IsNullOrWhiteSpace(_key) || string.IsNullOrWhiteSpace(_name))
{
EditorUtility.DisplayDialog("Error", "Key and Name can't be empty.", "Ok");
}
else
{
LobbyApi.TerminateLobbyService(_key.Trim(), _name.Trim(), res =>
{
EditorUtility.DisplayDialog("Success", $"The lobby service will start terminating (shutting down the deploy) now", "Ok");
}, error =>
{
EditorUtility.DisplayDialog("Failed to terminate lobby", $"The following error happened while trying to terminate the lobby service:\n\n{error}", "Ok");
});
}
}
EditorGUILayout.HelpBox(new GUIContent("Done with your lobby?\nEnter the same name as creation to shut it down"));
}
#endif
private void RefreshStatus()
{
// Stop if window is closed
if (!this)
{
return;
}
LobbyApi.GetLobbyService(_key, _name, res =>
{
if (!res.HasValue)
{
EditorUtility.DisplayDialog("Failed to create lobby", $"The lobby seems to have vanished while waiting for it to deploy.", "Ok");
waitingStatus = false;
Repaint();
return;
}
if (!string.IsNullOrEmpty(res.Value.url))
{
onLobby(res.Value.url);
Close();
return;
}
_lastStatus = res.Value.status;
Repaint();
Thread.Sleep(100); // :( but this is a lazy editor script, its fiiine
RefreshStatus();
}, error =>
{
EditorUtility.DisplayDialog("Failed to create lobby", $"The following error happened while trying to create (&deploy) a lobby:\n\n{error}", "Ok");
waitingStatus = false;
});
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 25579cc004424981bf0b05bcec65df0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Reflection;
using kcp2k;
using UnityEditor;
using UnityEngine;
#if UNITY_EDITOR
namespace Edgegap
{
[CustomEditor(typeof(EdgegapLobbyKcpTransport))]
public class EncryptionTransportInspector : UnityEditor.Editor
{
SerializedProperty lobbyUrlProperty;
SerializedProperty lobbyWaitTimeoutProperty;
private List<SerializedProperty> kcpProperties = new List<SerializedProperty>();
// Assuming proper SerializedProperty definitions for properties
// Add more SerializedProperty fields related to different modes as needed
void OnEnable()
{
lobbyUrlProperty = serializedObject.FindProperty("lobbyUrl");
lobbyWaitTimeoutProperty = serializedObject.FindProperty("lobbyWaitTimeout");
// Get public fields from KcpTransport
kcpProperties.Clear();
FieldInfo[] fields = typeof(KcpTransport).GetFields(BindingFlags.Public | BindingFlags.Instance);
foreach (var field in fields)
{
SerializedProperty prop = serializedObject.FindProperty(field.Name);
if (prop == null)
{
// callbacks have no property
continue;
}
kcpProperties.Add(prop);
}
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(lobbyUrlProperty);
if (GUILayout.Button("Create & Deploy Lobby"))
{
var input = CreateInstance<LobbyServiceCreateDialogue>();
input.onLobby = (url) =>
{
lobbyUrlProperty.stringValue = url;
serializedObject.ApplyModifiedProperties();
};
input.ShowUtility();
}
EditorGUILayout.PropertyField(lobbyWaitTimeoutProperty);
EditorGUILayout.Separator();
foreach (SerializedProperty prop in kcpProperties)
{
EditorGUILayout.PropertyField(prop);
}
serializedObject.ApplyModifiedProperties();
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d7cc53263184754a4682335440df515
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b9b459cf5e084bdd8b196df849a2c519
timeCreated: 1709953502

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions#functions
[Serializable]
public struct ListLobbiesResponse
{
public int count;
public LobbyBrief[] data;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fdb37041d9464f8c90ac86942b940565
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,45 @@
using System;
using UnityEngine;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions#getting-a-specific-lobbys-information
[Serializable]
public struct Lobby
{
[Serializable]
public struct Player
{
public uint authorization_token;
public string id;
public bool is_host;
}
[Serializable]
public struct Port
{
public string name;
public int port;
public string protocol;
}
[Serializable]
public struct Assignment
{
public uint authorization_token;
public string host;
public string ip;
public Port[] ports;
}
public Assignment assignment;
public string name;
public string lobby_id;
public bool is_joinable;
public bool is_started;
public int player_count;
public int capacity;
public int available_slots => capacity - player_count;
public string[] tags;
public Player[] players;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 64db55f096cd4ace83e1aa1c0c0588f7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using System;
namespace Edgegap
{
// Brief lobby data, returned by the list function
[Serializable]
public struct LobbyBrief
{
public string lobby_id;
public string name;
public bool is_joinable;
public bool is_started;
public int player_count;
public int capacity;
public int available_slots => capacity - player_count;
public string[] tags;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6018ece006144e719c6b3f0d4e256d7b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,27 @@
using System;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions#creating-a-new-lobby
[Serializable]
public struct LobbyCreateRequest
{
[Serializable]
public struct Player
{
public string id;
}
[Serializable]
public struct Annotation
{
public bool inject;
public string key;
public string value;
}
public Annotation[] annotations; // todo
public int capacity;
public bool is_joinable;
public string name;
public Player player;
public string[] tags;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4040c1adafc3449eaebd3bd22aa3ff26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,14 @@
using System;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions/#starting-a-lobby
[Serializable]
public struct LobbyIdRequest
{
public string lobby_id;
public LobbyIdRequest(string lobbyId)
{
lobby_id = lobbyId;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 219c7fba8724473caf170c6254e6dc45
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using System;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions#updating-a-lobby
// https://docs.edgegap.com/docs/lobby/functions#leaving-a-lobby
[Serializable]
public struct LobbyJoinOrLeaveRequest
{
[Serializable]
public struct Player
{
public string id;
}
public string lobby_id;
public Player player;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4091d555e62341f0ac30479952d517aa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,12 @@
using System;
namespace Edgegap
{
// https://docs.edgegap.com/docs/lobby/functions#updating-a-lobby
[Serializable]
public struct LobbyUpdateRequest
{
public int capacity;
public bool is_joinable;
public string[] tags;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee158bc379f44cdf9904578f37a5e7a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 62c28e855fc644011b4079c268b46b71
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,141 @@
// overwrite RawSend/Receive
using System;
using System.Net.Sockets;
using Mirror;
using UnityEngine;
using kcp2k;
namespace Edgegap
{
public class EdgegapKcpClient : KcpClient
{
// need buffer larger than KcpClient.rawReceiveBuffer to add metadata
readonly byte[] relayReceiveBuffer;
// authentication
public uint userId;
public uint sessionId;
public ConnectionState connectionState = ConnectionState.Disconnected;
// ping
double lastPingTime;
public EdgegapKcpClient(
Action OnConnected,
Action<ArraySegment<byte>, KcpChannel> OnData,
Action OnDisconnected,
Action<ErrorCode, string> OnError,
KcpConfig config)
: base(OnConnected, OnData, OnDisconnected, OnError, config)
{
relayReceiveBuffer = new byte[config.Mtu + Protocol.Overhead];
}
// custom start function with relay parameters; connects udp client.
public void Connect(string relayAddress, ushort relayPort, uint userId, uint sessionId)
{
// reset last state
connectionState = ConnectionState.Checking;
this.userId = userId;
this.sessionId = sessionId;
// reuse base connect
base.Connect(relayAddress, relayPort);
}
// parse metadata, then pass to kcp
protected override bool RawReceive(out ArraySegment<byte> segment)
{
segment = default;
if (socket == null) return false;
try
{
if (socket.ReceiveNonBlocking(relayReceiveBuffer, out ArraySegment<byte> content))
{
using (NetworkReaderPooled reader = NetworkReaderPool.Get(content))
{
// parse message type
if (reader.Remaining == 0)
{
Debug.LogWarning($"EdgegapClient: message of {content.Count} is too small to parse.");
return false;
}
byte messageType = reader.ReadByte();
// handle message type
switch (messageType)
{
case (byte)MessageType.Ping:
{
// parse state
if (reader.Remaining < 1) return false;
ConnectionState last = connectionState;
connectionState = (ConnectionState)reader.ReadByte();
// log state changes for debugging.
if (connectionState != last) Debug.Log($"EdgegapClient: state updated to: {connectionState}");
// return true indicates Mirror to keep checking
// for further messages.
return true;
}
case (byte)MessageType.Data:
{
segment = reader.ReadBytesSegment(reader.Remaining);
return true;
}
// wrong message type. return false, don't throw.
default: return false;
}
}
}
}
catch (SocketException e)
{
Log.Info($"EdgegapClient: looks like the other end has closed the connection. This is fine: {e}");
Disconnect();
}
return false;
}
protected override void RawSend(ArraySegment<byte> data)
{
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
writer.WriteUInt(userId);
writer.WriteUInt(sessionId);
writer.WriteByte((byte)MessageType.Data);
writer.WriteBytes(data.Array, data.Offset, data.Count);
base.RawSend(writer);
}
}
void SendPing()
{
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
writer.WriteUInt(userId);
writer.WriteUInt(sessionId);
writer.WriteByte((byte)MessageType.Ping);
base.RawSend(writer);
}
}
public override void TickOutgoing()
{
if (connected)
{
// ping every interval for keepalive & handshake
if (NetworkTime.localTime >= lastPingTime + Protocol.PingInterval)
{
SendPing();
lastPingTime = NetworkTime.localTime;
}
}
base.TickOutgoing();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a0d6fba7098f4ea3949d0195e8276adc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,203 @@
using System;
using System.Net;
using System.Net.Sockets;
using Mirror;
using UnityEngine;
using kcp2k;
namespace Edgegap
{
public class EdgegapKcpServer : KcpServer
{
// need buffer larger than KcpClient.rawReceiveBuffer to add metadata
readonly byte[] relayReceiveBuffer;
// authentication
public uint userId;
public uint sessionId;
public ConnectionState state = ConnectionState.Disconnected;
// server is an UDP client talking to relay
protected Socket relaySocket;
public EndPoint remoteEndPoint;
// ping
double lastPingTime;
// custom 'active'. while connected to relay
bool relayActive;
public EdgegapKcpServer(
Action<int, IPEndPoint> OnConnected,
Action<int, ArraySegment<byte>, KcpChannel> OnData,
Action<int> OnDisconnected,
Action<int, ErrorCode, string> OnError,
KcpConfig config)
// TODO don't call base. don't listen to local UdpServer at all?
: base(OnConnected, OnData, OnDisconnected, OnError, config)
{
relayReceiveBuffer = new byte[config.Mtu + Protocol.Overhead];
}
public override bool IsActive() => relayActive;
// custom start function with relay parameters; connects udp client.
public void Start(string relayAddress, ushort relayPort, uint userId, uint sessionId)
{
// reset last state
state = ConnectionState.Checking;
this.userId = userId;
this.sessionId = sessionId;
// try resolve host name
if (!Common.ResolveHostname(relayAddress, out IPAddress[] addresses))
{
OnError(0, ErrorCode.DnsResolve, $"Failed to resolve host: {relayAddress}");
return;
}
// create socket
remoteEndPoint = new IPEndPoint(addresses[0], relayPort);
relaySocket = new Socket(remoteEndPoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
relaySocket.Blocking = false;
// configure buffer sizes
Common.ConfigureSocketBuffers(relaySocket, config.RecvBufferSize, config.SendBufferSize);
// bind to endpoint for Send/Receive instead of SendTo/ReceiveFrom
relaySocket.Connect(remoteEndPoint);
relayActive = true;
}
public override void Stop()
{
relayActive = false;
}
protected override bool RawReceiveFrom(out ArraySegment<byte> segment, out int connectionId)
{
segment = default;
connectionId = 0;
if (relaySocket == null) return false;
try
{
// TODO need separate buffer. don't write into result yet. only payload
if (relaySocket.ReceiveNonBlocking(relayReceiveBuffer, out ArraySegment<byte> content))
{
using (NetworkReaderPooled reader = NetworkReaderPool.Get(content))
{
// parse message type
if (reader.Remaining == 0)
{
Debug.LogWarning($"EdgegapServer: message of {content.Count} is too small to parse header.");
return false;
}
byte messageType = reader.ReadByte();
// handle message type
switch (messageType)
{
case (byte)MessageType.Ping:
{
// parse state
if (reader.Remaining < 1) return false;
ConnectionState last = state;
state = (ConnectionState)reader.ReadByte();
// log state changes for debugging.
if (state != last) Debug.Log($"EdgegapServer: state updated to: {state}");
// return true indicates Mirror to keep checking
// for further messages.
return true;
}
case (byte)MessageType.Data:
{
// parse connectionId and payload
if (reader.Remaining <= 4)
{
Debug.LogWarning($"EdgegapServer: message of {content.Count} is too small to parse connId.");
return false;
}
connectionId = reader.ReadInt();
segment = reader.ReadBytesSegment(reader.Remaining);
// Debug.Log($"EdgegapServer: receiving from connId={connectionId}: {segment.ToHexString()}");
return true;
}
// wrong message type. return false, don't throw.
default: return false;
}
}
}
}
catch (SocketException e)
{
Log.Info($"EdgegapServer: looks like the other end has closed the connection. This is fine: {e}");
}
return false;
}
protected override void RawSend(int connectionId, ArraySegment<byte> data)
{
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
// Debug.Log($"EdgegapServer: sending to connId={connectionId}: {data.ToHexString()}");
writer.WriteUInt(userId);
writer.WriteUInt(sessionId);
writer.WriteByte((byte)MessageType.Data);
writer.WriteInt(connectionId);
writer.WriteBytes(data.Array, data.Offset, data.Count);
ArraySegment<byte> message = writer;
try
{
relaySocket.SendNonBlocking(message);
}
catch (SocketException e)
{
Log.Error($"KcpRleayServer: RawSend failed: {e}");
}
}
}
void SendPing()
{
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
writer.WriteUInt(userId);
writer.WriteUInt(sessionId);
writer.WriteByte((byte)MessageType.Ping);
ArraySegment<byte> message = writer;
try
{
relaySocket.SendNonBlocking(message);
}
catch (SocketException e)
{
Debug.LogWarning($"EdgegapServer: failed to ping. perhaps the relay isn't running? {e}");
}
}
}
public override void TickOutgoing()
{
if (relayActive)
{
// ping every interval for keepalive & handshake
if (NetworkTime.localTime >= lastPingTime + Protocol.PingInterval)
{
SendPing();
lastPingTime = NetworkTime.localTime;
}
}
// base processing
base.TickOutgoing();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd8551078397248b0848950352c208ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,166 @@
// edgegap relay transport.
// reuses KcpTransport with custom KcpServer/Client.
//#if MIRROR <- commented out because MIRROR isn't defined on first import yet
using System;
using System.Text.RegularExpressions;
using UnityEngine;
using Mirror;
using kcp2k;
namespace Edgegap
{
[HelpURL("https://mirror-networking.gitbook.io/docs/manual/transports/edgegap-transports/edgegap-relay")]
public class EdgegapKcpTransport : KcpTransport
{
[Header("Relay")]
public string relayAddress = "127.0.0.1";
public ushort relayGameServerPort = 8888;
public ushort relayGameClientPort = 9999;
// mtu for kcp transport. respects relay overhead.
public const int MaxPayload = Kcp.MTU_DEF - Protocol.Overhead;
[Header("Relay")]
public bool relayGUI = true;
public uint userId = 11111111;
public uint sessionId = 22222222;
// helper
internal static String ReParse(String cmd, String pattern, String defaultValue)
{
Match match = Regex.Match(cmd, pattern);
return match.Success ? match.Groups[1].Value : defaultValue;
}
protected override void Awake()
{
// logging
// Log.Info should use Debug.Log if enabled, or nothing otherwise
// (don't want to spam the console on headless servers)
if (debugLog)
Log.Info = Debug.Log;
else
Log.Info = _ => {};
Log.Warning = Debug.LogWarning;
Log.Error = Debug.LogError;
// create config from serialized settings.
// with MaxPayload as max size to respect relay overhead.
config = new KcpConfig(DualMode, RecvBufferSize, SendBufferSize, MaxPayload, NoDelay, Interval, FastResend, false, SendWindowSize, ReceiveWindowSize, Timeout, MaxRetransmit);
// client (NonAlloc version is not necessary anymore)
client = new EdgegapKcpClient(
() => OnClientConnected.Invoke(),
(message, channel) => OnClientDataReceived.Invoke(message, FromKcpChannel(channel)),
() => OnClientDisconnected?.Invoke(), // may be null in StopHost(): https://github.com/MirrorNetworking/Mirror/issues/3708
(error, reason) => OnClientError.Invoke(ToTransportError(error), reason),
config
);
// server
server = new EdgegapKcpServer(
(connectionId, endPoint) => OnServerConnectedWithAddress.Invoke(connectionId, endPoint.PrettyAddress()),
(connectionId, message, channel) => OnServerDataReceived.Invoke(connectionId, message, FromKcpChannel(channel)),
(connectionId) => OnServerDisconnected.Invoke(connectionId),
(connectionId, error, reason) => OnServerError.Invoke(connectionId, ToTransportError(error), reason),
config);
if (statisticsLog)
InvokeRepeating(nameof(OnLogStatistics), 1, 1);
Debug.Log("EdgegapTransport initialized!");
}
protected override void OnValidate()
{
// show max message sizes in inspector for convenience.
// 'config' isn't available in edit mode yet, so use MTU define.
ReliableMaxMessageSize = KcpPeer.ReliableMaxMessageSize(MaxPayload, ReceiveWindowSize);
UnreliableMaxMessageSize = KcpPeer.UnreliableMaxMessageSize(MaxPayload);
}
// client overwrites to use EdgegapClient instead of KcpClient
public override void ClientConnect(string address)
{
// connect to relay address:port instead of the expected server address
EdgegapKcpClient client = (EdgegapKcpClient)this.client;
client.userId = userId;
client.sessionId = sessionId;
client.connectionState = ConnectionState.Checking; // reset from last time
client.Connect(relayAddress, relayGameClientPort);
}
public override void ClientConnect(Uri uri)
{
if (uri.Scheme != Scheme)
throw new ArgumentException($"Invalid url {uri}, use {Scheme}://host:port instead", nameof(uri));
// connect to relay address:port instead of the expected server address
EdgegapKcpClient client = (EdgegapKcpClient)this.client;
client.Connect(relayAddress, relayGameClientPort, userId, sessionId);
}
// server overwrites to use EdgegapServer instead of KcpServer
public override void ServerStart()
{
// start the server
EdgegapKcpServer server = (EdgegapKcpServer)this.server;
server.Start(relayAddress, relayGameServerPort, userId, sessionId);
}
void OnGUIRelay()
{
// if (server.IsActive()) return;
GUILayout.BeginArea(new Rect(300, 30, 200, 100));
GUILayout.BeginHorizontal();
GUILayout.Label("SessionId:");
sessionId = Convert.ToUInt32(GUILayout.TextField(sessionId.ToString()));
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("UserId:");
userId = Convert.ToUInt32(GUILayout.TextField(userId.ToString()));
GUILayout.EndHorizontal();
if (NetworkServer.active)
{
EdgegapKcpServer server = (EdgegapKcpServer)this.server;
GUILayout.BeginHorizontal();
GUILayout.Label("State:");
GUILayout.Label(server.state.ToString());
GUILayout.EndHorizontal();
}
else if (NetworkClient.active)
{
EdgegapKcpClient client = (EdgegapKcpClient)this.client;
GUILayout.BeginHorizontal();
GUILayout.Label("State:");
GUILayout.Label(client.connectionState.ToString());
GUILayout.EndHorizontal();
}
GUILayout.EndArea();
}
#if UNITY_EDITOR || (!UNITY_SERVER && DEBUG)
protected override void OnGUI()
{
base.OnGUI();
if (relayGUI) OnGUIRelay();
}
#elif UNITY_EDITOR || (!UNITY_SERVER && !DEBUG)
// base OnGUI only shows in editor & development builds.
// here we always show it because we need the sessionid & userid buttons.
void OnGUI()
{
if (relayGUI) OnGUIRelay();
}
#endif
public override string ToString() => "Edgegap Kcp Transport";
}
}
//#endif MIRROR <- commented out because MIRROR isn't defined on first import yet

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2d1e0e17f753449798fa27474d6b86b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
// relay protocol definitions
namespace Edgegap
{
public enum ConnectionState : byte
{
Disconnected = 0, // until the user calls connect()
Checking = 1, // recently connected, validation in progress
Valid = 2, // validation succeeded
Invalid = 3, // validation rejected by tower
SessionTimeout = 4, // session owner timed out
Error = 5, // other error
}
public enum MessageType : byte
{
Ping = 1,
Data = 2
}
public static class Protocol
{
// MTU: relay adds up to 13 bytes of metadata in the worst case.
public const int Overhead = 13;
// ping interval should be between 100 ms and 1 second.
// faster ping gives faster authentication, but higher bandwidth.
public const float PingInterval = 0.5f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eac30312ba61470b849e368af3c3b0e9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,20 @@
# Edgegap Relay for Mirror
Documentation: https://docs.edgegap.com/docs/distributed-relay-manager/
## Prerequisites
- Unity project set up with the Mirror networking library installed
- Supported Versions: [Mirror](https://assetstore.unity.com/packages/tools/network/mirror-129321) and [Mirror LTS](https://assetstore.unity.com/packages/tools/network/mirror-lts-102631)
- EdgegapTransport module downloaded and extracted
## Steps
1. Open your Unity project and navigate to the "Assets" folder.
2. Locate the "Mirror" folder within "Assets" and open it.
3. Within the "Mirror" folder, open the "Transports" folder.
4. Drag and drop the "Unity" folder from the extracted EdgegapTransport files into the "Transports" folder.
5. Open your NetworkManager script in the Unity Editor and navigate to the "Inspector" panel.
6. In the "Inspector" panel, locate the "Network Manager" component and click the "+" button next to the "Transport" property.
7. In the "Add Component" menu that appears, select "Edgegap Transport" to add it to the NetworkManager.
8. Drag the newly added "Edgegap Transport" component into the "Transport" property in the "Inspector" panel.
## Notes
- The EdgegapTransport module is only compatible with Mirror and Mirror LTS versions.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8ade7c960d8fe4e94970ddd88ede3bca
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
// parse session_id and user_id from command line args.
// mac: "open mirror.app --args session_id=123 user_id=456"
using System;
using UnityEngine;
namespace Edgegap
{
public class RelayCredentialsFromArgs : MonoBehaviour
{
void Awake()
{
String cmd = Environment.CommandLine;
// parse session_id via regex
String sessionId = EdgegapKcpTransport.ReParse(cmd, "session_id=(\\d+)", "111111");
String userID = EdgegapKcpTransport.ReParse(cmd, "user_id=(\\d+)", "222222");
Debug.Log($"Parsed sessionId: {sessionId} user_id: {userID}");
// configure transport
EdgegapKcpTransport transport = GetComponent<EdgegapKcpTransport>();
transport.sessionId = UInt32.Parse(sessionId);
transport.userId = UInt32.Parse(userID);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e9ec7091b26c4d3882f4b42f10f9b8c1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 3ea6ff15cda674a57b0c7c8b7dc1878c, type: 3}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,123 @@
fileFormatVersion: 2
guid: 3ea6ff15cda674a57b0c7c8b7dc1878c
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 12
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 0
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 16
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 741b3c7e5d0842049ff50a2f6e27ca12
timeCreated: 1708015148

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0d3cd9d7d6e84a578f7e4b384ff813f1
timeCreated: 1708793986

View File

@@ -0,0 +1,18 @@
{
"name": "EncryptionTransportEditor",
"rootNamespace": "",
"references": [
"GUID:627104647b9c04b4ebb8978a92ecac63"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4c9c7b0ef83e6e945b276d644816a489
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,90 @@
using UnityEditor;
using UnityEngine;
namespace Mirror.Transports.Encryption
{
[CustomEditor(typeof(EncryptionTransport), true)]
public class EncryptionTransportInspector : UnityEditor.Editor
{
SerializedProperty innerProperty;
SerializedProperty clientValidatesServerPubKeyProperty;
SerializedProperty clientTrustedPubKeySignaturesProperty;
SerializedProperty serverKeypairPathProperty;
SerializedProperty serverLoadKeyPairFromFileProperty;
// Assuming proper SerializedProperty definitions for properties
// Add more SerializedProperty fields related to different modes as needed
void OnEnable()
{
innerProperty = serializedObject.FindProperty("Inner");
clientValidatesServerPubKeyProperty = serializedObject.FindProperty("ClientValidateServerPubKey");
clientTrustedPubKeySignaturesProperty = serializedObject.FindProperty("ClientTrustedPubKeySignatures");
serverKeypairPathProperty = serializedObject.FindProperty("ServerKeypairPath");
serverLoadKeyPairFromFileProperty = serializedObject.FindProperty("ServerLoadKeyPairFromFile");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// Draw default inspector for the parent class
DrawDefaultInspector();
EditorGUILayout.LabelField("Encryption Settings", EditorStyles.boldLabel);
if (innerProperty != null)
{
EditorGUILayout.LabelField("Common", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(innerProperty);
EditorGUILayout.Separator();
}
// Client Section
EditorGUILayout.LabelField("Client", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("Validating the servers public key is essential for complete man-in-the-middle (MITM) safety, but might not be feasible for all modes of hosting.", MessageType.Info);
EditorGUILayout.PropertyField(clientValidatesServerPubKeyProperty, new GUIContent("Validate Server Public Key"));
EncryptionTransport.ValidationMode validationMode = (EncryptionTransport.ValidationMode)clientValidatesServerPubKeyProperty.enumValueIndex;
switch (validationMode)
{
case EncryptionTransport.ValidationMode.List:
EditorGUILayout.PropertyField(clientTrustedPubKeySignaturesProperty);
break;
case EncryptionTransport.ValidationMode.Callback:
EditorGUILayout.HelpBox("Please set the EncryptionTransport.onClientValidateServerPubKey at runtime.", MessageType.Info);
break;
}
EditorGUILayout.Separator();
// Server Section
EditorGUILayout.LabelField("Server", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serverLoadKeyPairFromFileProperty, new GUIContent("Load Keypair From File"));
if (serverLoadKeyPairFromFileProperty.boolValue)
{
EditorGUILayout.PropertyField(serverKeypairPathProperty, new GUIContent("Keypair File Path"));
}
if(GUILayout.Button("Generate Key Pair"))
{
EncryptionCredentials keyPair = EncryptionCredentials.Generate();
string path = EditorUtility.SaveFilePanel("Select where to save the keypair", "", "server-keys.json", "json");
if (!string.IsNullOrEmpty(path))
{
keyPair.SaveToFile(path);
EditorUtility.DisplayDialog("KeyPair Saved", $"Successfully saved the keypair.\nThe fingerprint is {keyPair.PublicKeyFingerprint}, you can also retrieve it from the saved json file at any point.", "Ok");
if (validationMode == EncryptionTransport.ValidationMode.List)
{
if (EditorUtility.DisplayDialog("Add key to trusted list?", "Do you also want to add the generated key to the trusted list?", "Yes", "No"))
{
clientTrustedPubKeySignaturesProperty.arraySize++;
clientTrustedPubKeySignaturesProperty.GetArrayElementAtIndex(clientTrustedPubKeySignaturesProperty.arraySize - 1).stringValue = keyPair.PublicKeyFingerprint;
}
}
}
}
serializedObject.ApplyModifiedProperties();
}
[CustomEditor(typeof(ThreadedEncryptionKcpTransport), true)]
class EncryptionThreadedTransportInspector : EncryptionTransportInspector {}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 871580d2094a46139279d651cec92b5d
timeCreated: 1708794004

View File

@@ -0,0 +1,554 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Mirror.BouncyCastle.Crypto;
using Mirror.BouncyCastle.Crypto.Agreement;
using Mirror.BouncyCastle.Crypto.Digests;
using Mirror.BouncyCastle.Crypto.Generators;
using Mirror.BouncyCastle.Crypto.Modes;
using Mirror.BouncyCastle.Crypto.Parameters;
using UnityEngine.Profiling;
namespace Mirror.Transports.Encryption
{
public class EncryptedConnection
{
// 256-bit key
const int KeyLength = 32;
// 512-bit salt for the key derivation function
const int HkdfSaltSize = KeyLength * 2;
// Info tag for the HKDF, this just adds more entropy
static readonly byte[] HkdfInfo = Encoding.UTF8.GetBytes("Mirror/EncryptionTransport");
// fixed size of the unique per-packet nonce. Defaults to 12 bytes/96 bits (not recommended to be changed)
const int NonceSize = 12;
// this is the size of the "checksum" included in each encrypted payload
// 16 bytes/128 bytes is the recommended value for best security
// can be reduced to 12 bytes for a small space savings, but makes encryption slightly weaker.
// Setting it lower than 12 bytes is not recommended
const int MacSizeBytes = 16;
const int MacSizeBits = MacSizeBytes * 8;
// How much metadata overhead we have for regular packets
public const int Overhead = sizeof(OpCodes) + MacSizeBytes + NonceSize;
// After how many seconds of not receiving a handshake packet we should time out
const double DurationTimeout = 2; // 2s
// After how many seconds to assume the last handshake packet got lost and to resend another one
const double DurationResend = 0.05; // 50ms
// Static fields for allocation efficiency, makes this not thread safe
// It'd be as easy as using ThreadLocal though to fix that
// Set up a global cipher instance, it is initialised/reset before use
// (AesFastEngine used to exist, but was removed due to side channel issues)
// use AesUtilities.CreateEngine here as it'll pick the hardware accelerated one if available (which is will not be unless on .net core)
static readonly ThreadLocal<GcmBlockCipher> Cipher = new ThreadLocal<GcmBlockCipher>(() => new GcmBlockCipher(AesUtilities.CreateEngine()));
// Set up a global HKDF with a SHA-256 digest
static readonly ThreadLocal<HkdfBytesGenerator> Hkdf = new ThreadLocal<HkdfBytesGenerator>(() => new HkdfBytesGenerator(new Sha256Digest()));
// Global byte array to store nonce sent by the remote side, they're used immediately after
static readonly ThreadLocal<byte[]> ReceiveNonce = new ThreadLocal<byte[]>(() => new byte[NonceSize]);
// Buffer for the remote salt, as bouncycastle needs to take a byte[] *rolls eyes*
static readonly ThreadLocal<byte[]> TMPRemoteSaltBuffer = new ThreadLocal<byte[]>(() => new byte[HkdfSaltSize]);
// buffer for encrypt/decrypt operations, resized larger as needed
static ThreadLocal<byte[]> TMPCryptBuffer = new ThreadLocal<byte[]>(() => new byte[2048]);
// packet headers
enum OpCodes : byte
{
// start at 1 to maybe filter out random noise
Data = 1,
HandshakeStart = 2,
HandshakeAck = 3,
HandshakeFin = 4
}
enum State
{
// Waiting for a handshake to arrive
// this is for _sendsFirst:
// - false: OpCodes.HandshakeStart
// - true: Opcodes.HandshakeAck
WaitingHandshake,
// Waiting for a handshake reply/acknowledgement to arrive
// this is for _sendsFirst:
// - false: OpCodes.HandshakeFine
// - true: Opcodes.Data (implicitly)
WaitingHandshakeReply,
// Both sides have confirmed the keys are exchanged and data can be sent freely
Ready
}
State state = State.WaitingHandshake;
// Key exchange confirmed and data can be sent freely
public bool IsReady => state == State.Ready;
// Callback to send off encrypted data
readonly Action<ArraySegment<byte>, int> send;
// Callback when received data has been decrypted
readonly Action<ArraySegment<byte>, int> receive;
// Callback when the connection becomes ready
readonly Action ready;
// On-error callback, disconnect expected
readonly Action<TransportError, string> error;
// Optional callback to validate the remotes public key, validation on one side is necessary to ensure MITM resistance
// (usually client validates the server key)
readonly Func<PubKeyInfo, bool> validateRemoteKey;
// Our asymmetric credentials for the initial DH exchange
EncryptionCredentials credentials;
readonly byte[] hkdfSalt;
NetworkReader _tmpReader = new NetworkReader(new ArraySegment<byte>());
// After no handshake packet in this many seconds, the handshake fails
double handshakeTimeout;
// When to assume the last handshake packet got lost and to resend another one
double nextHandshakeResend;
// we can reuse the _cipherParameters here since the nonce is stored as the byte[] reference we pass in
// so we can update it without creating a new AeadParameters instance
// this might break in the future! (will cause bad data)
byte[] nonce = new byte[NonceSize];
AeadParameters cipherParametersEncrypt;
AeadParameters cipherParametersDecrypt;
/*
* Specifies if we send the first key, then receive ack, then send fin
* Or the opposite if set to false
*
* The client does this, since the fin is not acked explicitly, but by receiving data to decrypt
*/
readonly bool sendsFirst;
public EncryptedConnection(EncryptionCredentials credentials,
bool isClient,
Action<ArraySegment<byte>, int> sendAction,
Action<ArraySegment<byte>, int> receiveAction,
Action readyAction,
Action<TransportError, string> errorAction,
Func<PubKeyInfo, bool> validateRemoteKey = null)
{
this.credentials = credentials;
sendsFirst = isClient;
if (!sendsFirst)
// salt is controlled by the server
hkdfSalt = GenerateSecureBytes(HkdfSaltSize);
send = sendAction;
receive = receiveAction;
ready = readyAction;
error = errorAction;
this.validateRemoteKey = validateRemoteKey;
}
// Generates a random starting nonce
static byte[] GenerateSecureBytes(int size)
{
byte[] bytes = new byte[size];
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
rng.GetBytes(bytes);
return bytes;
}
public void OnReceiveRaw(ArraySegment<byte> data, int channel)
{
if (data.Count < 1)
{
error(TransportError.Unexpected, "Received empty packet");
return;
}
_tmpReader.SetBuffer(data);
OpCodes opcode = (OpCodes)_tmpReader.ReadByte();
switch (opcode)
{
case OpCodes.Data:
// first sender ready is implicit when data is received
if (sendsFirst && state == State.WaitingHandshakeReply)
SetReady();
else if (!IsReady)
error(TransportError.Unexpected, "Unexpected data while not ready.");
if (_tmpReader.Remaining < Overhead)
{
error(TransportError.Unexpected, "received data packet smaller than metadata size");
return;
}
ArraySegment<byte> ciphertext = _tmpReader.ReadBytesSegment(_tmpReader.Remaining - NonceSize);
_tmpReader.ReadBytes(ReceiveNonce.Value, NonceSize);
Profiler.BeginSample("EncryptedConnection.Decrypt");
ArraySegment<byte> plaintext = Decrypt(ciphertext);
Profiler.EndSample();
if (plaintext.Count == 0)
// error
return;
receive(plaintext, channel);
break;
case OpCodes.HandshakeStart:
if (sendsFirst)
{
error(TransportError.Unexpected, "Received HandshakeStart packet, we don't expect this.");
return;
}
if (state == State.WaitingHandshakeReply)
// this is fine, packets may arrive out of order
return;
state = State.WaitingHandshakeReply;
ResetTimeouts();
CompleteExchange(_tmpReader.ReadBytesSegment(_tmpReader.Remaining), hkdfSalt);
SendHandshakeAndPubKey(OpCodes.HandshakeAck);
break;
case OpCodes.HandshakeAck:
if (!sendsFirst)
{
error(TransportError.Unexpected, "Received HandshakeAck packet, we don't expect this.");
return;
}
if (IsReady)
// this is fine, packets may arrive out of order
return;
if (state == State.WaitingHandshakeReply)
// this is fine, packets may arrive out of order
return;
state = State.WaitingHandshakeReply;
ResetTimeouts();
_tmpReader.ReadBytes(TMPRemoteSaltBuffer.Value, HkdfSaltSize);
CompleteExchange(_tmpReader.ReadBytesSegment(_tmpReader.Remaining), TMPRemoteSaltBuffer.Value);
SendHandshakeFin();
break;
case OpCodes.HandshakeFin:
if (sendsFirst)
{
error(TransportError.Unexpected, "Received HandshakeFin packet, we don't expect this.");
return;
}
if (IsReady)
// this is fine, packets may arrive out of order
return;
if (state != State.WaitingHandshakeReply)
{
error(TransportError.Unexpected,
"Received HandshakeFin packet, we didn't expect this yet.");
return;
}
SetReady();
break;
default:
error(TransportError.InvalidReceive, $"Unhandled opcode {(byte)opcode:x}");
break;
}
}
void SetReady()
{
// done with credentials, null out the reference
credentials = null;
state = State.Ready;
ready();
}
void ResetTimeouts()
{
handshakeTimeout = 0;
nextHandshakeResend = -1;
}
public void Send(ArraySegment<byte> data, int channel)
{
using (ConcurrentNetworkWriterPooled writer = ConcurrentNetworkWriterPool.Get())
{
writer.WriteByte((byte)OpCodes.Data);
Profiler.BeginSample("EncryptedConnection.Encrypt");
ArraySegment<byte> encrypted = Encrypt(data);
Profiler.EndSample();
if (encrypted.Count == 0)
// error
return;
writer.WriteBytes(encrypted.Array, 0, encrypted.Count);
// write nonce after since Encrypt will update it
writer.WriteBytes(nonce, 0, NonceSize);
send(writer.ToArraySegment(), channel);
}
}
ArraySegment<byte> Encrypt(ArraySegment<byte> plaintext)
{
if (plaintext.Count == 0)
// Invalid
return new ArraySegment<byte>();
// Need to make the nonce unique again before encrypting another message
UpdateNonce();
// Re-initialize the cipher with our cached parameters
Cipher.Value.Init(true, cipherParametersEncrypt);
// Calculate the expected output size, this should always be input size + mac size
int outSize = Cipher.Value.GetOutputSize(plaintext.Count);
#if UNITY_EDITOR
// expecting the outSize to be input size + MacSize
if (outSize != plaintext.Count + MacSizeBytes)
throw new Exception($"Encrypt: Unexpected output size (Expected {plaintext.Count + MacSizeBytes}, got {outSize}");
#endif
// Resize the static buffer to fit
byte[] cryptBuffer = TMPCryptBuffer.Value;
EnsureSize(ref cryptBuffer, outSize);
TMPCryptBuffer.Value = cryptBuffer;
int resultLen;
try
{
// Run the plain text through the cipher, ProcessBytes will only process full blocks
resultLen =
Cipher.Value.ProcessBytes(plaintext.Array, plaintext.Offset, plaintext.Count, cryptBuffer, 0);
// Then run any potentially remaining partial blocks through with DoFinal (and calculate the mac)
resultLen += Cipher.Value.DoFinal(cryptBuffer, resultLen);
}
// catch all Exception's since BouncyCastle is fairly noisy with both standard and their own exception types
//
catch (Exception e)
{
error(TransportError.Unexpected, $"Unexpected exception while encrypting {e.GetType()}: {e.Message}");
return new ArraySegment<byte>();
}
#if UNITY_EDITOR
// expecting the result length to match the previously calculated input size + MacSize
if (resultLen != outSize)
throw new Exception($"Encrypt: resultLen did not match outSize (expected {outSize}, got {resultLen})");
#endif
return new ArraySegment<byte>(cryptBuffer, 0, resultLen);
}
ArraySegment<byte> Decrypt(ArraySegment<byte> ciphertext)
{
if (ciphertext.Count <= MacSizeBytes)
{
error(TransportError.Unexpected, $"Received too short data packet (min {{MacSizeBytes + 1}}, got {ciphertext.Count})");
// Invalid
return new ArraySegment<byte>();
}
// Re-initialize the cipher with our cached parameters
Cipher.Value.Init(false, cipherParametersDecrypt);
// Calculate the expected output size, this should always be input size - mac size
int outSize = Cipher.Value.GetOutputSize(ciphertext.Count);
#if UNITY_EDITOR
// expecting the outSize to be input size - MacSize
if (outSize != ciphertext.Count - MacSizeBytes)
throw new Exception($"Decrypt: Unexpected output size (Expected {ciphertext.Count - MacSizeBytes}, got {outSize}");
#endif
byte[] cryptBuffer = TMPCryptBuffer.Value;
EnsureSize(ref cryptBuffer, outSize);
TMPCryptBuffer.Value = cryptBuffer;
int resultLen;
try
{
// Run the ciphertext through the cipher, ProcessBytes will only process full blocks
resultLen =
Cipher.Value.ProcessBytes(ciphertext.Array, ciphertext.Offset, ciphertext.Count, cryptBuffer, 0);
// Then run any potentially remaining partial blocks through with DoFinal (and calculate/check the mac)
resultLen += Cipher.Value.DoFinal(cryptBuffer, resultLen);
}
// catch all Exception's since BouncyCastle is fairly noisy with both standard and their own exception types
catch (Exception e)
{
error(TransportError.Unexpected, $"Unexpected exception while decrypting {e.GetType()}: {e.Message}. This usually signifies corrupt data");
return new ArraySegment<byte>();
}
#if UNITY_EDITOR
// expecting the result length to match the previously calculated input size + MacSize
if (resultLen != outSize)
throw new Exception($"Decrypt: resultLen did not match outSize (expected {outSize}, got {resultLen})");
#endif
return new ArraySegment<byte>(cryptBuffer, 0, resultLen);
}
void UpdateNonce()
{
// increment the nonce by one
// we need to ensure the nonce is *always* unique and not reused
// easiest way to do this is by simply incrementing it
for (int i = 0; i < NonceSize; i++)
{
nonce[i]++;
if (nonce[i] != 0)
break;
}
}
static void EnsureSize(ref byte[] buffer, int size)
{
if (buffer.Length < size)
// double buffer to avoid constantly resizing by a few bytes
Array.Resize(ref buffer, Math.Max(size, buffer.Length * 2));
}
void SendHandshakeAndPubKey(OpCodes opcode)
{
using (ConcurrentNetworkWriterPooled writer = ConcurrentNetworkWriterPool.Get())
{
writer.WriteByte((byte)opcode);
if (opcode == OpCodes.HandshakeAck)
writer.WriteBytes(hkdfSalt, 0, HkdfSaltSize);
writer.WriteBytes(credentials.PublicKeySerialized, 0, credentials.PublicKeySerialized.Length);
send(writer.ToArraySegment(), Channels.Unreliable);
}
}
void SendHandshakeFin()
{
using (ConcurrentNetworkWriterPooled writer = ConcurrentNetworkWriterPool.Get())
{
writer.WriteByte((byte)OpCodes.HandshakeFin);
send(writer.ToArraySegment(), Channels.Unreliable);
}
}
void CompleteExchange(ArraySegment<byte> remotePubKeyRaw, byte[] salt)
{
AsymmetricKeyParameter remotePubKey;
try
{
remotePubKey = EncryptionCredentials.DeserializePublicKey(remotePubKeyRaw);
}
catch (Exception e)
{
error(TransportError.Unexpected, $"Failed to deserialize public key of remote. {e.GetType()}: {e.Message}");
return;
}
if (validateRemoteKey != null)
{
PubKeyInfo info = new PubKeyInfo
{
Fingerprint = EncryptionCredentials.PubKeyFingerprint(remotePubKeyRaw),
Serialized = remotePubKeyRaw,
Key = remotePubKey
};
if (!validateRemoteKey(info))
{
error(TransportError.Unexpected, $"Remote public key (fingerprint: {info.Fingerprint}) failed validation. ");
return;
}
}
// Calculate a common symmetric key from our private key and the remotes public key
// This gives us the same key on the other side, with our public key and their remote
// It's like magic, but with math!
ECDHBasicAgreement ecdh = new ECDHBasicAgreement();
ecdh.Init(credentials.PrivateKey);
byte[] sharedSecret;
try
{
sharedSecret = ecdh.CalculateAgreement(remotePubKey).ToByteArrayUnsigned();
}
catch
(Exception e)
{
error(TransportError.Unexpected, $"Failed to calculate the ECDH key exchange. {e.GetType()}: {e.Message}");
return;
}
if (salt.Length != HkdfSaltSize)
{
error(TransportError.Unexpected, $"Salt is expected to be {HkdfSaltSize} bytes long, got {salt.Length}.");
return;
}
Hkdf.Value.Init(new HkdfParameters(sharedSecret, salt, HkdfInfo));
// Allocate a buffer for the output key
byte[] keyRaw = new byte[KeyLength];
// Generate the output keying material
Hkdf.Value.GenerateBytes(keyRaw, 0, keyRaw.Length);
KeyParameter key = new KeyParameter(keyRaw);
// generate a starting nonce
nonce = GenerateSecureBytes(NonceSize);
// we pass in the nonce array once (as it's stored by reference) so we can cache the AeadParameters instance
// instead of creating a new one each encrypt/decrypt
cipherParametersEncrypt = new AeadParameters(key, MacSizeBits, nonce);
cipherParametersDecrypt = new AeadParameters(key, MacSizeBits, ReceiveNonce.Value);
}
/**
* non-ready connections need to be ticked for resending key data over unreliable
*/
public void TickNonReady(double time)
{
if (IsReady)
return;
// Timeout reset
if (handshakeTimeout == 0)
handshakeTimeout = time + DurationTimeout;
else if (time > handshakeTimeout)
{
error?.Invoke(TransportError.Timeout, $"Timed out during {state}, this probably just means the other side went away which is fine.");
return;
}
// Timeout reset
if (nextHandshakeResend < 0)
{
nextHandshakeResend = time + DurationResend;
return;
}
if (time < nextHandshakeResend)
// Resend isn't due yet
return;
nextHandshakeResend = time + DurationResend;
switch (state)
{
case State.WaitingHandshake:
if (sendsFirst)
SendHandshakeAndPubKey(OpCodes.HandshakeStart);
break;
case State.WaitingHandshakeReply:
if (sendsFirst)
SendHandshakeFin();
else
SendHandshakeAndPubKey(OpCodes.HandshakeAck);
break;
case State.Ready: // IsReady is checked above & early-returned
default:
throw new ArgumentOutOfRangeException();
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 28f3ac4ff1d346a895d0b4ff714fb57b
timeCreated: 1708111337

View File

@@ -0,0 +1,119 @@
using System;
using System.IO;
using Mirror.BouncyCastle.Asn1.Pkcs;
using Mirror.BouncyCastle.Asn1.X509;
using Mirror.BouncyCastle.Crypto;
using Mirror.BouncyCastle.Crypto.Digests;
using Mirror.BouncyCastle.Crypto.Generators;
using Mirror.BouncyCastle.X509;
using Mirror.BouncyCastle.Crypto.Parameters;
using Mirror.BouncyCastle.Pkcs;
using Mirror.BouncyCastle.Security;
using UnityEngine;
namespace Mirror.Transports.Encryption
{
public class EncryptionCredentials
{
const int PrivateKeyBits = 256;
// don't actually need to store this currently
// but we'll need to for loading/saving from file maybe?
// public ECPublicKeyParameters PublicKey;
// The serialized public key, in DER format
public byte[] PublicKeySerialized;
public ECPrivateKeyParameters PrivateKey;
public string PublicKeyFingerprint;
EncryptionCredentials() {}
// TODO: load from file
public static EncryptionCredentials Generate()
{
var generator = new ECKeyPairGenerator();
generator.Init(new KeyGenerationParameters(new SecureRandom(), PrivateKeyBits));
AsymmetricCipherKeyPair keyPair = generator.GenerateKeyPair();
var serialized = SerializePublicKey((ECPublicKeyParameters)keyPair.Public);
return new EncryptionCredentials
{
// see fields above
// PublicKey = (ECPublicKeyParameters)keyPair.Public,
PublicKeySerialized = serialized,
PublicKeyFingerprint = PubKeyFingerprint(new ArraySegment<byte>(serialized)),
PrivateKey = (ECPrivateKeyParameters)keyPair.Private
};
}
public static byte[] SerializePublicKey(AsymmetricKeyParameter publicKey)
{
// apparently the best way to transmit this public key over the network is to serialize it as a DER
SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(publicKey);
return publicKeyInfo.ToAsn1Object().GetDerEncoded();
}
public static AsymmetricKeyParameter DeserializePublicKey(ArraySegment<byte> pubKey) =>
// And then we do this to deserialize from the DER (from above)
// the "new MemoryStream" actually saves an allocation, since otherwise the ArraySegment would be converted
// to a byte[] first and then shoved through a MemoryStream
PublicKeyFactory.CreateKey(new MemoryStream(pubKey.Array, pubKey.Offset, pubKey.Count, false));
public static byte[] SerializePrivateKey(AsymmetricKeyParameter privateKey)
{
// Serialize privateKey as a DER
PrivateKeyInfo privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKey);
return privateKeyInfo.ToAsn1Object().GetDerEncoded();
}
public static AsymmetricKeyParameter DeserializePrivateKey(ArraySegment<byte> privateKey) =>
// And then we do this to deserialize from the DER (from above)
// the "new MemoryStream" actually saves an allocation, since otherwise the ArraySegment would be converted
// to a byte[] first and then shoved through a MemoryStream
PrivateKeyFactory.CreateKey(new MemoryStream(privateKey.Array, privateKey.Offset, privateKey.Count, false));
public static string PubKeyFingerprint(ArraySegment<byte> publicKeyBytes)
{
Sha256Digest digest = new Sha256Digest();
byte[] hash = new byte[digest.GetDigestSize()];
digest.BlockUpdate(publicKeyBytes.Array, publicKeyBytes.Offset, publicKeyBytes.Count);
digest.DoFinal(hash, 0);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
public void SaveToFile(string path)
{
string json = JsonUtility.ToJson(new SerializedPair
{
PublicKeyFingerprint = PublicKeyFingerprint,
PublicKey = Convert.ToBase64String(PublicKeySerialized),
PrivateKey= Convert.ToBase64String(SerializePrivateKey(PrivateKey))
});
File.WriteAllText(path, json);
}
public static EncryptionCredentials LoadFromFile(string path)
{
string json = File.ReadAllText(path);
SerializedPair serializedPair = JsonUtility.FromJson<SerializedPair>(json);
byte[] publicKeyBytes = Convert.FromBase64String(serializedPair.PublicKey);
byte[] privateKeyBytes = Convert.FromBase64String(serializedPair.PrivateKey);
if (serializedPair.PublicKeyFingerprint != PubKeyFingerprint(new ArraySegment<byte>(publicKeyBytes)))
throw new Exception("Saved public key fingerprint does not match public key.");
return new EncryptionCredentials
{
PublicKeySerialized = publicKeyBytes,
PublicKeyFingerprint = serializedPair.PublicKeyFingerprint,
PrivateKey = (ECPrivateKeyParameters) DeserializePrivateKey(new ArraySegment<byte>(privateKeyBytes))
};
}
class SerializedPair
{
public string PublicKeyFingerprint;
public string PublicKey;
public string PrivateKey;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: af6ae5f74f9548588cba5731643fabaf
timeCreated: 1708139579

View File

@@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
using Mirror.BouncyCastle.Crypto;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.Serialization;
namespace Mirror.Transports.Encryption
{
[HelpURL("https://mirror-networking.gitbook.io/docs/manual/transports/encryption-transport")]
public class EncryptionTransport : Transport, PortTransport
{
public override bool IsEncrypted => true;
public override string EncryptionCipher => "AES256-GCM";
[FormerlySerializedAs("inner")]
[HideInInspector]
public Transport Inner;
public ushort Port
{
get
{
if (Inner is PortTransport portTransport)
return portTransport.Port;
Debug.LogError($"EncryptionTransport can't get Port because {Inner} is not a PortTransport");
return 0;
}
set
{
if (Inner is PortTransport portTransport)
{
portTransport.Port = value;
return;
}
Debug.LogError($"EncryptionTransport can't set Port because {Inner} is not a PortTransport");
}
}
public enum ValidationMode
{
Off,
List,
Callback
}
[FormerlySerializedAs("clientValidateServerPubKey")]
[HideInInspector]
public ValidationMode ClientValidateServerPubKey;
[FormerlySerializedAs("clientTrustedPubKeySignatures")]
[HideInInspector]
[Tooltip("List of public key fingerprints the client will accept")]
public string[] ClientTrustedPubKeySignatures;
public Func<PubKeyInfo, bool> OnClientValidateServerPubKey;
[FormerlySerializedAs("serverLoadKeyPairFromFile")]
[HideInInspector]
public bool ServerLoadKeyPairFromFile;
[FormerlySerializedAs("serverKeypairPath")]
[HideInInspector]
public string ServerKeypairPath = "./server-keys.json";
EncryptedConnection client;
readonly Dictionary<int, EncryptedConnection> serverConnections = new Dictionary<int, EncryptedConnection>();
readonly List<EncryptedConnection> serverPendingConnections =
new List<EncryptedConnection>();
EncryptionCredentials credentials;
public string EncryptionPublicKeyFingerprint => credentials?.PublicKeyFingerprint;
public byte[] EncryptionPublicKey => credentials?.PublicKeySerialized;
void ServerRemoveFromPending(EncryptedConnection con)
{
for (int i = 0; i < serverPendingConnections.Count; i++)
if (serverPendingConnections[i] == con)
{
// remove by swapping with last
int lastIndex = serverPendingConnections.Count - 1;
serverPendingConnections[i] = serverPendingConnections[lastIndex];
serverPendingConnections.RemoveAt(lastIndex);
break;
}
}
void HandleInnerServerDisconnected(int connId)
{
if (serverConnections.TryGetValue(connId, out EncryptedConnection con))
{
ServerRemoveFromPending(con);
serverConnections.Remove(connId);
}
OnServerDisconnected?.Invoke(connId);
}
void HandleInnerServerError(int connId, TransportError type, string msg) => OnServerError?.Invoke(connId, type, $"inner: {msg}");
void HandleInnerServerDataReceived(int connId, ArraySegment<byte> data, int channel)
{
if (serverConnections.TryGetValue(connId, out EncryptedConnection c))
c.OnReceiveRaw(data, channel);
}
void HandleInnerServerConnected(int connId) => HandleInnerServerConnected(connId, Inner.ServerGetClientAddress(connId));
void HandleInnerServerConnected(int connId, string clientRemoteAddress)
{
Debug.Log($"[EncryptionTransport] New connection #{connId} from {clientRemoteAddress}");
EncryptedConnection ec = null;
ec = new EncryptedConnection(
credentials,
false,
(segment, channel) => Inner.ServerSend(connId, segment, channel),
(segment, channel) => OnServerDataReceived?.Invoke(connId, segment, channel),
() =>
{
Debug.Log($"[EncryptionTransport] Connection #{connId} is ready");
// ReSharper disable once AccessToModifiedClosure
ServerRemoveFromPending(ec);
OnServerConnectedWithAddress?.Invoke(connId, clientRemoteAddress);
},
(type, msg) =>
{
OnServerError?.Invoke(connId, type, msg);
ServerDisconnect(connId);
});
serverConnections.Add(connId, ec);
serverPendingConnections.Add(ec);
}
void HandleInnerClientDisconnected()
{
client = null;
OnClientDisconnected?.Invoke();
}
void HandleInnerClientError(TransportError arg1, string arg2) => OnClientError?.Invoke(arg1, $"inner: {arg2}");
void HandleInnerClientDataReceived(ArraySegment<byte> data, int channel) => client?.OnReceiveRaw(data, channel);
void HandleInnerClientConnected() =>
client = new EncryptedConnection(
credentials,
true,
(segment, channel) => Inner.ClientSend(segment, channel),
(segment, channel) => OnClientDataReceived?.Invoke(segment, channel),
() =>
{
OnClientConnected?.Invoke();
},
(type, msg) =>
{
OnClientError?.Invoke(type, msg);
ClientDisconnect();
},
HandleClientValidateServerPubKey);
bool HandleClientValidateServerPubKey(PubKeyInfo pubKeyInfo)
{
switch (ClientValidateServerPubKey)
{
case ValidationMode.Off:
return true;
case ValidationMode.List:
return Array.IndexOf(ClientTrustedPubKeySignatures, pubKeyInfo.Fingerprint) >= 0;
case ValidationMode.Callback:
return OnClientValidateServerPubKey(pubKeyInfo);
default:
throw new ArgumentOutOfRangeException();
}
}
void Awake() =>
// check if encryption via hardware acceleration is supported.
// this can be useful to know for low end devices.
//
// hardware acceleration requires netcoreapp3.0 or later:
// https://github.com/bcgit/bc-csharp/blob/449940429c57686a6fcf6bfbb4d368dec19d906e/crypto/src/crypto/AesUtilities.cs#L18
// because AesEngine_x86 requires System.Runtime.Intrinsics.X86:
// https://github.com/bcgit/bc-csharp/blob/449940429c57686a6fcf6bfbb4d368dec19d906e/crypto/src/crypto/engines/AesEngine_X86.cs
// which Unity does not support yet.
Debug.Log($"EncryptionTransport: IsHardwareAccelerated={AesUtilities.IsHardwareAccelerated}");
public override bool Available() => Inner.Available();
public override bool ClientConnected() => client != null && client.IsReady;
public override void ClientConnect(string address)
{
switch (ClientValidateServerPubKey)
{
case ValidationMode.Off:
break;
case ValidationMode.List:
if (ClientTrustedPubKeySignatures == null || ClientTrustedPubKeySignatures.Length == 0)
{
OnClientError?.Invoke(TransportError.Unexpected, "Validate Server Public Key is set to List, but the clientTrustedPubKeySignatures list is empty.");
return;
}
break;
case ValidationMode.Callback:
if (OnClientValidateServerPubKey == null)
{
OnClientError?.Invoke(TransportError.Unexpected, "Validate Server Public Key is set to Callback, but the onClientValidateServerPubKey handler is not set");
return;
}
break;
default:
throw new ArgumentOutOfRangeException();
}
credentials = EncryptionCredentials.Generate();
Inner.OnClientConnected = HandleInnerClientConnected;
Inner.OnClientDataReceived = HandleInnerClientDataReceived;
Inner.OnClientDataSent = (bytes, channel) => OnClientDataSent?.Invoke(bytes, channel);
Inner.OnClientError = HandleInnerClientError;
Inner.OnClientDisconnected = HandleInnerClientDisconnected;
Inner.ClientConnect(address);
}
public override void ClientSend(ArraySegment<byte> segment, int channelId = Channels.Reliable) =>
client?.Send(segment, channelId);
public override void ClientDisconnect() => Inner.ClientDisconnect();
public override Uri ServerUri() => Inner.ServerUri();
public override bool ServerActive() => Inner.ServerActive();
public override void ServerStart()
{
if (ServerLoadKeyPairFromFile)
credentials = EncryptionCredentials.LoadFromFile(ServerKeypairPath);
else
credentials = EncryptionCredentials.Generate();
#pragma warning disable CS0618 // Type or member is obsolete
Inner.OnServerConnected = HandleInnerServerConnected;
#pragma warning restore CS0618 // Type or member is obsolete
Inner.OnServerConnectedWithAddress = HandleInnerServerConnected;
Inner.OnServerDataReceived = HandleInnerServerDataReceived;
Inner.OnServerDataSent = (connId, bytes, channel) => OnServerDataSent?.Invoke(connId, bytes, channel);
Inner.OnServerError = HandleInnerServerError;
Inner.OnServerDisconnected = HandleInnerServerDisconnected;
Inner.ServerStart();
}
public override void ServerSend(int connectionId, ArraySegment<byte> segment, int channelId = Channels.Reliable)
{
if (serverConnections.TryGetValue(connectionId, out EncryptedConnection connection) && connection.IsReady)
connection.Send(segment, channelId);
}
public override void ServerDisconnect(int connectionId) =>
// cleanup is done via inners disconnect event
Inner.ServerDisconnect(connectionId);
public override string ServerGetClientAddress(int connectionId) => Inner.ServerGetClientAddress(connectionId);
public override void ServerStop() => Inner.ServerStop();
public override int GetMaxPacketSize(int channelId = Channels.Reliable) =>
Inner.GetMaxPacketSize(channelId) - EncryptedConnection.Overhead;
public override int GetBatchThreshold(int channelId = Channels.Reliable) => Inner.GetBatchThreshold(channelId) - EncryptedConnection.Overhead;
public override void Shutdown() => Inner.Shutdown();
public override void ClientEarlyUpdate() => Inner.ClientEarlyUpdate();
public override void ClientLateUpdate()
{
Inner.ClientLateUpdate();
Profiler.BeginSample("EncryptionTransport.ServerLateUpdate");
client?.TickNonReady(NetworkTime.localTime);
Profiler.EndSample();
}
public override void ServerEarlyUpdate() => Inner.ServerEarlyUpdate();
public override void ServerLateUpdate()
{
Inner.ServerLateUpdate();
Profiler.BeginSample("EncryptionTransport.ServerLateUpdate");
// Reverse iteration as entries can be removed while updating
for (int i = serverPendingConnections.Count - 1; i >= 0; i--)
serverPendingConnections[i].TickNonReady(NetworkTime.time);
Profiler.EndSample();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0aa135acc32a4383ae9a5817f018cb06
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4048f5ff245dfa34abec0a401364e7c0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 31ff83bf6d2e72542adcbe2c21383f4a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
Version with renamed namespaces to avoid conflicts lives here: https://github.com/MirrorNetworking/bc-csharp
Copyright (c) 2000-2024 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org).
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sub license, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: The above copyright notice and this
permission notice shall be included in all copies or substantial portions of the Software.
**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.**

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2b45a99b5583cda419e1f1ec943fec4b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,33 @@
fileFormatVersion: 2
guid: 03a89f29994a3b44cb3015b3c5ece010
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
- first:
Windows Store Apps: WindowsStoreApps
second:
enabled: 0
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,12 @@
using System;
using Mirror.BouncyCastle.Crypto;
namespace Mirror.Transports.Encryption
{
public struct PubKeyInfo
{
public string Fingerprint;
public ArraySegment<byte> Serialized;
public AsymmetricKeyParameter Key;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e1e3744418024c02acf39f44c1d1bd20
timeCreated: 1708874062

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using kcp2k;
using Mirror.BouncyCastle.Crypto;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.Serialization;
using Debug = UnityEngine.Debug;
namespace Mirror.Transports.Encryption
{
[HelpURL("https://mirror-networking.gitbook.io/docs/manual/transports/encryption-transport")]
public class ThreadedEncryptionKcpTransport : ThreadedKcpTransport
{
public override bool IsEncrypted => true;
public override string EncryptionCipher => "AES256-GCM";
public override string ToString() => $"Encrypted {base.ToString()}";
public enum ValidationMode
{
Off,
List,
Callback
}
[HideInInspector]
public ValidationMode ClientValidateServerPubKey;
[Tooltip("List of public key fingerprints the client will accept")]
[HideInInspector]
public string[] ClientTrustedPubKeySignatures;
/// <summary>
/// Called when a client connects to a server
/// ATTENTION: NOT THREAD SAFE.
/// This will be called on the worker thread.
/// </summary>
public Func<PubKeyInfo, bool> OnClientValidateServerPubKey;
[HideInInspector]
[FormerlySerializedAs("serverLoadKeyPairFromFile")]
public bool ServerLoadKeyPairFromFile;
[HideInInspector]
[FormerlySerializedAs("serverKeypairPath")]
public string ServerKeypairPath = "./server-keys.json";
EncryptedConnection encryptedClient;
readonly Dictionary<int, EncryptedConnection> serverConnections = new Dictionary<int, EncryptedConnection>();
readonly List<EncryptedConnection> serverPendingConnections =
new List<EncryptedConnection>();
EncryptionCredentials credentials;
public string EncryptionPublicKeyFingerprint => credentials?.PublicKeyFingerprint;
public byte[] EncryptionPublicKey => credentials?.PublicKeySerialized;
// Used for threaded time keeping as unitys Time.time is not thread safe
Stopwatch stopwatch = Stopwatch.StartNew();
void ServerRemoveFromPending(EncryptedConnection con)
{
for (int i = 0; i < serverPendingConnections.Count; i++)
if (serverPendingConnections[i] == con)
{
// remove by swapping with last
int lastIndex = serverPendingConnections.Count - 1;
serverPendingConnections[i] = serverPendingConnections[lastIndex];
serverPendingConnections.RemoveAt(lastIndex);
break;
}
}
void HandleInnerServerDisconnected(int connId)
{
if (serverConnections.TryGetValue(connId, out EncryptedConnection con))
{
ServerRemoveFromPending(con);
serverConnections.Remove(connId);
}
OnThreadedServerDisconnected(connId);
}
void HandleInnerServerDataReceived(int connId, ArraySegment<byte> data, int channel)
{
if (serverConnections.TryGetValue(connId, out EncryptedConnection c))
c.OnReceiveRaw(data, channel);
}
void HandleInnerServerConnected(int connId, IPEndPoint clientRemoteAddress)
{
Debug.Log($"[ThreadedEncryptionKcpTransport] New connection #{connId} from {clientRemoteAddress}");
EncryptedConnection ec = null;
ec = new EncryptedConnection(
credentials,
false,
(segment, channel) =>
{
server.Send(connId, segment, KcpTransport.ToKcpChannel(channel));
OnThreadedServerSend(connId, segment,channel);
},
(segment, channel) => OnThreadedServerReceive(connId, segment, channel),
() =>
{
Debug.Log($"[ThreadedEncryptionKcpTransport] Connection #{connId} is ready");
// ReSharper disable once AccessToModifiedClosure
ServerRemoveFromPending(ec);
OnThreadedServerConnected(connId, clientRemoteAddress);
},
(type, msg) =>
{
OnThreadedServerError(connId, type, msg);
ServerDisconnect(connId);
});
serverConnections.Add(connId, ec);
serverPendingConnections.Add(ec);
}
void HandleInnerClientDisconnected()
{
encryptedClient = null;
OnThreadedClientDisconnected();
}
void HandleInnerClientDataReceived(ArraySegment<byte> data, int channel) => encryptedClient?.OnReceiveRaw(data, channel);
void HandleInnerClientConnected() =>
encryptedClient = new EncryptedConnection(
credentials,
true,
(segment, channel) =>
{
client.Send(segment, KcpTransport.ToKcpChannel(channel));
OnThreadedClientSend(segment, channel);
},
(segment, channel) => OnThreadedClientReceive(segment, channel),
() =>
{
OnThreadedClientConnected();
},
(type, msg) =>
{
OnThreadedClientError(type, msg);
ClientDisconnect();
},
HandleClientValidateServerPubKey);
bool HandleClientValidateServerPubKey(PubKeyInfo pubKeyInfo)
{
switch (ClientValidateServerPubKey)
{
case ValidationMode.Off:
return true;
case ValidationMode.List:
return Array.IndexOf(ClientTrustedPubKeySignatures, pubKeyInfo.Fingerprint) >= 0;
case ValidationMode.Callback:
return OnClientValidateServerPubKey(pubKeyInfo);
default:
throw new ArgumentOutOfRangeException();
}
}
protected override void Awake()
{
base.Awake();
// client (NonAlloc version is not necessary anymore)
client = new KcpClient(
HandleInnerClientConnected,
(message, channel) => HandleInnerClientDataReceived(message, KcpTransport.FromKcpChannel(channel)),
HandleInnerClientDisconnected,
(error, reason) => OnThreadedClientError(KcpTransport.ToTransportError(error), reason),
config
);
// server
server = new KcpServer(
HandleInnerServerConnected,
(connectionId, message, channel) => HandleInnerServerDataReceived(connectionId, message, KcpTransport.FromKcpChannel(channel)),
HandleInnerServerDisconnected,
(connectionId, error, reason) => OnThreadedServerError(connectionId, KcpTransport.ToTransportError(error), reason),
config
);
// check if encryption via hardware acceleration is supported.
// this can be useful to know for low end devices.
//
// hardware acceleration requires netcoreapp3.0 or later:
// https://github.com/bcgit/bc-csharp/blob/449940429c57686a6fcf6bfbb4d368dec19d906e/crypto/src/crypto/AesUtilities.cs#L18
// because AesEngine_x86 requires System.Runtime.Intrinsics.X86:
// https://github.com/bcgit/bc-csharp/blob/449940429c57686a6fcf6bfbb4d368dec19d906e/crypto/src/crypto/engines/AesEngine_X86.cs
// which Unity does not support yet.
Debug.Log($"ThreadedEncryptionKcpTransport: IsHardwareAccelerated={AesUtilities.IsHardwareAccelerated}");
}
protected override void ThreadedClientConnect(string address)
{
if (!SetupEncryptionForClient())
return;
base.ThreadedClientConnect(address);
}
bool SetupEncryptionForClient()
{
switch (ClientValidateServerPubKey)
{
case ValidationMode.Off:
break;
case ValidationMode.List:
if (ClientTrustedPubKeySignatures == null || ClientTrustedPubKeySignatures.Length == 0)
{
OnThreadedClientError(TransportError.Unexpected, "Validate Server Public Key is set to List, but the clientTrustedPubKeySignatures list is empty.");
return false;
}
break;
case ValidationMode.Callback:
if (OnClientValidateServerPubKey == null)
{
OnThreadedClientError(TransportError.Unexpected, "Validate Server Public Key is set to Callback, but the onClientValidateServerPubKey handler is not set");
return false;
}
break;
default:
throw new ArgumentOutOfRangeException();
}
credentials = EncryptionCredentials.Generate();
return true;
}
protected override void ThreadedClientConnect(Uri address)
{
if (!SetupEncryptionForClient())
return;
base.ThreadedClientConnect(address);
}
protected override void ThreadedClientSend(ArraySegment<byte> segment, int channelId)
{
encryptedClient?.Send(segment, channelId);
}
protected override void ThreadedServerStart()
{
if (ServerLoadKeyPairFromFile)
credentials = EncryptionCredentials.LoadFromFile(ServerKeypairPath);
else
credentials = EncryptionCredentials.Generate();
base.ThreadedServerStart();
}
protected override void ThreadedServerSend(int connectionId, ArraySegment<byte> segment, int channelId)
{
if (serverConnections.TryGetValue(connectionId, out EncryptedConnection connection) && connection.IsReady)
connection.Send(segment, channelId);
}
public override int GetMaxPacketSize(int channelId = Channels.Reliable) => base.GetMaxPacketSize(channelId) - EncryptedConnection.Overhead;
public override int GetBatchThreshold(int channelId) => base.GetBatchThreshold(channelId) - EncryptedConnection.Overhead;
protected override void ThreadedClientLateUpdate()
{
base.ThreadedClientLateUpdate();
Profiler.BeginSample("ThreadedEncryptionKcpTransport.ServerLateUpdate");
encryptedClient?.TickNonReady(stopwatch.Elapsed.TotalSeconds);
Profiler.EndSample();
}
protected override void ThreadedServerLateUpdate()
{
base.ThreadedServerLateUpdate();
Profiler.BeginSample("ThreadedEncryptionKcpTransport.ServerLateUpdate");
// Reverse iteration as entries can be removed while updating
for (int i = serverPendingConnections.Count - 1; i >= 0; i--)
serverPendingConnections[i].TickNonReady(stopwatch.Elapsed.TotalSeconds);
Profiler.EndSample();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5d3e310924fb49c195391b9699f20809
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ea4ea5d03df6a49449fa679ac2390773
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,365 @@
//#if MIRROR <- commented out because MIRROR isn't defined on first import yet
using System;
using System.Linq;
using System.Net;
using Mirror;
using UnityEngine;
using UnityEngine.Serialization;
namespace kcp2k
{
[HelpURL("https://mirror-networking.gitbook.io/docs/transports/kcp-transport")]
[DisallowMultipleComponent]
public class KcpTransport : Transport, PortTransport
{
// scheme used by this transport
public const string Scheme = "kcp";
// common
[Header("Transport Configuration")]
[FormerlySerializedAs("Port")]
public ushort port = 7777;
public ushort Port { get => port; set => port=value; }
[Tooltip("DualMode listens to IPv6 and IPv4 simultaneously. Disable if the platform only supports IPv4.")]
public bool DualMode = true;
[Tooltip("NoDelay is recommended to reduce latency. This also scales better without buffers getting full.")]
public bool NoDelay = true;
[Tooltip("KCP internal update interval. 100ms is KCP default, but a lower interval is recommended to minimize latency and to scale to more networked entities.")]
public uint Interval = 10;
[Tooltip("KCP timeout in milliseconds. Note that KCP sends a ping automatically.")]
public int Timeout = 10000;
[Tooltip("Socket receive buffer size. Large buffer helps support more connections. Increase operating system socket buffer size limits if needed.")]
public int RecvBufferSize = 1024 * 1027 * 7;
[Tooltip("Socket send buffer size. Large buffer helps support more connections. Increase operating system socket buffer size limits if needed.")]
public int SendBufferSize = 1024 * 1027 * 7;
[Header("Advanced")]
[Tooltip("KCP fastresend parameter. Faster resend for the cost of higher bandwidth. 0 in normal mode, 2 in turbo mode.")]
public int FastResend = 2;
[Tooltip("KCP congestion window. Restricts window size to reduce congestion. Results in only 2-3 MTU messages per Flush even on loopback. Best to keept his disabled.")]
/*public*/ bool CongestionWindow = false; // KCP 'NoCongestionWindow' is false by default. here we negate it for ease of use.
[Tooltip("KCP window size can be modified to support higher loads. This also increases max message size.")]
public uint ReceiveWindowSize = 4096; //Kcp.WND_RCV; 128 by default. Mirror sends a lot, so we need a lot more.
[Tooltip("KCP window size can be modified to support higher loads.")]
public uint SendWindowSize = 4096; //Kcp.WND_SND; 32 by default. Mirror sends a lot, so we need a lot more.
[Tooltip("KCP will try to retransmit lost messages up to MaxRetransmit (aka dead_link) before disconnecting.")]
public uint MaxRetransmit = Kcp.DEADLINK * 2; // default prematurely disconnects a lot of people (#3022). use 2x.
[Tooltip("Enable to automatically set client & server send/recv buffers to OS limit. Avoids issues with too small buffers under heavy load, potentially dropping connections. Increase the OS limit if this is still too small.")]
[FormerlySerializedAs("MaximizeSendReceiveBuffersToOSLimit")]
public bool MaximizeSocketBuffers = true;
[Header("Allowed Max Message Sizes\nBased on Receive Window Size")]
[Tooltip("KCP reliable max message size shown for convenience. Can be changed via ReceiveWindowSize.")]
[ReadOnly] public int ReliableMaxMessageSize = 0; // readonly, displayed from OnValidate
[Tooltip("KCP unreliable channel max message size for convenience. Not changeable.")]
[ReadOnly] public int UnreliableMaxMessageSize = 0; // readonly, displayed from OnValidate
// config is created from the serialized properties above.
// we can expose the config directly in the future.
// for now, let's not break people's old settings.
protected KcpConfig config;
// use default MTU for this transport.
const int MTU = Kcp.MTU_DEF;
// server & client
protected KcpServer server;
protected KcpClient client;
// debugging
[Header("Debug")]
public bool debugLog;
// show statistics in OnGUI
public bool statisticsGUI;
// log statistics for headless servers that can't show them in GUI
public bool statisticsLog;
// translate Kcp <-> Mirror channels
public static int FromKcpChannel(KcpChannel channel) =>
channel == KcpChannel.Reliable ? Channels.Reliable : Channels.Unreliable;
public static KcpChannel ToKcpChannel(int channel) =>
channel == Channels.Reliable ? KcpChannel.Reliable : KcpChannel.Unreliable;
public static TransportError ToTransportError(ErrorCode error)
{
switch(error)
{
case ErrorCode.DnsResolve: return TransportError.DnsResolve;
case ErrorCode.Timeout: return TransportError.Timeout;
case ErrorCode.Congestion: return TransportError.Congestion;
case ErrorCode.InvalidReceive: return TransportError.InvalidReceive;
case ErrorCode.InvalidSend: return TransportError.InvalidSend;
case ErrorCode.ConnectionClosed: return TransportError.ConnectionClosed;
case ErrorCode.Unexpected: return TransportError.Unexpected;
default: throw new InvalidCastException($"KCP: missing error translation for {error}");
}
}
protected virtual void Awake()
{
// logging
// Log.Info should use Debug.Log if enabled, or nothing otherwise
// (don't want to spam the console on headless servers)
if (debugLog)
Log.Info = Debug.Log;
else
Log.Info = _ => {};
Log.Warning = Debug.LogWarning;
Log.Error = Debug.LogError;
// create config from serialized settings
config = new KcpConfig(DualMode, RecvBufferSize, SendBufferSize, MTU, NoDelay, Interval, FastResend, CongestionWindow, SendWindowSize, ReceiveWindowSize, Timeout, MaxRetransmit);
// client (NonAlloc version is not necessary anymore)
client = new KcpClient(
() => OnClientConnected.Invoke(),
(message, channel) => OnClientDataReceived.Invoke(message, FromKcpChannel(channel)),
() => OnClientDisconnected?.Invoke(), // may be null in StopHost(): https://github.com/MirrorNetworking/Mirror/issues/3708
(error, reason) => OnClientError?.Invoke(ToTransportError(error), reason), // may be null during shutdown: https://github.com/MirrorNetworking/Mirror/issues/3876
config
);
// server
server = new KcpServer(
(connectionId, endPoint) => OnServerConnectedWithAddress.Invoke(connectionId, endPoint.PrettyAddress()),
(connectionId, message, channel) => OnServerDataReceived.Invoke(connectionId, message, FromKcpChannel(channel)),
(connectionId) => OnServerDisconnected.Invoke(connectionId),
(connectionId, error, reason) => OnServerError.Invoke(connectionId, ToTransportError(error), reason),
config
);
if (statisticsLog)
InvokeRepeating(nameof(OnLogStatistics), 1, 1);
Log.Info("KcpTransport initialized!");
}
protected virtual void OnValidate()
{
// show max message sizes in inspector for convenience.
// 'config' isn't available in edit mode yet, so use MTU define.
ReliableMaxMessageSize = KcpPeer.ReliableMaxMessageSize(MTU, ReceiveWindowSize);
UnreliableMaxMessageSize = KcpPeer.UnreliableMaxMessageSize(MTU);
}
// all except WebGL
// Do not change this back to using Application.platform
// because that doesn't work in the Editor!
public override bool Available() =>
#if UNITY_WEBGL
false;
#else
true;
#endif
// client
public override bool ClientConnected() => client.connected;
public override void ClientConnect(string address)
{
client.Connect(address, Port);
}
public override void ClientConnect(Uri uri)
{
if (uri.Scheme != Scheme)
throw new ArgumentException($"Invalid url {uri}, use {Scheme}://host:port instead", nameof(uri));
int serverPort = uri.IsDefaultPort ? Port : uri.Port;
client.Connect(uri.Host, (ushort)serverPort);
}
public override void ClientSend(ArraySegment<byte> segment, int channelId)
{
client.Send(segment, ToKcpChannel(channelId));
// call event. might be null if no statistics are listening etc.
OnClientDataSent?.Invoke(segment, channelId);
}
public override void ClientDisconnect() => client.Disconnect();
// process incoming in early update
public override void ClientEarlyUpdate()
{
// only process messages while transport is enabled.
// scene change messsages disable it to stop processing.
// (see also: https://github.com/vis2k/Mirror/pull/379)
if (enabled) client.TickIncoming();
}
// process outgoing in late update
public override void ClientLateUpdate() => client.TickOutgoing();
// server
public override Uri ServerUri()
{
UriBuilder builder = new UriBuilder();
builder.Scheme = Scheme;
builder.Host = Dns.GetHostName();
builder.Port = Port;
return builder.Uri;
}
public override bool ServerActive() => server.IsActive();
public override void ServerStart() => server.Start(Port);
public override void ServerSend(int connectionId, ArraySegment<byte> segment, int channelId)
{
server.Send(connectionId, segment, ToKcpChannel(channelId));
// call event. might be null if no statistics are listening etc.
OnServerDataSent?.Invoke(connectionId, segment, channelId);
}
public override void ServerDisconnect(int connectionId) => server.Disconnect(connectionId);
public override string ServerGetClientAddress(int connectionId)
{
IPEndPoint endPoint = server.GetClientEndPoint(connectionId);
return endPoint.PrettyAddress();
}
public override void ServerStop() => server.Stop();
public override void ServerEarlyUpdate()
{
// only process messages while transport is enabled.
// scene change messsages disable it to stop processing.
// (see also: https://github.com/vis2k/Mirror/pull/379)
if (enabled) server.TickIncoming();
}
// process outgoing in late update
public override void ServerLateUpdate() => server.TickOutgoing();
// common
public override void Shutdown() {}
// max message size
public override int GetMaxPacketSize(int channelId = Channels.Reliable)
{
// switch to kcp channel.
// unreliable or reliable.
// default to reliable just to be sure.
switch (channelId)
{
case Channels.Unreliable:
return KcpPeer.UnreliableMaxMessageSize(config.Mtu);
default:
return KcpPeer.ReliableMaxMessageSize(config.Mtu, ReceiveWindowSize);
}
}
// kcp reliable channel max packet size is MTU * WND_RCV
// this allows 144kb messages. but due to head of line blocking, all
// other messages would have to wait until the maxed size one is
// delivered. batching 144kb messages each time would be EXTREMELY slow
// and fill the send queue nearly immediately when using it over the
// network.
// => instead we always use MTU sized batches.
// => people can still send maxed size if needed.
public override int GetBatchThreshold(int channelId) =>
KcpPeer.UnreliableMaxMessageSize(config.Mtu);
// server statistics
// LONG to avoid int overflows with connections.Sum.
// see also: https://github.com/vis2k/Mirror/pull/2777
public long GetAverageMaxSendRate() =>
server.connections.Count > 0
? server.connections.Values.Sum(conn => conn.MaxSendRate) / server.connections.Count
: 0;
public long GetAverageMaxReceiveRate() =>
server.connections.Count > 0
? server.connections.Values.Sum(conn => conn.MaxReceiveRate) / server.connections.Count
: 0;
long GetTotalSendQueue() =>
server.connections.Values.Sum(conn => conn.SendQueueCount);
long GetTotalReceiveQueue() =>
server.connections.Values.Sum(conn => conn.ReceiveQueueCount);
long GetTotalSendBuffer() =>
server.connections.Values.Sum(conn => conn.SendBufferCount);
long GetTotalReceiveBuffer() =>
server.connections.Values.Sum(conn => conn.ReceiveBufferCount);
// PrettyBytes function from DOTSNET
// pretty prints bytes as KB/MB/GB/etc.
// long to support > 2GB
// divides by floats to return "2.5MB" etc.
public static string PrettyBytes(long bytes)
{
// bytes
if (bytes < 1024)
return $"{bytes} B";
// kilobytes
else if (bytes < 1024L * 1024L)
return $"{(bytes / 1024f):F2} KB";
// megabytes
else if (bytes < 1024 * 1024L * 1024L)
return $"{(bytes / (1024f * 1024f)):F2} MB";
// gigabytes
return $"{(bytes / (1024f * 1024f * 1024f)):F2} GB";
}
protected virtual void OnGUIStatistics()
{
GUILayout.BeginArea(new Rect(5, 110, 300, 300));
if (ServerActive())
{
GUILayout.BeginVertical("Box");
GUILayout.Label("SERVER");
GUILayout.Label($" connections: {server.connections.Count}");
GUILayout.Label($" MaxSendRate (avg): {PrettyBytes(GetAverageMaxSendRate())}/s");
GUILayout.Label($" MaxRecvRate (avg): {PrettyBytes(GetAverageMaxReceiveRate())}/s");
GUILayout.Label($" SendQueue: {GetTotalSendQueue()}");
GUILayout.Label($" ReceiveQueue: {GetTotalReceiveQueue()}");
GUILayout.Label($" SendBuffer: {GetTotalSendBuffer()}");
GUILayout.Label($" ReceiveBuffer: {GetTotalReceiveBuffer()}");
GUILayout.EndVertical();
}
if (ClientConnected())
{
GUILayout.BeginVertical("Box");
GUILayout.Label("CLIENT");
GUILayout.Label($" MaxSendRate: {PrettyBytes(client.MaxSendRate)}/s");
GUILayout.Label($" MaxRecvRate: {PrettyBytes(client.MaxReceiveRate)}/s");
GUILayout.Label($" SendQueue: {client.SendQueueCount}");
GUILayout.Label($" ReceiveQueue: {client.ReceiveQueueCount}");
GUILayout.Label($" SendBuffer: {client.SendBufferCount}");
GUILayout.Label($" ReceiveBuffer: {client.ReceiveBufferCount}");
GUILayout.EndVertical();
}
GUILayout.EndArea();
}
// OnGUI allocates even if it does nothing. avoid in release.
#if UNITY_EDITOR || (!UNITY_SERVER && DEBUG)
protected virtual void OnGUI()
{
if (statisticsGUI) OnGUIStatistics();
}
#endif
protected virtual void OnLogStatistics()
{
if (ServerActive())
{
string log = "kcp SERVER @ time: " + NetworkTime.localTime + "\n";
log += $" connections: {server.connections.Count}\n";
log += $" MaxSendRate (avg): {PrettyBytes(GetAverageMaxSendRate())}/s\n";
log += $" MaxRecvRate (avg): {PrettyBytes(GetAverageMaxReceiveRate())}/s\n";
log += $" SendQueue: {GetTotalSendQueue()}\n";
log += $" ReceiveQueue: {GetTotalReceiveQueue()}\n";
log += $" SendBuffer: {GetTotalSendBuffer()}\n";
log += $" ReceiveBuffer: {GetTotalReceiveBuffer()}\n\n";
Log.Info(log);
}
if (ClientConnected())
{
string log = "kcp CLIENT @ time: " + NetworkTime.localTime + "\n";
log += $" MaxSendRate: {PrettyBytes(client.MaxSendRate)}/s\n";
log += $" MaxRecvRate: {PrettyBytes(client.MaxReceiveRate)}/s\n";
log += $" SendQueue: {client.SendQueueCount}\n";
log += $" ReceiveQueue: {client.ReceiveQueueCount}\n";
log += $" SendBuffer: {client.SendBufferCount}\n";
log += $" ReceiveBuffer: {client.ReceiveBufferCount}\n\n";
Log.Info(log);
}
}
public override string ToString() => $"KCP [{port}]";
}
}
//#endif MIRROR <- commented out because MIRROR isn't defined on first import yet

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b0fecffa3f624585964b0d0eb21b18e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,327 @@
// Threaded version of our KCP transport.
// Elevates a few milliseconds of transport computations into a worker thread.
//
//#if MIRROR <- commented out because MIRROR isn't defined on first import yet
using System;
using System.Net;
using Mirror;
using UnityEngine;
using UnityEngine.Serialization;
namespace kcp2k
{
[HelpURL("https://mirror-networking.gitbook.io/docs/transports/kcp-transport")]
[DisallowMultipleComponent]
public class ThreadedKcpTransport : ThreadedTransport, PortTransport
{
// scheme used by this transport
public const string Scheme = "kcp";
// common
[Header("Transport Configuration")]
[FormerlySerializedAs("Port")]
public ushort port = 7777;
public ushort Port { get => port; set => port=value; }
[Tooltip("DualMode listens to IPv6 and IPv4 simultaneously. Disable if the platform only supports IPv4.")]
public bool DualMode = true;
[Tooltip("NoDelay is recommended to reduce latency. This also scales better without buffers getting full.")]
public bool NoDelay = true;
[Tooltip("KCP internal update interval. 100ms is KCP default, but a lower interval is recommended to minimize latency and to scale to more networked entities.")]
public uint Interval = 10;
[Tooltip("KCP timeout in milliseconds. Note that KCP sends a ping automatically.")]
public int Timeout = 10000;
[Tooltip("Socket receive buffer size. Large buffer helps support more connections. Increase operating system socket buffer size limits if needed.")]
public int RecvBufferSize = 1024 * 1027 * 7;
[Tooltip("Socket send buffer size. Large buffer helps support more connections. Increase operating system socket buffer size limits if needed.")]
public int SendBufferSize = 1024 * 1027 * 7;
[Header("Advanced")]
[Tooltip("KCP fastresend parameter. Faster resend for the cost of higher bandwidth. 0 in normal mode, 2 in turbo mode.")]
public int FastResend = 2;
[Tooltip("KCP congestion window. Restricts window size to reduce congestion. Results in only 2-3 MTU messages per Flush even on loopback. Best to keept his disabled.")]
/*public*/ bool CongestionWindow = false; // KCP 'NoCongestionWindow' is false by default. here we negate it for ease of use.
[Tooltip("KCP window size can be modified to support higher loads. This also increases max message size.")]
public uint ReceiveWindowSize = 4096; //Kcp.WND_RCV; 128 by default. Mirror sends a lot, so we need a lot more.
[Tooltip("KCP window size can be modified to support higher loads.")]
public uint SendWindowSize = 4096; //Kcp.WND_SND; 32 by default. Mirror sends a lot, so we need a lot more.
[Tooltip("KCP will try to retransmit lost messages up to MaxRetransmit (aka dead_link) before disconnecting.")]
public uint MaxRetransmit = Kcp.DEADLINK * 2; // default prematurely disconnects a lot of people (#3022). use 2x.
[Tooltip("Enable to automatically set client & server send/recv buffers to OS limit. Avoids issues with too small buffers under heavy load, potentially dropping connections. Increase the OS limit if this is still too small.")]
[FormerlySerializedAs("MaximizeSendReceiveBuffersToOSLimit")]
public bool MaximizeSocketBuffers = true;
[Header("Allowed Max Message Sizes\nBased on Receive Window Size")]
[Tooltip("KCP reliable max message size shown for convenience. Can be changed via ReceiveWindowSize.")]
[ReadOnly] public int ReliableMaxMessageSize = 0; // readonly, displayed from OnValidate
[Tooltip("KCP unreliable channel max message size for convenience. Not changeable.")]
[ReadOnly] public int UnreliableMaxMessageSize = 0; // readonly, displayed from OnValidate
// config is created from the serialized properties above.
// we can expose the config directly in the future.
// for now, let's not break people's old settings.
protected KcpConfig config;
// use default MTU for this transport.
const int MTU = Kcp.MTU_DEF;
// server & client
protected KcpServer server; // USED IN WORKER THREAD. DON'T TOUCH FROM MAIN THREAD!
protected KcpClient client; // USED IN WORKER THREAD. DON'T TOUCH FROM MAIN THREAD!
// copy MonoBehaviour.enabled for thread safe access
volatile bool enabledCopy = true;
// debugging
[Header("Debug")]
public bool debugLog;
// show statistics in OnGUI
public bool statisticsGUI;
// log statistics for headless servers that can't show them in GUI
public bool statisticsLog;
protected override void Awake()
{
// logging
// Log.Info should use Debug.Log if enabled, or nothing otherwise
// (don't want to spam the console on headless servers)
// THREAD SAFE thanks to ThreadLog.cs
if (debugLog)
Log.Info = Debug.Log;
else
Log.Info = _ => {};
Log.Warning = Debug.LogWarning;
Log.Error = Debug.LogError;
// create config from serialized settings
config = new KcpConfig(DualMode, RecvBufferSize, SendBufferSize, MTU, NoDelay, Interval, FastResend, CongestionWindow, SendWindowSize, ReceiveWindowSize, Timeout, MaxRetransmit);
// client (NonAlloc version is not necessary anymore)
client = new KcpClient(
OnThreadedClientConnected,
(message, channel) => OnThreadedClientReceive(message, KcpTransport.FromKcpChannel(channel)),
OnThreadedClientDisconnected,
(error, reason) => OnThreadedClientError(KcpTransport.ToTransportError(error), reason),
config
);
// server
server = new KcpServer(
OnThreadedServerConnected,
(connectionId, message, channel) => OnThreadedServerReceive(connectionId, message, KcpTransport.FromKcpChannel(channel)),
OnThreadedServerDisconnected,
(connectionId, error, reason) => OnThreadedServerError(connectionId, KcpTransport.ToTransportError(error), reason),
config
);
if (statisticsLog)
InvokeRepeating(nameof(OnLogStatistics), 1, 1);
// call base after creating kcp.
// it'll be used by the created thread immediately.
base.Awake();
Log.Info("ThreadedKcpTransport initialized!");
}
protected virtual void OnValidate()
{
// show max message sizes in inspector for convenience.
// 'config' isn't available in edit mode yet, so use MTU define.
ReliableMaxMessageSize = KcpPeer.ReliableMaxMessageSize(MTU, ReceiveWindowSize);
UnreliableMaxMessageSize = KcpPeer.UnreliableMaxMessageSize(MTU);
}
// copy MonoBehaviour.enabled for thread safe use
void OnEnable() => enabledCopy = true;
void OnDisable() => enabledCopy = true;
// all except WebGL
// Do not change this back to using Application.platform
// because that doesn't work in the Editor!
public override bool Available() =>
#if UNITY_WEBGL
false;
#else
true;
#endif
protected override void ThreadedClientConnect(string address) => client.Connect(address, Port);
protected override void ThreadedClientConnect(Uri uri)
{
if (uri.Scheme != Scheme)
throw new ArgumentException($"Invalid url {uri}, use {Scheme}://host:port instead", nameof(uri));
int serverPort = uri.IsDefaultPort ? Port : uri.Port;
client.Connect(uri.Host, (ushort)serverPort);
}
protected override void ThreadedClientSend(ArraySegment<byte> segment, int channelId)
{
client.Send(segment, KcpTransport.ToKcpChannel(channelId));
// thread safe version for statistics
OnThreadedClientSend(segment, channelId);
}
protected override void ThreadedClientDisconnect() => client.Disconnect();
// process incoming in early update
protected override void ThreadedClientEarlyUpdate()
{
// only process messages while transport is enabled.
// scene change messsages disable it to stop processing.
// (see also: https://github.com/vis2k/Mirror/pull/379)
// => enabledCopy for thread safe use
if (enabledCopy) client.TickIncoming();
}
// process outgoing in late update
protected override void ThreadedClientLateUpdate() => client.TickOutgoing();
// server thread overrides
public override Uri ServerUri()
{
UriBuilder builder = new UriBuilder();
builder.Scheme = Scheme;
builder.Host = Dns.GetHostName();
builder.Port = Port;
return builder.Uri;
}
protected override void ThreadedServerStart() => server.Start(Port);
protected override void ThreadedServerSend(int connectionId, ArraySegment<byte> segment, int channelId)
{
server.Send(connectionId, segment, KcpTransport.ToKcpChannel(channelId));
// thread safe version for statistics
OnThreadedServerSend(connectionId, segment, channelId);
}
protected override void ThreadedServerDisconnect(int connectionId) => server.Disconnect(connectionId);
/* NOT THREAD SAFE. ThreadedTransport version throws NotImplementedException for this.
public override string ServerGetClientAddress(int connectionId)
{
IPEndPoint endPoint = server.GetClientEndPoint(connectionId);
return endPoint != null
// Map to IPv4 if "IsIPv4MappedToIPv6"
// "::ffff:127.0.0.1" -> "127.0.0.1"
? (endPoint.Address.IsIPv4MappedToIPv6
? endPoint.Address.MapToIPv4().ToString()
: endPoint.Address.ToString())
: "";
}
*/
protected override void ThreadedServerStop() => server.Stop();
protected override void ThreadedServerEarlyUpdate()
{
// only process messages while transport is enabled.
// scene change messsages disable it to stop processing.
// (see also: https://github.com/vis2k/Mirror/pull/379)
// => enabledCopy for thread safe use
if (enabledCopy) server.TickIncoming();
}
// process outgoing in late update
protected override void ThreadedServerLateUpdate() => server.TickOutgoing();
protected override void ThreadedShutdown() {}
// max message size
public override int GetMaxPacketSize(int channelId = Channels.Reliable)
{
// switch to kcp channel.
// unreliable or reliable.
// default to reliable just to be sure.
switch (channelId)
{
case Channels.Unreliable:
return KcpPeer.UnreliableMaxMessageSize(config.Mtu);
default:
return KcpPeer.ReliableMaxMessageSize(config.Mtu, ReceiveWindowSize);
}
}
// kcp reliable channel max packet size is MTU * WND_RCV
// this allows 144kb messages. but due to head of line blocking, all
// other messages would have to wait until the maxed size one is
// delivered. batching 144kb messages each time would be EXTREMELY slow
// and fill the send queue nearly immediately when using it over the
// network.
// => instead we always use MTU sized batches.
// => people can still send maxed size if needed.
public override int GetBatchThreshold(int channelId) =>
KcpPeer.UnreliableMaxMessageSize(config.Mtu);
protected virtual void OnGUIStatistics()
{
// TODO not thread safe
/*
GUILayout.BeginArea(new Rect(5, 110, 300, 300));
if (ServerActive())
{
GUILayout.BeginVertical("Box");
GUILayout.Label("SERVER");
GUILayout.Label($" connections: {server.connections.Count}");
GUILayout.Label($" MaxSendRate (avg): {KcpTransport.PrettyBytes(GetAverageMaxSendRate())}/s");
GUILayout.Label($" MaxRecvRate (avg): {KcpTransport.PrettyBytes(GetAverageMaxReceiveRate())}/s");
GUILayout.Label($" SendQueue: {GetTotalSendQueue()}");
GUILayout.Label($" ReceiveQueue: {GetTotalReceiveQueue()}");
GUILayout.Label($" SendBuffer: {GetTotalSendBuffer()}");
GUILayout.Label($" ReceiveBuffer: {GetTotalReceiveBuffer()}");
GUILayout.EndVertical();
}
if (ClientConnected())
{
GUILayout.BeginVertical("Box");
GUILayout.Label("CLIENT");
GUILayout.Label($" MaxSendRate: {KcpTransport.PrettyBytes(client.peer.MaxSendRate)}/s");
GUILayout.Label($" MaxRecvRate: {KcpTransport.PrettyBytes(client.peer.MaxReceiveRate)}/s");
GUILayout.Label($" SendQueue: {client.peer.SendQueueCount}");
GUILayout.Label($" ReceiveQueue: {client.peer.ReceiveQueueCount}");
GUILayout.Label($" SendBuffer: {client.peer.SendBufferCount}");
GUILayout.Label($" ReceiveBuffer: {client.peer.ReceiveBufferCount}");
GUILayout.EndVertical();
}
GUILayout.EndArea();
*/
}
// OnGUI allocates even if it does nothing. avoid in release.
#if UNITY_EDITOR || (!UNITY_SERVER && DEBUG)
protected virtual void OnGUI()
{
if (statisticsGUI) OnGUIStatistics();
}
#endif
protected virtual void OnLogStatistics()
{
// TODO not thread safe
/*
if (ServerActive())
{
string log = "kcp SERVER @ time: " + NetworkTime.localTime + "\n";
log += $" connections: {server.connections.Count}\n";
log += $" MaxSendRate (avg): {KcpTransport.PrettyBytes(GetAverageMaxSendRate())}/s\n";
log += $" MaxRecvRate (avg): {KcpTransport.PrettyBytes(GetAverageMaxReceiveRate())}/s\n";
log += $" SendQueue: {GetTotalSendQueue()}\n";
log += $" ReceiveQueue: {GetTotalReceiveQueue()}\n";
log += $" SendBuffer: {GetTotalSendBuffer()}\n";
log += $" ReceiveBuffer: {GetTotalReceiveBuffer()}\n\n";
Log.Info(log);
}
if (ClientConnected())
{
string log = "kcp CLIENT @ time: " + NetworkTime.localTime + "\n";
log += $" MaxSendRate: {KcpTransport.PrettyBytes(client.peer.MaxSendRate)}/s\n";
log += $" MaxRecvRate: {KcpTransport.PrettyBytes(client.peer.MaxReceiveRate)}/s\n";
log += $" SendQueue: {client.peer.SendQueueCount}\n";
log += $" ReceiveQueue: {client.peer.ReceiveQueueCount}\n";
log += $" SendBuffer: {client.peer.SendBufferCount}\n";
log += $" ReceiveBuffer: {client.peer.ReceiveBufferCount}\n\n";
Log.Info(log);
}
*/
}
public override string ToString() => $"ThreadedKCP {port}";
}
}
//#endif MIRROR <- commented out because MIRROR isn't defined on first import yet

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7e416e0486524f0d9580be7e13388f4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 71a1c8e8c022d4731a481c1808f37e5d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
{
"name": "kcp2k",
"rootNamespace": "",
"references": [
"GUID:30817c1a0e6d646d99c048fc403f5979"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": true,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6806a62c384838046a3c66c44f06d75f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,24 @@
MIT License
Copyright (c) 2016 limpo1989
Copyright (c) 2020 Paul Pacheco
Copyright (c) 2020 Lymdun
Copyright (c) 2020 vis2k
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9a3e8369060cf4e94ac117603de47aa6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,261 @@
V1.41 [2024-04-28]
- fix: KcpHeader is now parsed safely, handling attackers potentially sending values out of enum range
- fix: KcpClient RawSend may throw ConnectionRefused SocketException when OnDisconnected calls SendDisconnect(), which is fine
- fix: less scary cookie message and better explanation
V1.40 [2024-01-03]
- added [KCP] to all log messages
- fix: #3704 remove old fix for #2353 which caused log spam and isn't needed anymore since the
original Mirror issue is long gone
- fix: KcpClient.RawSend now returns if socket wasn't created yet
- fix: https://github.com/MirrorNetworking/Mirror/issues/3591 KcpPeer.SendDisconnect now rapid
fires several unreliable messages instead of sending reliable. Fixes disconnect message not
going through if the connection is closed & removed immediately after.
V1.39 [2023-10-31]
- fix: https://github.com/MirrorNetworking/Mirror/issues/3611 Windows UDP socket exceptions
on server if one of the clients died
V1.38 [2023-10-29]
- fix: #54 mismatching cookie race condition. cookie is now included in all messages.
- feature: Exposed local end point on KcpClient/Server
- refactor: KcpPeer refactored as abstract class to remove KcpServer initialization workarounds
V1.37 [2023-07-31]
- fix: #47 KcpServer.Stop now clears connections so they aren't carried over to the next session
- fix: KcpPeer doesn't log 'received unreliable message while not authenticated' anymore.
V1.36 [2023-06-08]
- fix: #49 KcpPeer.RawInput message size check now considers cookie as well
- kcp.cs cleanups
V1.35 [2023-04-05]
- fix: KcpClients now need to validate with a secure cookie in order to protect against
UDP spoofing. fixes:
https://github.com/MirrorNetworking/Mirror/issues/3286
[disclosed by IncludeSec]
- KcpClient/Server: change callbacks to protected so inheriting classes can use them too
- KcpClient/Server: change config visibility to protected
V1.34 [2023-03-15]
- Send/SendTo/Receive/ReceiveFrom NonBlocking extensions.
to encapsulate WouldBlock allocations, exceptions, etc.
allows for reuse when overwriting KcpServer/Client (i.e. for relays).
V1.33 [2023-03-14]
- perf: KcpServer/Client RawReceive now call socket.Poll to avoid non-blocking
socket's allocating a new SocketException in case they WouldBlock.
fixes https://github.com/MirrorNetworking/Mirror/issues/3413
- perf: KcpServer/Client RawSend now call socket.Poll to avoid non-blocking
socket's allocating a new SocketException in case they WouldBlock.
fixes https://github.com/MirrorNetworking/Mirror/issues/3413
V1.32 [2023-03-12]
- fix: KcpPeer RawInput now doesn't disconnect in case of random internet noise
V1.31 [2023-03-05]
- KcpClient: Tick/Incoming/Outgoing can now be overwritten (virtual)
- breaking: KcpClient now takes KcpConfig in constructor instead of in Connect.
cleaner, and prepares for KcpConfig.MTU setting.
- KcpConfig now includes MTU; KcpPeer now works with KcpConfig's MTU, KcpServer/Client
buffers are now created with config's MTU.
V1.30 [2023-02-20]
- fix: set send/recv buffer sizes directly instead of iterating to find the limit.
fixes: https://github.com/MirrorNetworking/Mirror/issues/3390
- fix: server & client sockets are now always non-blocking to ensure main thread never
blocks on socket.recv/send. Send() now also handles WouldBlock.
- fix: socket.Receive/From directly with non-blocking sockets and handle WouldBlock,
instead of socket.Poll. faster, more obvious, and fixes Poll() looping forever while
socket is in error state. fixes: https://github.com/MirrorNetworking/Mirror/issues/2733
V1.29 [2023-01-28]
- fix: KcpServer.CreateServerSocket now handles NotSupportedException when setting DualMode
https://github.com/MirrorNetworking/Mirror/issues/3358
V1.28 [2023-01-28]
- fix: KcpClient.Connect now resolves hostname before creating peer
https://github.com/MirrorNetworking/Mirror/issues/3361
V1.27 [2023-01-08]
- KcpClient.Connect: invoke own events directly instead of going through peer,
which calls our own events anyway
- fix: KcpPeer/Client/Server callbacks are readonly and assigned in constructor
to ensure they are safe to use at all times.
fixes https://github.com/MirrorNetworking/Mirror/issues/3337
V1.26 [2022-12-22]
- KcpPeer.RawInput: fix compile error in old Unity Mono versions
- fix: KcpServer sets up a new connection's OnError immediately.
fixes KcpPeer throwing NullReferenceException when attempting to call OnError
after authentication errors.
- improved log messages
V1.25 [2022-12-14]
- breaking: removed where-allocation. use IL2CPP on servers instead.
- breaking: KcpConfig to simplify configuration
- high level cleanups
V1.24 [2022-12-14]
- KcpClient: fixed NullReferenceException when connection without a server.
added test coverage to ensure this never happens again.
V1.23 [2022-12-07]
- KcpClient: rawReceiveBuffer exposed
- fix: KcpServer RawSend uses connection.remoteEndPoint instead of the helper
'newClientEP'. fixes clients receiving the wrong messages meant for others.
https://github.com/MirrorNetworking/Mirror/issues/3296
V1.22 [2022-11-30]
- high level refactor, part two.
V1.21 [2022-11-24]
- high level refactor, part one.
- KcpPeer instead of KcpConnection, KcpClientConnection, KcpServerConnection
- RawSend/Receive can now easily be overwritten in KcpClient/Server.
for non-alloc, relays, etc.
V1.20 [2022-11-22]
- perf: KcpClient receive allocation was removed entirely.
reduces Mirror benchmark client sided allocations from 4.9 KB / 1.7 KB (non-alloc) to 0B.
- fix: KcpConnection.Disconnect does not check socket.Connected anymore.
UDP sockets don't have a connection.
fixes Disconnects not being sent to clients in netcore.
- KcpConnection.SendReliable: added OnError instead of logs
V1.19 [2022-05-12]
- feature: OnError ErrorCodes
V1.18 [2022-05-08]
- feature: OnError to allow higher level to show popups etc.
- feature: KcpServer.GetClientAddress is now GetClientEndPoint in order to
expose more details
- ResolveHostname: include exception in log for easier debugging
- fix: KcpClientConnection.RawReceive now logs the SocketException even if
it was expected. makes debugging easier.
- fix: KcpServer.TickIncoming now logs the SocketException even if it was
expected. makes debugging easier.
- fix: KcpClientConnection.RawReceive now calls Disconnect() if the other end
has closed the connection. better than just remaining in a state with unusable
sockets.
V1.17 [2022-01-09]
- perf: server/client MaximizeSendReceiveBuffersToOSLimit option to set send/recv
buffer sizes to OS limit. avoids drops due to small buffers under heavy load.
V1.16 [2022-01-06]
- fix: SendUnreliable respects ArraySegment.Offset
- fix: potential bug with negative length (see PR #2)
- breaking: removed pause handling because it's not necessary for Mirror anymore
V1.15 [2021-12-11]
- feature: feature: MaxRetransmits aka dead_link now configurable
- dead_link disconnect message improved to show exact retransmit count
V1.14 [2021-11-30]
- fix: Send() now throws an exception for messages which require > 255 fragments
- fix: ReliableMaxMessageSize is now limited to messages which require <= 255 fragments
V1.13 [2021-11-28]
- fix: perf: uncork max message size from 144 KB to as much as we want based on
receive window size.
fixes https://github.com/vis2k/kcp2k/issues/22
fixes https://github.com/skywind3000/kcp/pull/291
- feature: OnData now includes channel it was received on
V1.12 [2021-07-16]
- Tests: don't depend on Unity anymore
- fix: #26 - Kcp now catches exception if host couldn't be resolved, and calls
OnDisconnected to let the user now.
- fix: KcpServer.DualMode is now configurable in the constructor instead of
using #if UNITY_SWITCH. makes it run on all other non dual mode platforms too.
- fix: where-allocation made optional via virtuals and inheriting
KcpServer/Client/Connection NonAlloc classes. fixes a bug where some platforms
might not support where-allocation.
V1.11 rollback [2021-06-01]
- perf: Segment MemoryStream initial capacity set to MTU to avoid early runtime
resizing/allocations
V1.10 [2021-05-28]
- feature: configurable Timeout
- allocations explained with comments (C# ReceiveFrom / IPEndPoint.GetHashCode)
- fix: #17 KcpConnection.ReceiveNextReliable now assigns message default so it
works in .net too
- fix: Segment pool is not static anymore. Each kcp instance now has it's own
Pool<Segment>. fixes #18 concurrency issues
V1.9 [2021-03-02]
- Tick() split into TickIncoming()/TickOutgoing() to use in Mirror's new update
functions. allows to minimize latency.
=> original Tick() is still supported for convenience. simply processes both!
V1.8 [2021-02-14]
- fix: Unity IPv6 errors on Nintendo Switch
- fix: KcpConnection now disconnects if data message was received without content.
previously it would call OnData with an empty ArraySegment, causing all kinds of
weird behaviour in Mirror/DOTSNET. Added tests too.
- fix: KcpConnection.SendData: don't allow sending empty messages anymore. disconnect
and log a warning to make it completely obvious.
V1.7 [2021-01-13]
- fix: unreliable messages reset timeout now too
- perf: KcpConnection OnCheckEnabled callback changed to a simple 'paused' boolean.
This is faster than invoking a Func<bool> every time and allows us to fix #8 more
easily later by calling .Pause/.Unpause from OnEnable/OnDisable in MirrorTransport.
- fix #8: Unpause now resets timeout to fix a bug where Mirror would pause kcp,
change the scene which took >10s, then unpause and kcp would detect the lack of
any messages for >10s as timeout. Added test to make sure it never happens again.
- MirrorTransport: statistics logging for headless servers
- Mirror Transport: Send/Receive window size increased once more from 2048 to 4096.
V1.6 [2021-01-10]
- Unreliable channel added!
- perf: KcpHeader byte added to every kcp message to indicate
Handshake/Data/Ping/Disconnect instead of scanning each message for Hello/Byte/Ping
content via SegmentEquals. It's a lot cleaner, should be faster and should avoid
edge cases where a message content would equal Hello/Ping/Bye sequence accidentally.
- Kcp.Input: offset moved to parameters for cases where it's needed
- Kcp.SetMtu from original Kcp.c
V1.5 [2021-01-07]
- KcpConnection.MaxSend/ReceiveRate calculation based on the article
- MirrorTransport: large send/recv window size defaults to avoid high latencies caused
by packets not being processed fast enough
- MirrorTransport: show MaxSend/ReceiveRate in debug gui
- MirrorTransport: don't Log.Info to console in headless mode if debug log is disabled
V1.4 [2020-11-27]
- fix: OnCheckEnabled added. KcpConnection message processing while loop can now
be interrupted immediately. fixes Mirror Transport scene changes which need to stop
processing any messages immediately after a scene message)
- perf: Mirror KcpTransport: FastResend enabled by default. turbo mode according to:
https://github.com/skywind3000/kcp/blob/master/README.en.md#protocol-configuration
- perf: Mirror KcpTransport: CongestionControl disabled by default (turbo mode)
V1.3 [2020-11-17]
- Log.Info/Warning/Error so logging doesn't depend on UnityEngine anymore
- fix: Server.Tick catches SocketException which happens if Android client is killed
- MirrorTransport: debugLog option added that can be checked in Unity Inspector
- Utils.Clamp so Kcp.cs doesn't depend on UnityEngine
- Utils.SegmentsEqual: use Linq SequenceEqual so it doesn't depend on UnityEngine
=> kcp2k can now be used in any C# project even without Unity
V1.2 [2020-11-10]
- more tests added
- fix: raw receive buffers are now all of MTU size
- fix: raw receive detects error where buffer was too small for msgLength and
result in excess data being dropped silently
- KcpConnection.MaxMessageSize added for use in high level
- KcpConnection.MaxMessageSize increased from 1200 bytes to to maximum allowed
message size of 145KB for kcp (based on mtu, overhead, wnd_rcv)
V1.1 [2020-10-30]
- high level cleanup, fixes, improvements
V1.0 [2020-10-22]
- Kcp.cs now mirrors original Kcp.c behaviour
(this fixes dozens of bugs)
V0.1
- initial kcp-csharp based version

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ed3f2cf1bbf1b4d53a6f2c103d311f71
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d9ce2267cb8a4a1c9632025287e8da88
timeCreated: 1669162433

View File

@@ -0,0 +1 @@
// removed 2022-12-13

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 54b8398dcd544c8a93bcad846214cc40
timeCreated: 1626432191

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5a54d18b954cb4407a28b633fc32ea6d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,75 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
namespace kcp2k
{
public static class Common
{
// helper function to resolve host to IPAddress
public static bool ResolveHostname(string hostname, out IPAddress[] addresses)
{
try
{
// NOTE: dns lookup is blocking. this can take a second.
addresses = Dns.GetHostAddresses(hostname);
return addresses.Length >= 1;
}
catch (SocketException exception)
{
Log.Info($"[KCP] Failed to resolve host: {hostname} reason: {exception}");
addresses = null;
return false;
}
}
// if connections drop under heavy load, increase to OS limit.
// if still not enough, increase the OS limit.
public static void ConfigureSocketBuffers(Socket socket, int recvBufferSize, int sendBufferSize)
{
// log initial size for comparison.
// remember initial size for log comparison
int initialReceive = socket.ReceiveBufferSize;
int initialSend = socket.SendBufferSize;
// set to configured size
try
{
socket.ReceiveBufferSize = recvBufferSize;
socket.SendBufferSize = sendBufferSize;
}
catch (SocketException)
{
Log.Warning($"[KCP] failed to set Socket RecvBufSize = {recvBufferSize} SendBufSize = {sendBufferSize}");
}
Log.Info($"[KCP] RecvBuf = {initialReceive}=>{socket.ReceiveBufferSize} ({socket.ReceiveBufferSize/initialReceive}x) SendBuf = {initialSend}=>{socket.SendBufferSize} ({socket.SendBufferSize/initialSend}x)");
}
// generate a connection hash from IP+Port.
//
// NOTE: IPEndPoint.GetHashCode() allocates.
// it calls m_Address.GetHashCode().
// m_Address is an IPAddress.
// GetHashCode() allocates for IPv6:
// https://github.com/mono/mono/blob/bdd772531d379b4e78593587d15113c37edd4a64/mcs/class/referencesource/System/net/System/Net/IPAddress.cs#L699
//
// => using only newClientEP.Port wouldn't work, because
// different connections can have the same port.
public static int ConnectionHash(EndPoint endPoint) =>
endPoint.GetHashCode();
// cookies need to be generated with a secure random generator.
// we don't want them to be deterministic / predictable.
// RNG is cached to avoid runtime allocations.
static readonly RNGCryptoServiceProvider cryptoRandom = new RNGCryptoServiceProvider();
static readonly byte[] cryptoRandomBuffer = new byte[4];
public static uint GenerateCookie()
{
cryptoRandom.GetBytes(cryptoRandomBuffer);
return BitConverter.ToUInt32(cryptoRandomBuffer, 0);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9ced451c2954435f88cf718bcba020cb
timeCreated: 1669135138

View File

@@ -0,0 +1,15 @@
// kcp specific error codes to allow for error switching, localization,
// translation to Mirror errors, etc.
namespace kcp2k
{
public enum ErrorCode : byte
{
DnsResolve, // failed to resolve a host name
Timeout, // ping timeout or dead link
Congestion, // more messages than transport / network can process
InvalidReceive, // recv invalid packet (possibly intentional attack)
InvalidSend, // user tried to send invalid data
ConnectionClosed, // connection closed voluntarily or lost involuntarily
Unexpected // unexpected error / exception, requires fix.
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3abbeffc1d794f11a45b7fcf110353f5
timeCreated: 1652320712

View File

@@ -0,0 +1,166 @@
using System;
using System.Net;
using System.Net.Sockets;
namespace kcp2k
{
public static class Extensions
{
// ArraySegment as HexString for convenience
public static string ToHexString(this ArraySegment<byte> segment) =>
BitConverter.ToString(segment.Array, segment.Offset, segment.Count);
// non-blocking UDP send.
// allows for reuse when overwriting KcpServer/Client (i.e. for relays).
// => wrapped with Poll to avoid WouldBlock allocating new SocketException.
// => wrapped with try-catch to ignore WouldBlock exception.
// make sure to set socket.Blocking = false before using this!
public static bool SendToNonBlocking(this Socket socket, ArraySegment<byte> data, EndPoint remoteEP)
{
try
{
// when using non-blocking sockets, SendTo may return WouldBlock.
// in C#, WouldBlock throws a SocketException, which is expected.
// unfortunately, creating the SocketException allocates in C#.
// let's poll first to avoid the WouldBlock allocation.
// note that this entirely to avoid allocations.
// non-blocking UDP doesn't need Poll in other languages.
// and the code still works without the Poll call.
if (!socket.Poll(0, SelectMode.SelectWrite)) return false;
// send to the the endpoint.
// do not send to 'newClientEP', as that's always reused.
// fixes https://github.com/MirrorNetworking/Mirror/issues/3296
socket.SendTo(data.Array, data.Offset, data.Count, SocketFlags.None, remoteEP);
return true;
}
catch (SocketException e)
{
// for non-blocking sockets, SendTo may throw WouldBlock.
// in that case, simply drop the message. it's UDP, it's fine.
if (e.SocketErrorCode == SocketError.WouldBlock) return false;
// otherwise it's a real socket error. throw it.
throw;
}
}
// non-blocking UDP send.
// allows for reuse when overwriting KcpServer/Client (i.e. for relays).
// => wrapped with Poll to avoid WouldBlock allocating new SocketException.
// => wrapped with try-catch to ignore WouldBlock exception.
// make sure to set socket.Blocking = false before using this!
public static bool SendNonBlocking(this Socket socket, ArraySegment<byte> data)
{
try
{
// when using non-blocking sockets, SendTo may return WouldBlock.
// in C#, WouldBlock throws a SocketException, which is expected.
// unfortunately, creating the SocketException allocates in C#.
// let's poll first to avoid the WouldBlock allocation.
// note that this entirely to avoid allocations.
// non-blocking UDP doesn't need Poll in other languages.
// and the code still works without the Poll call.
if (!socket.Poll(0, SelectMode.SelectWrite)) return false;
// SendTo allocates. we used bound Send.
socket.Send(data.Array, data.Offset, data.Count, SocketFlags.None);
return true;
}
catch (SocketException e)
{
// for non-blocking sockets, SendTo may throw WouldBlock.
// in that case, simply drop the message. it's UDP, it's fine.
if (e.SocketErrorCode == SocketError.WouldBlock) return false;
// otherwise it's a real socket error. throw it.
throw;
}
}
// non-blocking UDP receive.
// allows for reuse when overwriting KcpServer/Client (i.e. for relays).
// => wrapped with Poll to avoid WouldBlock allocating new SocketException.
// => wrapped with try-catch to ignore WouldBlock exception.
// make sure to set socket.Blocking = false before using this!
public static bool ReceiveFromNonBlocking(this Socket socket, byte[] recvBuffer, out ArraySegment<byte> data, ref EndPoint remoteEP)
{
data = default;
try
{
// when using non-blocking sockets, ReceiveFrom may return WouldBlock.
// in C#, WouldBlock throws a SocketException, which is expected.
// unfortunately, creating the SocketException allocates in C#.
// let's poll first to avoid the WouldBlock allocation.
// note that this entirely to avoid allocations.
// non-blocking UDP doesn't need Poll in other languages.
// and the code still works without the Poll call.
if (!socket.Poll(0, SelectMode.SelectRead)) return false;
// NOTE: ReceiveFrom allocates.
// we pass our IPEndPoint to ReceiveFrom.
// receive from calls newClientEP.Create(socketAddr).
// IPEndPoint.Create always returns a new IPEndPoint.
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1761
//
// throws SocketException if datagram was larger than buffer.
// https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-6.0
int size = socket.ReceiveFrom(recvBuffer, 0, recvBuffer.Length, SocketFlags.None, ref remoteEP);
data = new ArraySegment<byte>(recvBuffer, 0, size);
return true;
}
catch (SocketException e)
{
// for non-blocking sockets, Receive throws WouldBlock if there is
// no message to read. that's okay. only log for other errors.
if (e.SocketErrorCode == SocketError.WouldBlock) return false;
// otherwise it's a real socket error. throw it.
throw;
}
}
// non-blocking UDP receive.
// allows for reuse when overwriting KcpServer/Client (i.e. for relays).
// => wrapped with Poll to avoid WouldBlock allocating new SocketException.
// => wrapped with try-catch to ignore WouldBlock exception.
// make sure to set socket.Blocking = false before using this!
public static bool ReceiveNonBlocking(this Socket socket, byte[] recvBuffer, out ArraySegment<byte> data)
{
data = default;
try
{
// when using non-blocking sockets, ReceiveFrom may return WouldBlock.
// in C#, WouldBlock throws a SocketException, which is expected.
// unfortunately, creating the SocketException allocates in C#.
// let's poll first to avoid the WouldBlock allocation.
// note that this entirely to avoid allocations.
// non-blocking UDP doesn't need Poll in other languages.
// and the code still works without the Poll call.
if (!socket.Poll(0, SelectMode.SelectRead)) return false;
// ReceiveFrom allocates. we used bound Receive.
// returns amount of bytes written into buffer.
// throws SocketException if datagram was larger than buffer.
// https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-6.0
//
// throws SocketException if datagram was larger than buffer.
// https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-6.0
int size = socket.Receive(recvBuffer, 0, recvBuffer.Length, SocketFlags.None);
data = new ArraySegment<byte>(recvBuffer, 0, size);
return true;
}
catch (SocketException e)
{
// for non-blocking sockets, Receive throws WouldBlock if there is
// no message to read. that's okay. only log for other errors.
if (e.SocketErrorCode == SocketError.WouldBlock) return false;
// otherwise it's a real socket error. throw it.
throw;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c0649195e5ba4fcf8e0e1231fee7d5f6
timeCreated: 1641701011

View File

@@ -0,0 +1,10 @@
namespace kcp2k
{
// channel type and header for raw messages
public enum KcpChannel : byte
{
// don't react on 0x00. might help to filter out random noise.
Reliable = 1,
Unreliable = 2
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9e852b2532fb248d19715cfebe371db3
timeCreated: 1610081248

View File

@@ -0,0 +1,292 @@
// kcp client logic abstracted into a class.
// for use in Mirror, DOTSNET, testing, etc.
using System;
using System.Net;
using System.Net.Sockets;
namespace kcp2k
{
public class KcpClient : KcpPeer
{
// IO
protected Socket socket;
public EndPoint remoteEndPoint;
// expose local endpoint for users / relays / nat traversal etc.
public EndPoint LocalEndPoint => socket?.LocalEndPoint;
// config
protected readonly KcpConfig config;
// raw receive buffer always needs to be of 'MTU' size, even if
// MaxMessageSize is larger. kcp always sends in MTU segments and having
// a buffer smaller than MTU would silently drop excess data.
// => we need the MTU to fit channel + message!
// => protected because someone may overwrite RawReceive but still wants
// to reuse the buffer.
protected readonly byte[] rawReceiveBuffer;
// callbacks
// even for errors, to allow liraries to show popups etc.
// instead of logging directly.
// (string instead of Exception for ease of use and to avoid user panic)
//
// events are readonly, set in constructor.
// this ensures they are always initialized when used.
// fixes https://github.com/MirrorNetworking/Mirror/issues/3337 and more
protected readonly Action OnConnectedCallback;
protected readonly Action<ArraySegment<byte>, KcpChannel> OnDataCallback;
protected readonly Action OnDisconnectedCallback;
protected readonly Action<ErrorCode, string> OnErrorCallback;
// state
bool active = false; // active between when connect() and disconnect() are called
public bool connected;
public KcpClient(Action OnConnected,
Action<ArraySegment<byte>, KcpChannel> OnData,
Action OnDisconnected,
Action<ErrorCode, string> OnError,
KcpConfig config)
: base(config, 0) // client has no cookie yet
{
// initialize callbacks first to ensure they can be used safely.
OnConnectedCallback = OnConnected;
OnDataCallback = OnData;
OnDisconnectedCallback = OnDisconnected;
OnErrorCallback = OnError;
this.config = config;
// create mtu sized receive buffer
rawReceiveBuffer = new byte[config.Mtu];
}
// callbacks ///////////////////////////////////////////////////////////
// some callbacks need to wrapped with some extra logic
protected override void OnAuthenticated()
{
Log.Info($"[KCP] Client: OnConnected");
connected = true;
OnConnectedCallback();
}
protected override void OnData(ArraySegment<byte> message, KcpChannel channel) =>
OnDataCallback(message, channel);
protected override void OnError(ErrorCode error, string message) =>
OnErrorCallback(error, message);
protected override void OnDisconnected()
{
Log.Info($"[KCP] Client: OnDisconnected");
connected = false;
socket?.Close();
socket = null;
remoteEndPoint = null;
OnDisconnectedCallback();
active = false;
}
////////////////////////////////////////////////////////////////////////
public void Connect(string address, ushort port)
{
if (connected)
{
Log.Warning("[KCP] Client: already connected!");
return;
}
// resolve host name before creating peer.
// fixes: https://github.com/MirrorNetworking/Mirror/issues/3361
if (!Common.ResolveHostname(address, out IPAddress[] addresses))
{
// pass error to user callback. no need to log it manually.
OnError(ErrorCode.DnsResolve, $"Failed to resolve host: {address}");
OnDisconnectedCallback();
return;
}
// create fresh peer for each new session
// client doesn't need secure cookie.
Reset(config);
Log.Info($"[KCP] Client: connect to {address}:{port}");
// create socket
remoteEndPoint = new IPEndPoint(addresses[0], port);
socket = new Socket(remoteEndPoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
active = true;
// recv & send are called from main thread.
// need to ensure this never blocks.
// even a 1ms block per connection would stop us from scaling.
socket.Blocking = false;
// configure buffer sizes
Common.ConfigureSocketBuffers(socket, config.RecvBufferSize, config.SendBufferSize);
// bind to endpoint so we can use send/recv instead of sendto/recvfrom.
socket.Connect(remoteEndPoint);
// immediately send a hello message to the server.
// server will call OnMessage and add the new connection.
// note that this still has cookie=0 until we receive the server's hello.
SendHello();
}
// io - input.
// virtual so it may be modified for relays, etc.
// call this while it returns true, to process all messages this tick.
// returned ArraySegment is valid until next call to RawReceive.
protected virtual bool RawReceive(out ArraySegment<byte> segment)
{
segment = default;
if (socket == null) return false;
try
{
return socket.ReceiveNonBlocking(rawReceiveBuffer, out segment);
}
// for non-blocking sockets, Receive throws WouldBlock if there is
// no message to read. that's okay. only log for other errors.
catch (SocketException e)
{
// the other end closing the connection is not an 'error'.
// but connections should never just end silently.
// at least log a message for easier debugging.
// for example, his can happen when connecting without a server.
// see test: ConnectWithoutServer().
Log.Info($"[KCP] Client.RawReceive: looks like the other end has closed the connection. This is fine: {e}");
base.Disconnect();
return false;
}
}
// io - output.
// virtual so it may be modified for relays, etc.
protected override void RawSend(ArraySegment<byte> data)
{
// only if socket was connected / created yet.
// users may call send functions without having connected, causing NRE.
if (socket == null) return;
try
{
socket.SendNonBlocking(data);
}
catch (SocketException e)
{
// SendDisconnect() sometimes gets a SocketException with
// 'Connection Refused' if the other end already closed.
// this is not an 'error', it's expected to happen.
// but connections should never just end silently.
// at least log a message for easier debugging.
Log.Info($"[KCP] Client.RawSend: looks like the other end has closed the connection. This is fine: {e}");
// base.Disconnect(); <- don't call this, would deadlock if SendDisconnect() already throws
}
}
public void Send(ArraySegment<byte> segment, KcpChannel channel)
{
if (!connected)
{
Log.Warning("[KCP] Client: can't send because not connected!");
return;
}
SendData(segment, channel);
}
// insert raw IO. usually from socket.Receive.
// offset is useful for relays, where we may parse a header and then
// feed the rest to kcp.
public void RawInput(ArraySegment<byte> segment)
{
// ensure valid size: at least 1 byte for channel + 4 bytes for cookie
if (segment.Count <= 5) return;
// parse channel
// byte channel = segment[0]; ArraySegment[i] isn't supported in some older Unity Mono versions
byte channel = segment.Array[segment.Offset + 0];
// server messages always contain the security cookie.
// parse it, assign if not assigned, warn if suddenly different.
Utils.Decode32U(segment.Array, segment.Offset + 1, out uint messageCookie);
if (messageCookie == 0)
{
Log.Error($"[KCP] Client: received message with cookie=0, this should never happen. Server should always include the security cookie.");
}
if (cookie == 0)
{
cookie = messageCookie;
Log.Info($"[KCP] Client: received initial cookie: {cookie}");
}
else if (cookie != messageCookie)
{
Log.Warning($"[KCP] Client: dropping message with mismatching cookie: {messageCookie} expected: {cookie}.");
return;
}
// parse message
ArraySegment<byte> message = new ArraySegment<byte>(segment.Array, segment.Offset + 1+4, segment.Count - 1-4);
switch (channel)
{
case (byte)KcpChannel.Reliable:
{
OnRawInputReliable(message);
break;
}
case (byte)KcpChannel.Unreliable:
{
OnRawInputUnreliable(message);
break;
}
default:
{
// invalid channel indicates random internet noise.
// servers may receive random UDP data.
// just ignore it, but log for easier debugging.
Log.Warning($"[KCP] Client: invalid channel header: {channel}, likely internet noise");
break;
}
}
}
// process incoming messages. should be called before updating the world.
// virtual because relay may need to inject their own ping or similar.
public override void TickIncoming()
{
// recv on socket first, then process incoming
// (even if we didn't receive anything. need to tick ping etc.)
// (connection is null if not active)
if (active)
{
while (RawReceive(out ArraySegment<byte> segment))
RawInput(segment);
}
// RawReceive may have disconnected peer. active check again.
if (active) base.TickIncoming();
}
// process outgoing messages. should be called after updating the world.
// virtual because relay may need to inject their own ping or similar.
public override void TickOutgoing()
{
// process outgoing while active
if (active) base.TickOutgoing();
}
// process incoming and outgoing for convenience
// => ideally call ProcessIncoming() before updating the world and
// ProcessOutgoing() after updating the world for minimum latency
public virtual void Tick()
{
TickIncoming();
TickOutgoing();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6aa069a28ed24fedb533c102d9742b36
timeCreated: 1603786960

View File

@@ -0,0 +1,97 @@
// common config struct, instead of passing 10 parameters manually every time.
using System;
namespace kcp2k
{
// [Serializable] to show it in Unity inspector.
// 'class' so we can set defaults easily.
[Serializable]
public class KcpConfig
{
// socket configuration ////////////////////////////////////////////////
// DualMode uses both IPv6 and IPv4. not all platforms support it.
// (Nintendo Switch, etc.)
public bool DualMode;
// UDP servers use only one socket.
// maximize buffer to handle as many connections as possible.
//
// M1 mac pro:
// recv buffer default: 786896 (771 KB)
// send buffer default: 9216 (9 KB)
// max configurable: ~7 MB
public int RecvBufferSize;
public int SendBufferSize;
// kcp configuration ///////////////////////////////////////////////////
// configurable MTU in case kcp sits on top of other abstractions like
// encrypted transports, relays, etc.
public int Mtu;
// NoDelay is recommended to reduce latency. This also scales better
// without buffers getting full.
public bool NoDelay;
// KCP internal update interval. 100ms is KCP default, but a lower
// interval is recommended to minimize latency and to scale to more
// networked entities.
public uint Interval;
// KCP fastresend parameter. Faster resend for the cost of higher
// bandwidth.
public int FastResend;
// KCP congestion window heavily limits messages flushed per update.
// congestion window may actually be broken in kcp:
// - sending max sized message @ M1 mac flushes 2-3 messages per update
// - even with super large send/recv window, it requires thousands of
// update calls
// best to leave this disabled, as it may significantly increase latency.
public bool CongestionWindow;
// KCP window size can be modified to support higher loads.
// for example, Mirror Benchmark requires:
// 128, 128 for 4k monsters
// 512, 512 for 10k monsters
// 8192, 8192 for 20k monsters
public uint SendWindowSize;
public uint ReceiveWindowSize;
// timeout in milliseconds
public int Timeout;
// maximum retransmission attempts until dead_link
public uint MaxRetransmits;
// constructor /////////////////////////////////////////////////////////
// constructor with defaults for convenience.
// makes it easy to define "new KcpConfig(DualMode=false)" etc.
public KcpConfig(
bool DualMode = true,
int RecvBufferSize = 1024 * 1024 * 7,
int SendBufferSize = 1024 * 1024 * 7,
int Mtu = Kcp.MTU_DEF,
bool NoDelay = true,
uint Interval = 10,
int FastResend = 0,
bool CongestionWindow = false,
uint SendWindowSize = Kcp.WND_SND,
uint ReceiveWindowSize = Kcp.WND_RCV,
int Timeout = KcpPeer.DEFAULT_TIMEOUT,
uint MaxRetransmits = Kcp.DEADLINK)
{
this.DualMode = DualMode;
this.RecvBufferSize = RecvBufferSize;
this.SendBufferSize = SendBufferSize;
this.Mtu = Mtu;
this.NoDelay = NoDelay;
this.Interval = Interval;
this.FastResend = FastResend;
this.CongestionWindow = CongestionWindow;
this.SendWindowSize = SendWindowSize;
this.ReceiveWindowSize = ReceiveWindowSize;
this.Timeout = Timeout;
this.MaxRetransmits = MaxRetransmits;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 99692f99c45c4b47b0500e7abbfd12da
timeCreated: 1670946969

View File

@@ -0,0 +1,57 @@
using System;
namespace kcp2k
{
// header for messages processed by kcp.
// this is NOT for the raw receive messages(!) because handshake/disconnect
// need to be sent reliably. it's not enough to have those in rawreceive
// because those messages might get lost without being resent!
public enum KcpHeaderReliable : byte
{
// don't react on 0x00. might help to filter out random noise.
Hello = 1,
// ping goes over reliable & KcpHeader for now. could go over unreliable
// too. there is no real difference except that this is easier because
// we already have a KcpHeader for reliable messages.
// ping is only used to keep it alive, so latency doesn't matter.
Ping = 2,
Data = 3,
}
public enum KcpHeaderUnreliable : byte
{
// users may send unreliable messages
Data = 4,
// disconnect always goes through rapid fire unreliable (glenn fielder)
Disconnect = 5,
}
// save convert the enums from/to byte.
// attackers may attempt to send invalid values, so '255' may not convert.
public static class KcpHeader
{
public static bool ParseReliable(byte value, out KcpHeaderReliable header)
{
if (Enum.IsDefined(typeof(KcpHeaderReliable), value))
{
header = (KcpHeaderReliable)value;
return true;
}
header = KcpHeaderReliable.Ping; // any default
return false;
}
public static bool ParseUnreliable(byte value, out KcpHeaderUnreliable header)
{
if (Enum.IsDefined(typeof(KcpHeaderUnreliable), value))
{
header = (KcpHeaderUnreliable)value;
return true;
}
header = KcpHeaderUnreliable.Disconnect; // any default
return false;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 91b5edac31224a49bd76f960ae018942
timeCreated: 1610081248

View File

@@ -0,0 +1,791 @@
// Kcp Peer, similar to UDP Peer but wrapped with reliability, channels,
// timeouts, authentication, state, etc.
//
// still IO agnostic to work with udp, nonalloc, relays, native, etc.
using System;
using System.Diagnostics;
using System.Net.Sockets;
namespace kcp2k
{
public abstract class KcpPeer
{
// kcp reliability algorithm
internal Kcp kcp;
// security cookie to prevent UDP spoofing.
// credits to IncludeSec for disclosing the issue.
//
// server passes the expected cookie to the client's KcpPeer.
// KcpPeer sends cookie to the connected client.
// KcpPeer only accepts packets which contain the cookie.
// => cookie can be a random number, but it needs to be cryptographically
// secure random that can't be easily predicted.
// => cookie can be hash(ip, port) BUT only if salted to be not predictable
internal uint cookie;
// state: connected as soon as we create the peer.
// leftover from KcpConnection. remove it after refactoring later.
protected KcpState state = KcpState.Connected;
// If we don't receive anything these many milliseconds
// then consider us disconnected
public const int DEFAULT_TIMEOUT = 10000;
public int timeout;
uint lastReceiveTime;
// internal time.
// StopWatch offers ElapsedMilliSeconds and should be more precise than
// Unity's time.deltaTime over long periods.
readonly Stopwatch watch = new Stopwatch();
// buffer to receive kcp's processed messages (avoids allocations).
// IMPORTANT: this is for KCP messages. so it needs to be of size:
// 1 byte header + MaxMessageSize content
readonly byte[] kcpMessageBuffer;// = new byte[1 + ReliableMaxMessageSize];
// send buffer for handing user messages to kcp for processing.
// (avoids allocations).
// IMPORTANT: needs to be of size:
// 1 byte header + MaxMessageSize content
readonly byte[] kcpSendBuffer;// = new byte[1 + ReliableMaxMessageSize];
// raw send buffer is exactly MTU.
readonly byte[] rawSendBuffer;
// send a ping occasionally so we don't time out on the other end.
// for example, creating a character in an MMO could easily take a
// minute of no data being sent. which doesn't mean we want to time out.
// same goes for slow paced card games etc.
public const int PING_INTERVAL = 1000;
uint lastPingTime;
// if we send more than kcp can handle, we will get ever growing
// send/recv buffers and queues and minutes of latency.
// => if a connection can't keep up, it should be disconnected instead
// to protect the server under heavy load, and because there is no
// point in growing to gigabytes of memory or minutes of latency!
// => 2k isn't enough. we reach 2k when spawning 4k monsters at once
// easily, but it does recover over time.
// => 10k seems safe.
//
// note: we have a ChokeConnectionAutoDisconnects test for this too!
internal const int QueueDisconnectThreshold = 10000;
// getters for queue and buffer counts, used for debug info
public int SendQueueCount => kcp.snd_queue.Count;
public int ReceiveQueueCount => kcp.rcv_queue.Count;
public int SendBufferCount => kcp.snd_buf.Count;
public int ReceiveBufferCount => kcp.rcv_buf.Count;
// we need to subtract the channel and cookie bytes from every
// MaxMessageSize calculation.
// we also need to tell kcp to use MTU-1 to leave space for the byte.
public const int CHANNEL_HEADER_SIZE = 1;
public const int COOKIE_HEADER_SIZE = 4;
public const int METADATA_SIZE = CHANNEL_HEADER_SIZE + COOKIE_HEADER_SIZE;
// reliable channel (= kcp) MaxMessageSize so the outside knows largest
// allowed message to send. the calculation in Send() is not obvious at
// all, so let's provide the helper here.
//
// kcp does fragmentation, so max message is way larger than MTU.
//
// -> runtime MTU changes are disabled: mss is always MTU_DEF-OVERHEAD
// -> Send() checks if fragment count < rcv_wnd, so we use rcv_wnd - 1.
// NOTE that original kcp has a bug where WND_RCV default is used
// instead of configured rcv_wnd, limiting max message size to 144 KB
// https://github.com/skywind3000/kcp/pull/291
// we fixed this in kcp2k.
// -> we add 1 byte KcpHeader enum to each message, so -1
//
// IMPORTANT: max message is MTU * rcv_wnd, in other words it completely
// fills the receive window! due to head of line blocking,
// all other messages have to wait while a maxed size message
// is being delivered.
// => in other words, DO NOT use max size all the time like
// for batching.
// => sending UNRELIABLE max message size most of the time is
// best for performance (use that one for batching!)
static int ReliableMaxMessageSize_Unconstrained(int mtu, uint rcv_wnd) =>
(mtu - Kcp.OVERHEAD - METADATA_SIZE) * ((int)rcv_wnd - 1) - 1;
// kcp encodes 'frg' as 1 byte.
// max message size can only ever allow up to 255 fragments.
// WND_RCV gives 127 fragments.
// WND_RCV * 2 gives 255 fragments.
// so we can limit max message size by limiting rcv_wnd parameter.
public static int ReliableMaxMessageSize(int mtu, uint rcv_wnd) =>
ReliableMaxMessageSize_Unconstrained(mtu, Math.Min(rcv_wnd, Kcp.FRG_MAX));
// unreliable max message size is simply MTU - channel header - kcp header
public static int UnreliableMaxMessageSize(int mtu) =>
mtu - METADATA_SIZE - 1;
// maximum send rate per second can be calculated from kcp parameters
// source: https://translate.google.com/translate?sl=auto&tl=en&u=https://wetest.qq.com/lab/view/391.html
//
// KCP can send/receive a maximum of WND*MTU per interval.
// multiple by 1000ms / interval to get the per-second rate.
//
// example:
// WND(32) * MTU(1400) = 43.75KB
// => 43.75KB * 1000 / INTERVAL(10) = 4375KB/s
//
// returns bytes/second!
public uint MaxSendRate => kcp.snd_wnd * kcp.mtu * 1000 / kcp.interval;
public uint MaxReceiveRate => kcp.rcv_wnd * kcp.mtu * 1000 / kcp.interval;
// calculate max message sizes based on mtu and wnd only once
public readonly int unreliableMax;
public readonly int reliableMax;
// SetupKcp creates and configures a new KCP instance.
// => useful to start from a fresh state every time the client connects
// => NoDelay, interval, wnd size are the most important configurations.
// let's force require the parameters so we don't forget it anywhere.
protected KcpPeer(KcpConfig config, uint cookie)
{
// initialize variable state in extra function so we can reuse it
// when reconnecting to reset state
Reset(config);
// set the cookie after resetting state so it's not overwritten again.
// with log message for debugging in case of cookie issues.
this.cookie = cookie;
Log.Info($"[KCP] {GetType()}: created with cookie={cookie}");
// create mtu sized send buffer
rawSendBuffer = new byte[config.Mtu];
// calculate max message sizes once
unreliableMax = UnreliableMaxMessageSize(config.Mtu);
reliableMax = ReliableMaxMessageSize(config.Mtu, config.ReceiveWindowSize);
// create message buffers AFTER window size is set
// see comments on buffer definition for the "+1" part
kcpMessageBuffer = new byte[1 + reliableMax];
kcpSendBuffer = new byte[1 + reliableMax];
}
// Reset all state once.
// useful for KcpClient to reconned with a fresh kcp state.
protected void Reset(KcpConfig config)
{
// reset state
cookie = 0;
state = KcpState.Connected;
lastReceiveTime = 0;
lastPingTime = 0;
watch.Restart(); // start at 0 each time
// set up kcp over reliable channel (that's what kcp is for)
kcp = new Kcp(0, RawSendReliable);
// set nodelay.
// note that kcp uses 'nocwnd' internally so we negate the parameter
kcp.SetNoDelay(config.NoDelay ? 1u : 0u, config.Interval, config.FastResend, !config.CongestionWindow);
kcp.SetWindowSize(config.SendWindowSize, config.ReceiveWindowSize);
// IMPORTANT: high level needs to add 1 channel byte to each raw
// message. so while Kcp.MTU_DEF is perfect, we actually need to
// tell kcp to use MTU-1 so we can still put the header into the
// message afterwards.
kcp.SetMtu((uint)config.Mtu - METADATA_SIZE);
// set maximum retransmits (aka dead_link)
kcp.dead_link = config.MaxRetransmits;
timeout = config.Timeout;
}
// callbacks ///////////////////////////////////////////////////////////
// events are abstract, guaranteed to be implemented.
// this ensures they are always initialized when used.
// fixes https://github.com/MirrorNetworking/Mirror/issues/3337 and more
protected abstract void OnAuthenticated();
protected abstract void OnData(ArraySegment<byte> message, KcpChannel channel);
protected abstract void OnDisconnected();
// error callback instead of logging.
// allows libraries to show popups etc.
// (string instead of Exception for ease of use and to avoid user panic)
protected abstract void OnError(ErrorCode error, string message);
protected abstract void RawSend(ArraySegment<byte> data);
////////////////////////////////////////////////////////////////////////
void HandleTimeout(uint time)
{
// note: we are also sending a ping regularly, so timeout should
// only ever happen if the connection is truly gone.
if (time >= lastReceiveTime + timeout)
{
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.Timeout, $"{GetType()}: Connection timed out after not receiving any message for {timeout}ms. Disconnecting.");
Disconnect();
}
}
void HandleDeadLink()
{
// kcp has 'dead_link' detection. might as well use it.
if (kcp.state == -1)
{
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.Timeout, $"{GetType()}: dead_link detected: a message was retransmitted {kcp.dead_link} times without ack. Disconnecting.");
Disconnect();
}
}
// send a ping occasionally in order to not time out on the other end.
void HandlePing(uint time)
{
// enough time elapsed since last ping?
if (time >= lastPingTime + PING_INTERVAL)
{
// ping again and reset time
//Log.Debug("[KCP] sending ping...");
SendPing();
lastPingTime = time;
}
}
void HandleChoked()
{
// disconnect connections that can't process the load.
// see QueueSizeDisconnect comments.
// => include all of kcp's buffers and the unreliable queue!
int total = kcp.rcv_queue.Count + kcp.snd_queue.Count +
kcp.rcv_buf.Count + kcp.snd_buf.Count;
if (total >= QueueDisconnectThreshold)
{
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.Congestion,
$"{GetType()}: disconnecting connection because it can't process data fast enough.\n" +
$"Queue total {total}>{QueueDisconnectThreshold}. rcv_queue={kcp.rcv_queue.Count} snd_queue={kcp.snd_queue.Count} rcv_buf={kcp.rcv_buf.Count} snd_buf={kcp.snd_buf.Count}\n" +
$"* Try to Enable NoDelay, decrease INTERVAL, disable Congestion Window (= enable NOCWND!), increase SEND/RECV WINDOW or compress data.\n" +
$"* Or perhaps the network is simply too slow on our end, or on the other end.");
// let's clear all pending sends before disconnting with 'Bye'.
// otherwise a single Flush in Disconnect() won't be enough to
// flush thousands of messages to finally deliver 'Bye'.
// this is just faster and more robust.
kcp.snd_queue.Clear();
Disconnect();
}
}
// reads the next reliable message type & content from kcp.
// -> to avoid buffering, unreliable messages call OnData directly.
bool ReceiveNextReliable(out KcpHeaderReliable header, out ArraySegment<byte> message)
{
message = default;
header = KcpHeaderReliable.Ping;
int msgSize = kcp.PeekSize();
if (msgSize <= 0) return false;
// only allow receiving up to buffer sized messages.
// otherwise we would get BlockCopy ArgumentException anyway.
if (msgSize > kcpMessageBuffer.Length)
{
// we don't allow sending messages > Max, so this must be an
// attacker. let's disconnect to avoid allocation attacks etc.
// pass error to user callback. no need to log it manually.
OnError(ErrorCode.InvalidReceive, $"{GetType()}: possible allocation attack for msgSize {msgSize} > buffer {kcpMessageBuffer.Length}. Disconnecting the connection.");
Disconnect();
return false;
}
// receive from kcp
int received = kcp.Receive(kcpMessageBuffer, msgSize);
if (received < 0)
{
// if receive failed, close everything
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidReceive, $"{GetType()}: Receive failed with error={received}. closing connection.");
Disconnect();
return false;
}
// safely extract header. attackers may send values out of enum range.
byte headerByte = kcpMessageBuffer[0];
if (!KcpHeader.ParseReliable(headerByte, out header))
{
OnError(ErrorCode.InvalidReceive, $"{GetType()}: Receive failed to parse header: {headerByte} is not defined in {typeof(KcpHeaderReliable)}.");
Disconnect();
return false;
}
// extract content without header
message = new ArraySegment<byte>(kcpMessageBuffer, 1, msgSize - 1);
lastReceiveTime = (uint)watch.ElapsedMilliseconds;
return true;
}
void TickIncoming_Connected(uint time)
{
// detect common events & ping
HandleTimeout(time);
HandleDeadLink();
HandlePing(time);
HandleChoked();
// any reliable kcp message received?
if (ReceiveNextReliable(out KcpHeaderReliable header, out ArraySegment<byte> message))
{
// message type FSM. no default so we never miss a case.
switch (header)
{
case KcpHeaderReliable.Hello:
{
// we were waiting for a Hello message.
// it proves that the other end speaks our protocol.
// log with previously parsed cookie
Log.Info($"[KCP] {GetType()}: received hello with cookie={cookie}");
state = KcpState.Authenticated;
OnAuthenticated();
break;
}
case KcpHeaderReliable.Ping:
{
// ping keeps kcp from timing out. do nothing.
break;
}
case KcpHeaderReliable.Data:
{
// everything else is not allowed during handshake!
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidReceive, $"[KCP] {GetType()}: received invalid header {header} while Connected. Disconnecting the connection.");
Disconnect();
break;
}
}
}
}
void TickIncoming_Authenticated(uint time)
{
// detect common events & ping
HandleTimeout(time);
HandleDeadLink();
HandlePing(time);
HandleChoked();
// process all received messages
while (ReceiveNextReliable(out KcpHeaderReliable header, out ArraySegment<byte> message))
{
// message type FSM. no default so we never miss a case.
switch (header)
{
case KcpHeaderReliable.Hello:
{
// should never receive another hello after auth
// GetType() shows Server/ClientConn instead of just Connection.
Log.Warning($"{GetType()}: received invalid header {header} while Authenticated. Disconnecting the connection.");
Disconnect();
break;
}
case KcpHeaderReliable.Data:
{
// call OnData IF the message contained actual data
if (message.Count > 0)
{
//Log.Warning($"Kcp recv msg: {BitConverter.ToString(message.Array, message.Offset, message.Count)}");
OnData(message, KcpChannel.Reliable);
}
// empty data = attacker, or something went wrong
else
{
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidReceive, $"{GetType()}: received empty Data message while Authenticated. Disconnecting the connection.");
Disconnect();
}
break;
}
case KcpHeaderReliable.Ping:
{
// ping keeps kcp from timing out. do nothing.
break;
}
}
}
}
public virtual void TickIncoming()
{
uint time = (uint)watch.ElapsedMilliseconds;
try
{
switch (state)
{
case KcpState.Connected:
{
TickIncoming_Connected(time);
break;
}
case KcpState.Authenticated:
{
TickIncoming_Authenticated(time);
break;
}
case KcpState.Disconnected:
{
// do nothing while disconnected
break;
}
}
}
// TODO KcpConnection is IO agnostic. move this to outside later.
catch (SocketException exception)
{
// this is ok, the connection was closed
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.ConnectionClosed, $"{GetType()}: Disconnecting because {exception}. This is fine.");
Disconnect();
}
catch (ObjectDisposedException exception)
{
// fine, socket was closed
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.ConnectionClosed, $"{GetType()}: Disconnecting because {exception}. This is fine.");
Disconnect();
}
catch (Exception exception)
{
// unexpected
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.Unexpected, $"{GetType()}: unexpected Exception: {exception}");
Disconnect();
}
}
public virtual void TickOutgoing()
{
uint time = (uint)watch.ElapsedMilliseconds;
try
{
switch (state)
{
case KcpState.Connected:
case KcpState.Authenticated:
{
// update flushes out messages
kcp.Update(time);
break;
}
case KcpState.Disconnected:
{
// do nothing while disconnected
break;
}
}
}
// TODO KcpConnection is IO agnostic. move this to outside later.
catch (SocketException exception)
{
// this is ok, the connection was closed
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.ConnectionClosed, $"{GetType()}: Disconnecting because {exception}. This is fine.");
Disconnect();
}
catch (ObjectDisposedException exception)
{
// fine, socket was closed
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.ConnectionClosed, $"{GetType()}: Disconnecting because {exception}. This is fine.");
Disconnect();
}
catch (Exception exception)
{
// unexpected
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.Unexpected, $"{GetType()}: unexpected exception: {exception}");
Disconnect();
}
}
protected void OnRawInputReliable(ArraySegment<byte> message)
{
// input into kcp, but skip channel byte
int input = kcp.Input(message.Array, message.Offset, message.Count);
if (input != 0)
{
// GetType() shows Server/ClientConn instead of just Connection.
Log.Warning($"[KCP] {GetType()}: Input failed with error={input} for buffer with length={message.Count - 1}");
}
}
protected void OnRawInputUnreliable(ArraySegment<byte> message)
{
// need at least one byte for the KcpHeader enum
if (message.Count < 1) return;
// safely extract header. attackers may send values out of enum range.
byte headerByte = message.Array[message.Offset + 0];
if (!KcpHeader.ParseUnreliable(headerByte, out KcpHeaderUnreliable header))
{
OnError(ErrorCode.InvalidReceive, $"{GetType()}: Receive failed to parse header: {headerByte} is not defined in {typeof(KcpHeaderUnreliable)}.");
Disconnect();
return;
}
// subtract header from message content
// (above we already ensure it's at least 1 byte long)
message = new ArraySegment<byte>(message.Array, message.Offset + 1, message.Count - 1);
switch (header)
{
case KcpHeaderUnreliable.Data:
{
// ideally we would queue all unreliable messages and
// then process them in ReceiveNext() together with the
// reliable messages, but:
// -> queues/allocations/pools are slow and complex.
// -> DOTSNET 10k is actually slower if we use pooled
// unreliable messages for transform messages.
//
// DOTSNET 10k benchmark:
// reliable-only: 170 FPS
// unreliable queued: 130-150 FPS
// unreliable direct: 183 FPS(!)
//
// DOTSNET 50k benchmark:
// reliable-only: FAILS (queues keep growing)
// unreliable direct: 18-22 FPS(!)
//
// -> all unreliable messages are DATA messages anyway.
// -> let's skip the magic and call OnData directly if
// the current state allows it.
if (state == KcpState.Authenticated)
{
OnData(message, KcpChannel.Unreliable);
// set last receive time to avoid timeout.
// -> we do this in ANY case even if not enabled.
// a message is a message.
// -> we set last receive time for both reliable and
// unreliable messages. both count.
// otherwise a connection might time out even
// though unreliable were received, but no
// reliable was received.
lastReceiveTime = (uint)watch.ElapsedMilliseconds;
}
else
{
// it's common to receive unreliable messages before being
// authenticated, for example:
// - random internet noise
// - game server may send an unreliable message after authenticating,
// and the unreliable message arrives on the client before the
// 'auth_ok' message. this can be avoided by sending a final
// 'ready' message after being authenticated, but this would
// add another 'round trip time' of latency to the handshake.
//
// it's best to simply ignore invalid unreliable messages here.
// Log.Info($"{GetType()}: received unreliable message while not authenticated.");
}
break;
}
case KcpHeaderUnreliable.Disconnect:
{
// GetType() shows Server/ClientConn instead of just Connection.
Log.Info($"[KCP] {GetType()}: received disconnect message");
Disconnect();
break;
}
}
}
// raw send called by kcp
void RawSendReliable(byte[] data, int length)
{
// write channel header
// from 0, with 1 byte
rawSendBuffer[0] = (byte)KcpChannel.Reliable;
// write handshake cookie to protect against UDP spoofing.
// from 1, with 4 bytes
Utils.Encode32U(rawSendBuffer, 1, cookie); // allocation free
// write data
// from 5, with N bytes
Buffer.BlockCopy(data, 0, rawSendBuffer, 1+4, length);
// IO send
ArraySegment<byte> segment = new ArraySegment<byte>(rawSendBuffer, 0, length + 1+4);
RawSend(segment);
}
void SendReliable(KcpHeaderReliable header, ArraySegment<byte> content)
{
// 1 byte header + content needs to fit into send buffer
if (1 + content.Count > kcpSendBuffer.Length) // TODO
{
// otherwise content is larger than MaxMessageSize. let user know!
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidSend, $"{GetType()}: Failed to send reliable message of size {content.Count} because it's larger than ReliableMaxMessageSize={reliableMax}");
return;
}
// write channel header
kcpSendBuffer[0] = (byte)header;
// write data (if any)
if (content.Count > 0)
Buffer.BlockCopy(content.Array, content.Offset, kcpSendBuffer, 1, content.Count);
// send to kcp for processing
int sent = kcp.Send(kcpSendBuffer, 0, 1 + content.Count);
if (sent < 0)
{
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidSend, $"{GetType()}: Send failed with error={sent} for content with length={content.Count}");
}
}
void SendUnreliable(KcpHeaderUnreliable header, ArraySegment<byte> content)
{
// message size needs to be <= unreliable max size
if (content.Count > unreliableMax)
{
// otherwise content is larger than MaxMessageSize. let user know!
// GetType() shows Server/ClientConn instead of just Connection.
Log.Error($"[KCP] {GetType()}: Failed to send unreliable message of size {content.Count} because it's larger than UnreliableMaxMessageSize={unreliableMax}");
return;
}
// write channel header
// from 0, with 1 byte
rawSendBuffer[0] = (byte)KcpChannel.Unreliable;
// write handshake cookie to protect against UDP spoofing.
// from 1, with 4 bytes
Utils.Encode32U(rawSendBuffer, 1, cookie); // allocation free
// write kcp header
rawSendBuffer[5] = (byte)header;
// write data (if any)
// from 6, with N bytes
if (content.Count > 0)
Buffer.BlockCopy(content.Array, content.Offset, rawSendBuffer, 1 + 4 + 1, content.Count);
// IO send
ArraySegment<byte> segment = new ArraySegment<byte>(rawSendBuffer, 0, content.Count + 1 + 4 + 1);
RawSend(segment);
}
// server & client need to send handshake at different times, so we need
// to expose the function.
// * client should send it immediately.
// * server should send it as reply to client's handshake, not before
// (server should not reply to random internet messages with handshake)
// => handshake info needs to be delivered, so it goes over reliable.
public void SendHello()
{
// send an empty message with 'Hello' header.
// cookie is automatically included in all messages.
// GetType() shows Server/ClientConn instead of just Connection.
Log.Info($"[KCP] {GetType()}: sending handshake to other end with cookie={cookie}");
SendReliable(KcpHeaderReliable.Hello, default);
}
public void SendData(ArraySegment<byte> data, KcpChannel channel)
{
// sending empty segments is not allowed.
// nobody should ever try to send empty data.
// it means that something went wrong, e.g. in Mirror/DOTSNET.
// let's make it obvious so it's easy to debug.
if (data.Count == 0)
{
// pass error to user callback. no need to log it manually.
// GetType() shows Server/ClientConn instead of just Connection.
OnError(ErrorCode.InvalidSend, $"{GetType()}: tried sending empty message. This should never happen. Disconnecting.");
Disconnect();
return;
}
switch (channel)
{
case KcpChannel.Reliable:
SendReliable(KcpHeaderReliable.Data, data);
break;
case KcpChannel.Unreliable:
SendUnreliable(KcpHeaderUnreliable.Data, data);
break;
}
}
// ping goes through kcp to keep it from timing out, so it goes over the
// reliable channel.
void SendPing() => SendReliable(KcpHeaderReliable.Ping, default);
// send disconnect message
void SendDisconnect()
{
// sending over reliable to ensure delivery seems like a good idea:
// but if we close the connection immediately, it often doesn't get
// fully delivered: https://github.com/MirrorNetworking/Mirror/issues/3591
// SendReliable(KcpHeader.Disconnect, default);
//
// instead, rapid fire a few unreliable messages.
// they are sent immediately even if we close the connection after.
// this way we don't need to keep the connection alive for a while.
// (glenn fiedler method)
for (int i = 0; i < 5; ++i)
SendUnreliable(KcpHeaderUnreliable.Disconnect, default);
}
// disconnect this connection
public virtual void Disconnect()
{
// only if not disconnected yet
if (state == KcpState.Disconnected)
return;
// send a disconnect message
try
{
SendDisconnect();
}
// TODO KcpConnection is IO agnostic. move this to outside later.
catch (SocketException)
{
// this is ok, the connection was already closed
}
catch (ObjectDisposedException)
{
// this is normal when we stop the server
// the socket is stopped so we can't send anything anymore
// to the clients
// the clients will eventually timeout and realize they
// were disconnected
}
// set as Disconnected, call event
// GetType() shows Server/ClientConn instead of just Connection.
Log.Info($"[KCP] {GetType()}: Disconnected.");
state = KcpState.Disconnected;
OnDisconnected();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3915c7c62b72d4dc2a9e4e76c94fc484
timeCreated: 1602600432

View File

@@ -0,0 +1,412 @@
// kcp server logic abstracted into a class.
// for use in Mirror, DOTSNET, testing, etc.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
namespace kcp2k
{
public class KcpServer
{
// callbacks
// even for errors, to allow liraries to show popups etc.
// instead of logging directly.
// (string instead of Exception for ease of use and to avoid user panic)
//
// events are readonly, set in constructor.
// this ensures they are always initialized when used.
// fixes https://github.com/MirrorNetworking/Mirror/issues/3337 and more
protected readonly Action<int, IPEndPoint> OnConnected; // connectionId, address
protected readonly Action<int, ArraySegment<byte>, KcpChannel> OnData;
protected readonly Action<int> OnDisconnected;
protected readonly Action<int, ErrorCode, string> OnError;
// configuration
protected readonly KcpConfig config;
// state
protected Socket socket;
EndPoint newClientEP;
// expose local endpoint for users / relays / nat traversal etc.
public EndPoint LocalEndPoint => socket?.LocalEndPoint;
// raw receive buffer always needs to be of 'MTU' size, even if
// MaxMessageSize is larger. kcp always sends in MTU segments and having
// a buffer smaller than MTU would silently drop excess data.
// => we need the mtu to fit channel + message!
protected readonly byte[] rawReceiveBuffer;
// connections <connectionId, connection> where connectionId is EndPoint.GetHashCode
public Dictionary<int, KcpServerConnection> connections =
new Dictionary<int, KcpServerConnection>();
public KcpServer(Action<int, IPEndPoint> OnConnected,
Action<int, ArraySegment<byte>, KcpChannel> OnData,
Action<int> OnDisconnected,
Action<int, ErrorCode, string> OnError,
KcpConfig config)
{
// initialize callbacks first to ensure they can be used safely.
this.OnConnected = OnConnected;
this.OnData = OnData;
this.OnDisconnected = OnDisconnected;
this.OnError = OnError;
this.config = config;
// create mtu sized receive buffer
rawReceiveBuffer = new byte[config.Mtu];
// create newClientEP either IPv4 or IPv6
newClientEP = config.DualMode
? new IPEndPoint(IPAddress.IPv6Any, 0)
: new IPEndPoint(IPAddress.Any, 0);
}
public virtual bool IsActive() => socket != null;
static Socket CreateServerSocket(bool DualMode, ushort port)
{
if (DualMode)
{
// IPv6 socket with DualMode @ "::" : port
Socket socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Dgram, ProtocolType.Udp);
// enabling DualMode may throw:
// https://learn.microsoft.com/en-us/dotnet/api/System.Net.Sockets.Socket.DualMode?view=net-7.0
// attempt it, otherwise log but continue
// fixes: https://github.com/MirrorNetworking/Mirror/issues/3358
try
{
socket.DualMode = true;
}
catch (NotSupportedException e)
{
Log.Warning($"[KCP] Failed to set Dual Mode, continuing with IPv6 without Dual Mode. Error: {e}");
}
// for windows sockets, there's a rare issue where when using
// a server socket with multiple clients, if one of the clients
// is closed, the single server socket throws exceptions when
// sending/receiving. even if the socket is made for N clients.
//
// this actually happened to one of our users:
// https://github.com/MirrorNetworking/Mirror/issues/3611
//
// here's the in-depth explanation & solution:
//
// "As you may be aware, if a host receives a packet for a UDP
// port that is not currently bound, it may send back an ICMP
// "Port Unreachable" message. Whether or not it does this is
// dependent on the firewall, private/public settings, etc.
// On localhost, however, it will pretty much always send this
// packet back.
//
// Now, on Windows (and only on Windows), by default, a received
// ICMP Port Unreachable message will close the UDP socket that
// sent it; hence, the next time you try to receive on the
// socket, it will throw an exception because the socket has
// been closed by the OS.
//
// Obviously, this causes a headache in the multi-client,
// single-server socket set-up you have here, but luckily there
// is a fix:
//
// You need to utilise the not-often-required SIO_UDP_CONNRESET
// Winsock control code, which turns off this built-in behaviour
// of automatically closing the socket.
//
// Note that this ioctl code is only supported on Windows
// (XP and later), not on Linux, since it is provided by the
// Winsock extensions. Of course, since the described behavior
// is only the default behavior on Windows, this omission is not
// a major loss. If you are attempting to create a
// cross-platform library, you should cordon this off as
// Windows-specific code."
// https://stackoverflow.com/questions/74327225/why-does-sending-via-a-udpclient-cause-subsequent-receiving-to-fail
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
const uint IOC_IN = 0x80000000U;
const uint IOC_VENDOR = 0x18000000U;
const int SIO_UDP_CONNRESET = unchecked((int)(IOC_IN | IOC_VENDOR | 12));
socket.IOControl(SIO_UDP_CONNRESET, new byte[] { 0x00 }, null);
}
socket.Bind(new IPEndPoint(IPAddress.IPv6Any, port));
return socket;
}
else
{
// IPv4 socket @ "0.0.0.0" : port
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Any, port));
return socket;
}
}
public virtual void Start(ushort port)
{
// only start once
if (socket != null)
{
Log.Warning("[KCP] Server: already started!");
return;
}
// listen
socket = CreateServerSocket(config.DualMode, port);
// recv & send are called from main thread.
// need to ensure this never blocks.
// even a 1ms block per connection would stop us from scaling.
socket.Blocking = false;
// configure buffer sizes
Common.ConfigureSocketBuffers(socket, config.RecvBufferSize, config.SendBufferSize);
}
public void Send(int connectionId, ArraySegment<byte> segment, KcpChannel channel)
{
if (connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
connection.SendData(segment, channel);
}
}
public void Disconnect(int connectionId)
{
if (connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
connection.Disconnect();
}
}
// expose the whole IPEndPoint, not just the IP address. some need it.
public IPEndPoint GetClientEndPoint(int connectionId)
{
if (connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
return connection.remoteEndPoint as IPEndPoint;
}
return null;
}
// io - input.
// virtual so it may be modified for relays, nonalloc workaround, etc.
// https://github.com/vis2k/where-allocation
// bool return because not all receives may be valid.
// for example, relay may expect a certain header.
protected virtual bool RawReceiveFrom(out ArraySegment<byte> segment, out int connectionId)
{
segment = default;
connectionId = 0;
if (socket == null) return false;
try
{
if (socket.ReceiveFromNonBlocking(rawReceiveBuffer, out segment, ref newClientEP))
{
// set connectionId to hash from endpoint
connectionId = Common.ConnectionHash(newClientEP);
return true;
}
}
catch (SocketException e)
{
// NOTE: SocketException is not a subclass of IOException.
// the other end closing the connection is not an 'error'.
// but connections should never just end silently.
// at least log a message for easier debugging.
Log.Info($"[KCP] Server: ReceiveFrom failed: {e}");
}
return false;
}
// io - out.
// virtual so it may be modified for relays, nonalloc workaround, etc.
// relays may need to prefix connId (and remoteEndPoint would be same for all)
protected virtual void RawSend(int connectionId, ArraySegment<byte> data)
{
// get the connection's endpoint
if (!connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
Log.Warning($"[KCP] Server: RawSend invalid connectionId={connectionId}");
return;
}
try
{
socket.SendToNonBlocking(data, connection.remoteEndPoint);
}
catch (SocketException e)
{
Log.Error($"[KCP] Server: SendTo failed: {e}");
}
}
protected virtual KcpServerConnection CreateConnection(int connectionId)
{
// generate a random cookie for this connection to avoid UDP spoofing.
// needs to be random, but without allocations to avoid GC.
uint cookie = Common.GenerateCookie();
// create empty connection without peer first.
// we need it to set up peer callbacks.
// afterwards we assign the peer.
// events need to be wrapped with connectionIds
KcpServerConnection connection = new KcpServerConnection(
OnConnectedCallback,
(message, channel) => OnData(connectionId, message, channel),
OnDisconnectedCallback,
(error, reason) => OnError(connectionId, error, reason),
(data) => RawSend(connectionId, data),
config,
cookie,
newClientEP);
return connection;
// setup authenticated event that also adds to connections
void OnConnectedCallback(KcpServerConnection conn)
{
// add to connections dict after being authenticated.
connections.Add(connectionId, conn);
Log.Info($"[KCP] Server: added connection({connectionId})");
// setup Data + Disconnected events only AFTER the
// handshake. we don't want to fire OnServerDisconnected
// every time we receive invalid random data from the
// internet.
// setup data event
// finally, call mirror OnConnected event
Log.Info($"[KCP] Server: OnConnected({connectionId})");
IPEndPoint endPoint = conn.remoteEndPoint as IPEndPoint;
OnConnected(connectionId, endPoint);
}
void OnDisconnectedCallback()
{
// flag for removal
// (can't remove directly because connection is updated
// and event is called while iterating all connections)
connectionsToRemove.Add(connectionId);
// call mirror event
Log.Info($"[KCP] Server: OnDisconnected({connectionId})");
OnDisconnected(connectionId);
}
}
// receive + add + process once.
// best to call this as long as there is more data to receive.
void ProcessMessage(ArraySegment<byte> segment, int connectionId)
{
//Log.Info($"[KCP] server raw recv {msgLength} bytes = {BitConverter.ToString(buffer, 0, msgLength)}");
// is this a new connection?
if (!connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
// create a new KcpConnection based on last received
// EndPoint. can be overwritten for where-allocation.
connection = CreateConnection(connectionId);
// DO NOT add to connections yet. only if the first message
// is actually the kcp handshake. otherwise it's either:
// * random data from the internet
// * or from a client connection that we just disconnected
// but that hasn't realized it yet, still sending data
// from last session that we should absolutely ignore.
//
//
// TODO this allocates a new KcpConnection for each new
// internet connection. not ideal, but C# UDP Receive
// already allocated anyway.
//
// expecting a MAGIC byte[] would work, but sending the raw
// UDP message without kcp's reliability will have low
// probability of being received.
//
// for now, this is fine.
// now input the message & process received ones
// connected event was set up.
// tick will process the first message and adds the
// connection if it was the handshake.
connection.RawInput(segment);
connection.TickIncoming();
// again, do not add to connections.
// if the first message wasn't the kcp handshake then
// connection will simply be garbage collected.
}
// existing connection: simply input the message into kcp
else
{
connection.RawInput(segment);
}
}
// process incoming messages. should be called before updating the world.
// virtual because relay may need to inject their own ping or similar.
readonly HashSet<int> connectionsToRemove = new HashSet<int>();
public virtual void TickIncoming()
{
// input all received messages into kcp
while (RawReceiveFrom(out ArraySegment<byte> segment, out int connectionId))
{
ProcessMessage(segment, connectionId);
}
// process inputs for all server connections
// (even if we didn't receive anything. need to tick ping etc.)
foreach (KcpServerConnection connection in connections.Values)
{
connection.TickIncoming();
}
// remove disconnected connections
// (can't do it in connection.OnDisconnected because Tick is called
// while iterating connections)
foreach (int connectionId in connectionsToRemove)
{
connections.Remove(connectionId);
}
connectionsToRemove.Clear();
}
// process outgoing messages. should be called after updating the world.
// virtual because relay may need to inject their own ping or similar.
public virtual void TickOutgoing()
{
// flush all server connections
foreach (KcpServerConnection connection in connections.Values)
{
connection.TickOutgoing();
}
}
// process incoming and outgoing for convenience.
// => ideally call ProcessIncoming() before updating the world and
// ProcessOutgoing() after updating the world for minimum latency
public virtual void Tick()
{
TickIncoming();
TickOutgoing();
}
public virtual void Stop()
{
// need to clear connections, otherwise they are in next session.
// fixes https://github.com/vis2k/kcp2k/pull/47
connections.Clear();
socket?.Close();
socket = null;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9759159c6589494a9037f5e130a867ed
timeCreated: 1603787747

View File

@@ -0,0 +1,126 @@
// server needs to store a separate KcpPeer for each connection.
// as well as remoteEndPoint so we know where to send data to.
using System;
using System.Net;
namespace kcp2k
{
public class KcpServerConnection : KcpPeer
{
public readonly EndPoint remoteEndPoint;
// callbacks
// even for errors, to allow liraries to show popups etc.
// instead of logging directly.
// (string instead of Exception for ease of use and to avoid user panic)
//
// events are readonly, set in constructor.
// this ensures they are always initialized when used.
// fixes https://github.com/MirrorNetworking/Mirror/issues/3337 and more
protected readonly Action<KcpServerConnection> OnConnectedCallback;
protected readonly Action<ArraySegment<byte>, KcpChannel> OnDataCallback;
protected readonly Action OnDisconnectedCallback;
protected readonly Action<ErrorCode, string> OnErrorCallback;
protected readonly Action<ArraySegment<byte>> RawSendCallback;
public KcpServerConnection(
Action<KcpServerConnection> OnConnected,
Action<ArraySegment<byte>, KcpChannel> OnData,
Action OnDisconnected,
Action<ErrorCode, string> OnError,
Action<ArraySegment<byte>> OnRawSend,
KcpConfig config,
uint cookie,
EndPoint remoteEndPoint)
: base(config, cookie)
{
OnConnectedCallback = OnConnected;
OnDataCallback = OnData;
OnDisconnectedCallback = OnDisconnected;
OnErrorCallback = OnError;
RawSendCallback = OnRawSend;
this.remoteEndPoint = remoteEndPoint;
}
// callbacks ///////////////////////////////////////////////////////////
protected override void OnAuthenticated()
{
// once we receive the first client hello,
// immediately reply with hello so the client knows the security cookie.
SendHello();
OnConnectedCallback(this);
}
protected override void OnData(ArraySegment<byte> message, KcpChannel channel) =>
OnDataCallback(message, channel);
protected override void OnDisconnected() =>
OnDisconnectedCallback();
protected override void OnError(ErrorCode error, string message) =>
OnErrorCallback(error, message);
protected override void RawSend(ArraySegment<byte> data) =>
RawSendCallback(data);
////////////////////////////////////////////////////////////////////////
// insert raw IO. usually from socket.Receive.
// offset is useful for relays, where we may parse a header and then
// feed the rest to kcp.
public void RawInput(ArraySegment<byte> segment)
{
// ensure valid size: at least 1 byte for channel + 4 bytes for cookie
if (segment.Count <= 5) return;
// parse channel
// byte channel = segment[0]; ArraySegment[i] isn't supported in some older Unity Mono versions
byte channel = segment.Array[segment.Offset + 0];
// all server->client messages include the server's security cookie.
// all client->server messages except for the initial 'hello' include it too.
// parse the cookie and make sure it matches (except for initial hello).
Utils.Decode32U(segment.Array, segment.Offset + 1, out uint messageCookie);
// security: messages after authentication are expected to contain the cookie.
// this protects against UDP spoofing.
// simply drop the message if the cookie doesn't match.
if (state == KcpState.Authenticated)
{
if (messageCookie != cookie)
{
// Info is enough, don't scare users.
// => this can happen for malicious messages
// => it can also happen if client's Hello message was retransmitted multiple times, which is totally normal.
Log.Info($"[KCP] ServerConnection: dropped message with invalid cookie: {messageCookie} from {remoteEndPoint} expected: {cookie} state: {state}. This can happen if the client's Hello message was transmitted multiple times, or if an attacker attempted UDP spoofing.");
return;
}
}
// parse message
ArraySegment<byte> message = new ArraySegment<byte>(segment.Array, segment.Offset + 1+4, segment.Count - 1-4);
switch (channel)
{
case (byte)KcpChannel.Reliable:
{
OnRawInputReliable(message);
break;
}
case (byte)KcpChannel.Unreliable:
{
OnRawInputUnreliable(message);
break;
}
default:
{
// invalid channel indicates random internet noise.
// servers may receive random UDP data.
// just ignore it, but log for easier debugging.
Log.Warning($"[KCP] ServerConnection: invalid channel header: {channel} from {remoteEndPoint}, likely internet noise");
break;
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 80a9b1ce9a6f14abeb32bfa9921d097b
timeCreated: 1602601483

View File

@@ -0,0 +1,4 @@
namespace kcp2k
{
public enum KcpState { Connected, Authenticated, Disconnected }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 81a02c141a88d45d4a2f5ef68c6da75f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More