-
Notifications
You must be signed in to change notification settings - Fork 19
/
Copy pathMasterServer.cs
592 lines (475 loc) · 21.7 KB
/
MasterServer.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using System.Timers;
using System.Xml.Serialization;
using Uchu.Api;
using Uchu.Api.Models;
using Uchu.Core;
using Uchu.Core.Client;
using Uchu.Core.Config;
using Uchu.Core.Providers;
using Uchu.Master.Api;
namespace Uchu.Master
{
internal static class MasterServer
{
public static string DllLocation { get; set; }
public static UchuConfiguration Config { get; set; }
public static List<ServerInstance> Instances { get; set; }
public static Dictionary<string, int> InstanceHeartBeats { get; set; }
public static Dictionary<string, ServerHealth> InstanceHealth { get; set; }
public static Timer InstanceHeartBeatCheck { get; set; }
public static string ConfigPath { get; set; }
public static string CdClientPath { get; set; }
public static bool Running { get; set; }
public static bool UseAuthentication { get; set; }
public static int ApiPortIndex { get; set; }
public static int ApiPort { get; set; }
public static bool IsSubsidiary { get; set; }
public static List<int> Subsidiaries { get; set; }
public static ApiManager Api { get; set; }
public static int WorldServerHeartBeatsIntervalInMinutes { get; set; }
public static int WorldServerHeartBeatsPerInterval { get; set; }
public static int MasterPort => Config.ApiConfig.Port;
private static async Task Main(string[] args)
{
Subsidiaries = new List<int>();
Instances = new List<ServerInstance>();
InstanceHeartBeats = new Dictionary<string, int>();
InstanceHealth = new Dictionary<string, ServerHealth>();
try
{
await ConfigureAsync();
}
catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException)
{
Logger.Error(e.Message);
if (Config.ServerBehaviour.PressKeyToExit)
{
Logger.Error("Press any key to exit...");
Console.ReadKey();
}
Environment.Exit(1);
}
var databaseVerified = await CheckForDatabaseUpdatesAsync();
if (!databaseVerified)
{
Logger.Error($"Failed to connect to database provider \"{Config.Database.Provider}\".");
if (Config.ServerBehaviour.PressKeyToExit)
{
Logger.Error("Press any key to exit...");
Console.ReadKey();
}
Environment.Exit(1);
}
Console.CancelKeyPress += ShutdownProcesses;
AppDomain.CurrentDomain.ProcessExit += ShutdownProcesses;
await SetupApiAsync();
// Setup health checks and automatic server closing
InstanceHeartBeatCheck = new Timer(WorldServerHeartBeatsIntervalInMinutes * 60000);
InstanceHeartBeatCheck.Elapsed += async (sender, eventArgs) =>
{
foreach (var instance in Instances.Where(i => i.ServerType == ServerType.World).ToList())
{
var id = instance.Id.ToString();
if (InstanceHealth.ContainsKey(id) && InstanceHeartBeats.ContainsKey(id))
{
InstanceHealth[id] = (InstanceHeartBeats[id] != 0
? (float) InstanceHeartBeats[id] / WorldServerHeartBeatsPerInterval : 0) switch
{
var health when health >= 1 => ServerHealth.Healthy,
var health when health >= 0.75 => ServerHealth.Lagging,
var health when health >= 0.5 => ServerHealth.SeverelyLagging,
var health when health >= 0.25 => ServerHealth.Unhealthy,
// If hardly any heartbeats were received, gradually downgrade the health until closing
_ => (ServerHealth) (int) InstanceHealth[id] - 1
};
}
else
{
InstanceHealth.Add(id, ServerHealth.Dead);
}
Logger.Information($"{id} is {InstanceHealth[id]}");
InstanceHeartBeats[id] = 0;
}
// Kill all unhealthy instances
foreach (var unhealthyInstance in Instances.Where(i => i.ServerType == ServerType.World
&& InstanceHealth.TryGetValue(i.Id.ToString(),
out var health)
&& health == ServerHealth.Dead).ToList())
{
Logger.Information($"Closing {unhealthyInstance.Id.ToString()} " +
$"({InstanceHealth[unhealthyInstance.Id.ToString()]}) due to health status.");
await Api.RunCommandAsync<BaseResponse>(ApiPort,
$"instance/decommission?instance={unhealthyInstance.Id.ToString()}");
}
};
InstanceHeartBeatCheck.Enabled = true;
InstanceHeartBeatCheck.AutoReset = true;
try
{
// Throws HttpListenerException when port is unavailable or permission is denied
await Api.StartAsync(ApiPort);
}
catch (HttpListenerException e)
{
Logger.Error($"Could not start API listener on port {ApiPort}: {e.Message}. Try specifying a different port in config.xml.");
if (Config.ServerBehaviour.PressKeyToExit)
{
Logger.Error("Press any key to exit...");
Console.ReadKey();
}
Environment.Exit(1);
}
}
private static async Task<bool> CheckForDatabaseUpdatesAsync()
{
try
{
await using var uchuContext = new UchuContext();
await uchuContext.EnsureUpdatedAsync();
await using var cdClientContext = new CdClientContext();
await cdClientContext.EnsureUpdatedAsync();
return true;
}
catch
{
return false;
}
}
private static void ShutdownProcesses(object _, EventArgs ev)
{
Running = false;
foreach (var instance in Instances)
{
instance.Process?.Kill();
}
if (!(ev is ConsoleCancelEventArgs cancelEv)) return;
cancelEv.Cancel = true;
Environment.Exit(0);
}
private static async Task SetupApiAsync()
{
var apiConfig = Config.ApiConfig;
Api = new ApiManager(apiConfig.Protocol, apiConfig.Domain);
Api.RegisterCommandCollection<AccountCommands>();
Api.RegisterCommandCollection<MasterCommands>();
Api.RegisterCommandCollection<CharacterCommands>();
var response = await Api.RunCommandAsync<ClaimPortResponse>(MasterPort, "subsidiary");
if (response != null && response.Success)
{
IsSubsidiary = true;
ApiPort = response.Port;
Logger.Information($"Is subsidiary: {ApiPort}");
}
else
{
ApiPort = MasterPort;
}
Api.OnLoaded += async () =>
{
Logger.Information($"API Ready!");
await StartDefaultInstances();
};
}
private static async Task StartDefaultInstances()
{
var hostAuthentication = Config.Networking.HostAuthentication;
var hostCharacter = Config.Networking.HostCharacter;
if (IsSubsidiary)
{
var instances = await GetAllInstancesAsync();
if (hostAuthentication)
{
if (instances.Any(i => i.Type == (int) ServerType.Authentication))
{
hostAuthentication = false;
}
}
if (hostCharacter)
{
if (instances.Any(i => i.Type == (int) ServerType.Character))
{
hostCharacter = false;
}
}
}
if (hostAuthentication)
{
await StartInstanceAsync(ServerType.Authentication, Config.Networking.AuthenticationPort);
}
if (hostCharacter)
{
await StartInstanceAsync(ServerType.Character, Config.Networking.CharacterPort);
}
}
/// <summary>
/// Attempt to find the resource folder of an unpacked client installed using Nexus LU Launcher.
/// </summary>
/// <returns>The path to the client's res folder.</returns>
/// <exception cref="FileNotFoundException">Valid resource folder was not found.</exception>
private static string FindNlulClientResources()
{
// Get .nlul location, e.g. ~/.nlul
var nlulHomeLocation = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".nlul");
if (!Directory.Exists(nlulHomeLocation))
throw new DirectoryNotFoundException("No NLUL installation found.");
// Get path to NLUL config file
var launcherFileLocation = Path.Combine(
nlulHomeLocation,
"launcher.json");
if (!File.Exists(launcherFileLocation))
throw new FileNotFoundException("No NLUL configuration file found.");
// Parse NLUL config file
var nlulConfig = JsonDocument.Parse(File.ReadAllText(launcherFileLocation));
// Client parent directory is ClientParentLocation if set, otherwise nlulHomeLocation
var clientParentLocation = nlulHomeLocation;
if (nlulConfig.RootElement.TryGetProperty("ClientParentLocation", out var customClientParentLocation))
clientParentLocation = customClientParentLocation.GetString();
if (!Directory.Exists(clientParentLocation))
throw new FileNotFoundException("Configured ClientParentLocation directory does not exist.");
// Iterate over subdirectories to search for a valid client
Debug.Assert(clientParentLocation != null, nameof(clientParentLocation) + " != null");
foreach (var clientDirectory in Directory.GetDirectories(clientParentLocation))
{
var resLocation = Path.Combine(clientDirectory, "res");
if (!EnsureUnpackedClient(resLocation))
continue;
return resLocation;
}
throw new FileNotFoundException("No unpacked client found.");
}
/// <summary>
/// Load the server configuration.
/// </summary>
/// <exception cref="DirectoryNotFoundException">Resource folder is not configured correctly.</exception>
/// <exception cref="FileNotFoundException">One of the required files are not configured correctly.</exception>
private static async Task ConfigureAsync()
{
const string configFilename = "config.xml";
const string legacySecondConfigName = "config.default.xml";
// Use config.xml if it exists
if (File.Exists(configFilename))
{
Config = UchuConfiguration.Load(configFilename);
Logger.SetConfiguration(Config);
#if DEBUG
if (!Config.DebugConfig.StartInstancesAsThreads)
#endif
Logger.SetServerTypeInformation("Master");
}
// Otherwise, use config.default.xml if it exists
else if (File.Exists(legacySecondConfigName))
{
Config = UchuConfiguration.Load(legacySecondConfigName);
Logger.SetConfiguration(Config);
#if DEBUG
if (!Config.DebugConfig.StartInstancesAsThreads)
#endif
Logger.SetServerTypeInformation("Master");
}
// Otherwise, generate a new config file
else
{
Config = new UchuConfiguration();
Logger.SetConfiguration(Config);
#if DEBUG
if (!Config.DebugConfig.StartInstancesAsThreads)
#endif
Logger.SetServerTypeInformation("Master");
// Add default value for instance DLL source and script DLL source.
if (File.Exists("lib/Uchu.Instance.dll"))
{
Config.DllSource.Instance = "lib/Uchu.Instance.dll";
}
Config.DllSource.ScriptDllSource.Add(File.Exists("lib/Uchu.StandardScripts.dll")
? "lib/Uchu.StandardScripts.dll"
: "../../../../Uchu.StandardScripts/bin/Debug/net6.0/Uchu.StandardScripts.dll");
// Write config file
Config.Save(configFilename);
var info = new FileInfo(configFilename);
Logger.Warning($"No config file found, created one at {info.FullName}");
}
SqliteContext.DatabasePath = Path.Combine(Directory.GetCurrentDirectory(), "./Uchu.sqlite");
UchuContextBase.Config = Config;
UseAuthentication = Config.Networking.HostAuthentication;
// Check: resource folder
var resourceFolder = Config.ResourcesConfiguration.GameResourceFolder;
if (!EnsureUnpackedClient(resourceFolder))
{
// Try finding NLUL client
try
{
Config.ResourcesConfiguration.GameResourceFolder = FindNlulClientResources();
Config.Save(configFilename);
Logger.Information($"Using automatically detected client resource folder: {Config.ResourcesConfiguration.GameResourceFolder}");
}
catch
{
// Unsuccessful in finding unpacked client resource folder
throw new DirectoryNotFoundException(
"Please enter a valid unpacked client resource folder in the configuration file.");
}
}
// Check: Uchu.Instance.dll
// Migration from .net 5 to .net 6
if (Config.DllSource.Instance.Contains("net5.0"))
{
Config.DllSource.Instance = Config.DllSource.Instance.Replace("net5.0", "net6.0");
Config.Save(configFilename);
}
DllLocation = Config.DllSource.Instance;
if (!File.Exists(DllLocation))
{
throw new FileNotFoundException("Could not find file specified in <Instance> under <DllSource> in config.xml.");
}
// Check: Uchu.StandardScripts.dll
var validScriptPackExists = false;
for (var i = 0; i < Config.DllSource.ScriptDllSource.Count; i++)
{
// Migration from .net 5 to .net 6
if (Config.DllSource.ScriptDllSource[i].Contains("net5.0"))
{
Config.DllSource.ScriptDllSource[i] = Config.DllSource.ScriptDllSource[i].Replace("net5.0", "net6.0");
Config.Save(configFilename);
}
if (File.Exists(Config.DllSource.ScriptDllSource[i]))
{
Logger.Information($"Using script pack: {Config.DllSource.ScriptDllSource[i]}");
validScriptPackExists = true;
break;
}
Logger.Warning($"Could not find script pack at {Config.DllSource.ScriptDllSource[i]}");
}
if (!validScriptPackExists)
{
throw new FileNotFoundException("No valid <ScriptDllSource> specified under <DllSource> in config.xml.\n"
+ "Without Uchu.StandardScripts.dll, Uchu cannot function correctly.");
}
ApiPortIndex = Config.ApiConfig.Port;
WorldServerHeartBeatsPerInterval = Config.Networking.WorldServerHeartBeatsPerInterval;
WorldServerHeartBeatsIntervalInMinutes = Config.Networking.WorldServerHeartBeatIntervalInMinutes;
var source = Directory.GetCurrentDirectory();
ConfigPath = Path.Combine(source, $"{configFilename}");
CdClientPath = Path.Combine(source, "CDClient.db");
Logger.Information($"Using configuration: {ConfigPath}\nUsing CDClient: {CdClientPath}");
}
/// <summary>
/// Check whether a directory is the res folder of an unpacked client. Accounts for the possibility of the
/// directory not existing at all.
/// </summary>
/// <param name="directory">Path to the directory.</param>
/// <returns>Whether it's an unpacked res folder.</returns>
private static bool EnsureUnpackedClient(string directory)
{
return !string.IsNullOrWhiteSpace(directory) &&
directory.EndsWith("res") &&
Directory.Exists(directory) &&
Directory.GetFiles(directory, "*.luz", SearchOption.AllDirectories).Any();
}
public static async Task<Guid> StartInstanceAsync(ServerType type, int port)
{
var id = Guid.NewGuid();
var instance = new ServerInstance(id)
{
ServerType = type,
ServerPort = port,
ApiPort = await ClaimApiPortAsync(),
};
instance.Start(DllLocation, Config.DllSource.DotNetPath);
Instances.Add(instance);
if (type == ServerType.World)
{
InstanceHeartBeats.Add(id.ToString(), 0);
InstanceHealth.Add(id.ToString(), ServerHealth.Healthy);
}
return id;
}
public static async Task<int> ClaimWorldPortAsync()
{
if (IsSubsidiary)
{
var response = await Api.RunCommandAsync<ClaimPortResponse>(
MasterPort, "claim/world"
);
if (response.Success) return response.Port;
Logger.Error(response.FailedReason);
throw new Exception(response.FailedReason);
}
var instances = await GetAllInstancesAsync();
var worlds = instances.Where(i => i.Type == (int) ServerType.World).ToArray();
var specified = Config.Networking.WorldPorts.Count > 0;
lock (Api)
{
if (!specified)
{
if (worlds.Length == default) return 20000;
return worlds.Max(i => i.Port) + 1;
}
var available = Config.Networking.WorldPorts.ToList();
foreach (var world in worlds)
{
if (available.Contains(world.Port))
{
available.Remove(world.Port);
}
}
return available.FirstOrDefault();
}
}
public static async Task<int> ClaimApiPortAsync()
{
if (IsSubsidiary)
{
var response = await Api.RunCommandAsync<ClaimPortResponse>(
MasterPort, "claim/api"
);
if (response.Success) return response.Port;
Logger.Error(response.FailedReason);
throw new Exception(response.FailedReason);
}
lock (Api)
{
return ++ApiPortIndex;
}
}
public static async Task<List<InstanceInfo>> GetAllInstancesAsync()
{
if (IsSubsidiary)
{
var result = await Api.RunCommandAsync<InstanceListResponse>(
MasterPort, "instance/list/complete"
);
return result.Instances;
}
var localList = await Api.RunCommandAsync<InstanceListResponse>(
ApiPort, "instance/list"
);
var instances = localList.Instances;
foreach (var subsidiary in Subsidiaries)
{
Logger.Information($"Request instance from: {subsidiary}");
var result = await Api.RunCommandAsync<InstanceListResponse>(
subsidiary, "instance/list"
);
if (result == null)
continue;
if (!result.Success)
{
Logger.Error(result.FailedReason);
continue;
}
instances.AddRange(result.Instances);
}
return instances;
}
}
}