Letsencrypt certificate do certbot sinh ra có thời hạn 90 ngày và được tự động tạo mới sau 60 ngày. Bài viết này thảo luận cách EzyTank tự động cập nhật socket SSL khi Letsencrypt certificate được tạo mới.

1. Lưu đường dẫn đến Full Chain file và Private Key file của Letsencrypt certificate

Trong "Settings" menu của game-master-server plugin ở admin, ta có thể thấy đường dẫn mặc định đến Full Chain file và Private Key file. Admin có thể thay đổi đường dẫn này nếu cần thiết.
Screenshot from 2026-01-07 17-45-16.png

2. Appender ở plugin game-master-server theo dõi Letsencrypt certificate

AdminSocketSslKeystoreReloadAppender theo dõi full chain file, nếu có thay đổi nó sẽ cập thật last modifed date time của file này vào settings:
package org.youngmonkeys.masterserver.admin.appender;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.tvd12.ezyfox.bean.annotation.EzySingleton;
import org.youngmonkeys.ezyplatform.admin.appender.AdminDataAppender;
import org.youngmonkeys.ezyplatform.admin.service.AdminSettingService;
import org.youngmonkeys.masterserver.admin.service.GameMasterServerSettingService;

import java.io.File;
import java.util.List;

@EzySingleton
public class AdminSocketSslKeystoreReloadAppender
    extends AdminDataAppender<Void, Void, Void> {

    private final GameMasterServerSettingService masterServerSettingService;

    public AdminSocketSslKeystoreReloadAppender(
        ObjectMapper mapper,
        AdminSettingService settingService,
        GameMasterServerSettingService masterServerSettingService
    ) {
        super(mapper, settingService);
        this.masterServerSettingService = masterServerSettingService;
    }

    @Override
    protected boolean defaultStarted() {
        return true;
    }

    @Override
    protected void doAppend() {
        File cert = new File(masterServerSettingService.getFullChainPath());
        if (!cert.exists()) {
            return;
        }
        long currentLastModified = cert.lastModified();
        long previousLastModified = masterServerSettingService.getFullChainLastModified();
        if (currentLastModified > previousLastModified) {
            logger.info("Cert file has been updated");
            masterServerSettingService.saveFullChainLastModified(currentLastModified);
        }
    }

    @Override
    protected String getAppenderNamePrefix() {
        return "socket_ssl_keystore_reload_appender";
    }

    @Override
    protected Void defaultPageToken() {
        throw new UnsupportedOperationException();
    }

    @Override
    protected Class<Void> pageTokenType() {
        throw new UnsupportedOperationException();
    }

    @Override
    protected Void extractNewLastPageToken(List<Void> v, Void p) {
        throw new UnsupportedOperationException();
    }

    @Override
    protected List<Void> getValueList(Void p) {
        throw new UnsupportedOperationException();
    }
}

2. API /api/v1/instances/{port} trả về thông tin certificate khi có thay đổi

API endpoint này nhận vào activeFullChainLastModified parameter từ client gửi lên, so sánh với giá trị tương ứng đã lưu trong settings để xác định có cần trả về thông tin certificate cho người dùng hay không:
@DoGet("/{port}")
    public AdminInstanceResponse portGet(
        @PathVariable int port,
        @RequestParam(value = "apiKey") String apiKey,
        @RequestParam(
            value = "activeFullChainLastModified",
            defaultValue = "0"
        ) long activeFullChainLastModified
    ) {
        apiKeyValidator.validate(apiKey);
        return instanceControllerService.getInstance(
            port,
            activeFullChainLastModified
        );
    }
package org.youngmonkeys.masterserver.admin.response;

import lombok.Getter;
import lombok.experimental.SuperBuilder;
import org.youngmonkeys.masterserver.response.InstanceResponse;

@Getter
@SuperBuilder
public class AdminInstanceResponse extends InstanceResponse {
    private int playerCount;
    private long createdAt;
    private long updatedAt;
    private long sslFullChainLastModified;
    private String sslFullChain;                             // null nếu không có thay đổi gì
    private String sslPrivateKey;
}

3. Unity client tự động restart khi nhận về thông tin certificate mới

  • Trong "ServerEntryScene", ServerNetworkStreamDriverEntryPresenter chỉ chuyển qua "ServerLoungeScene" khi có thông tin về SSL certificate
using System;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace Youngmonkeys.EzyTank
{
	public class ServerNetworkStreamDriverEntryPresenter : AbstractNetworkStreamDriverEntry<ServerNetworkStreamDriverEntryPresenter>
	{
		public void Start()
		{
			if (InstanceManager.Instance.FullChain.Equals(""))
			{
				return;    // Không tiếp tục nếu chưa có thông tin FullChain
			}
			if (ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.Server)
			{
				var oldConstructor = NetworkStreamReceiveSystem.DriverConstructor;
				try
				{
					NetworkStreamReceiveSystem.DriverConstructor = new IcpUdpWsNetworkDriverConstructor();
					var server = ClientServerBootstrap.CreateServerWorld("ServerWorld");
					string commandPort = EzyTankCommandLineUtils.GetPort();
					Debug.Log($"CommandPort = {commandPort}");
					NetworkEndpoint ep = NetworkEndpoint.AnyIpv4.WithPort(Convert.ToUInt16(commandPort));
					{
						using EntityQuery drvQuery = server.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
						drvQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.RequireConnectionApproval = true;
						drvQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Listen(ep);
					}
					DestroyLocalSimulationWorld();
					World.DefaultGameObjectInjectionWorld ??= server;

					SceneManager.LoadSceneAsync("ServerLoungeScene", LoadSceneMode.Single);
				}
				finally
				{
					NetworkStreamReceiveSystem.DriverConstructor = oldConstructor;
				}
			}
		}
	}
}
  • InstanceManager liên tục gửi requests đến /api/v1/instances/{port} sau 1 khoảng thời gian, nếu thấy thay đổi về SSL certificate, nó sẽ gọi đến ServerNetworkRestartManager.Instance.RequestServerNetworkRestart()
public class InstanceManager : SingletonMonoBehaviour<InstanceManager>
	{
		...

		private void Update()
		{
			if (ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.Client)
			{
				return;
			}
			mTimerInSeconds -= Time.deltaTime;
			if (mTimerInSeconds > 0)
			{
				return;
			}
			mTimerInSeconds = timerMaxInSeconds;
			string url = HttpUtils.ToGameMasterServerApiUrl(
				$"/instances/{mCommandPort}?apiKey={mCommandApiKey}&activeFullChainLastModified={mActiveFullChainLastModified}",
				true
			);
			HttpGetRequest getRequest = new HttpGetRequest(url);

			HttpClient.Instance.SendHttpRequest<HttpGetRequest, InstanceModel>(
				getRequest,
				HandleGetInstanceResponse,
				HandleGetInstanceError
			);
		}

                ...

		private void HandleGetInstanceResponse(InstanceModel instance)
		{
			if (instance.Status == "SHUTTING_DOWN")
			{
				string url = HttpUtils.ToGameMasterServerApiUrl($"/instances/{mCommandPort}?apiKey={mCommandApiKey}", true);
				HttpDeleteRequest deleteRequest = new HttpDeleteRequest(url);
				HttpClient.Instance.SendHttpRequest<HttpDeleteRequest, NoContentResponse>(
					deleteRequest,
					QuitApplication,
					HandleDeleteInstanceError
				);
			}
			if (instance.SslFullChain != null)               // Có thông tin mới về certificate
			{
				mActiveFullChainLastModified = instance.SslFullChainLastModified;
				mFullChain = instance.SslFullChain;
				mPrivateKey = instance.SslPrivateKey;
				ServerNetworkRestartManager.Instance.RequestServerNetworkRestart();
			}
		}
                ...
	}
  • Game server chỉ restart khi không còn connection nào:
/// MonoBehaviour-based manager that handles server network restarts./// Lives outside the ECS world to survive world disposal./// {private static ServerNetworkRestartManager INSTANCE;private bool mRestartPending;private string commandPort;private void Start(){mRestartPending = false;commandPort = EzyTankCommandLineUtils.GetPort();}private void Update(){// Only run on serverif (ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Server){return;}if (Input.GetKeyDown(KeyCode.R)){RequestServerNetworkRestart();}// Only restart when no connections are leftif (mRestartPending){int connectionCount = GetActiveConnectionCount();if (connectionCount > 0){// Still have active connections, keep waitingreturn;}Debug.Log($"[ServerNetworkRestartManager] No active connections. Restarting server network driver now...");StartCoroutine(RestartServerNetworkCoroutine());// Reset statemRestartPending = false;}}public void RequestServerNetworkRestart(){mRestartPending = true;}private int GetActiveConnectionCount(){var serverWorld = GetServerWorld();if (serverWorld == null || !serverWorld.IsCreated){return 0;}var entityManager = serverWorld.EntityManager;var connectionQuery = entityManager.CreateEntityQuery(ComponentType.ReadOnly(),ComponentType.ReadOnly());int count = connectionQuery.CalculateEntityCount();connectionQuery.Dispose();return count;}private World GetServerWorld(){foreach (var world in World.All){if (world.IsServer()){return world;}}return null;}private IEnumerator RestartServerNetworkCoroutine(){Debug.Log($"[ServerNetworkRestartManager] Preparing to restart on port {commandPort}");// Get reference to the old worldvar worldToDispose = GetServerWorld();// Clean up scenes associated with the old worldCleanupOldScenes();yield return null;// IMPORTANT: Dispose the old world to release the portDebug.Log("[ServerNetworkRestartManager] Disposing old server world to release port...");if (worldToDispose != null){worldToDispose.Dispose();}// Wait a frame to ensure cleanup is completeyield return new WaitForSeconds(2);SceneManager.LoadSceneAsync("ServerEntryScene", LoadSceneMode.Single);}private void CleanupOldScenes(){// Unload ServerLoungeScene if it's loadedfor (int i = 0; i < SceneManager.sceneCount; i++){Scene scene = SceneManager.GetSceneAt(i);if (scene.name == "ServerLoungeScene" && scene.isLoaded){Debug.Log($"[ServerNetworkRestartManager] Unloading scene: {scene.name}");SceneManager.UnloadSceneAsync(scene);}}}}
- ServerDriver luôn dùng thông tin mới nhất về FullChain và PrivateKey được lưu trong InstanceManager:
using Unity.Entities;using Unity.NetCode;using Unity.Networking.Transport;using Unity.Networking.Transport.TLS;using UnityEngine;namespace Youngmonkeys.EzyTank{public struct IcpUdpWsNetworkDriverConstructor : INetworkStreamDriverConstructor{...public void CreateServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug){

if UNITY_EDITOR || !UNITY_WEBGL

var settings = DefaultDriverBuilder.GetNetworkServerSettings();DefaultDriverBuilder.RegisterServerIpcDriver(world, ref driverStore, netDebug, settings);DefaultDriverBuilder.RegisterServerUdpDriver(world, ref driverStore, netDebug, settings);

if !UNITY_WEBGL

if (ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.ClientAndServer){settings.WithSecureServerParameters(certificate: InstanceManager.Instance.FullChain, // Lấy FullChain từ InstanceManagerprivateKey: InstanceManager.Instance.PrivateKey // Lấy FullChain từ InstanceManager);DefaultDriverBuilder.RegisterServerWebSocketDriver(world, ref driverStore, netDebug, settings);}

endif

endif

}}}