Skip to content

Commit 74ae3ad

Browse files
authored
Merge pull request #318 from PretendoNetwork/dev
Release
2 parents 9115810 + bdf0563 commit 74ae3ad

File tree

5 files changed

+130
-12
lines changed

5 files changed

+130
-12
lines changed

src/models/server.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,61 @@
1+
import dgram from 'node:dgram';
2+
import crypto from 'node:crypto';
13
import { Schema, model } from 'mongoose';
24
import uniqueValidator from 'mongoose-unique-validator';
3-
import type { IServer, IServerMethods, ServerModel } from '@/types/mongoose/server';
5+
import { LOG_WARN } from '@/logger';
6+
import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server';
7+
8+
// * Kinda ugly to slap this in with the Mongoose stuff but it's fine for now
9+
// TODO - Maybe move this one day?
10+
const socket = dgram.createSocket('udp4');
11+
const pendingHealthCheckRequests = new Map<string, () => void>();
12+
13+
socket.on('message', (msg: Buffer, _rinfo: dgram.RemoteInfo) => {
14+
const uuid = msg.toString();
15+
const resolve = pendingHealthCheckRequests.get(uuid);
16+
17+
if (resolve) {
18+
resolve();
19+
}
20+
});
21+
22+
socket.bind();
23+
24+
function healthCheck(target: { host: string; port: number }): Promise<string> {
25+
return new Promise((resolve, reject) => {
26+
const uuid = crypto.randomUUID();
27+
28+
const timeout = setTimeout(() => {
29+
pendingHealthCheckRequests.delete(uuid);
30+
reject(new Error('No valid response received'));
31+
}, 2 * 1000); // TODO - Make this configurable? 2 seconds seems fine for now
32+
33+
pendingHealthCheckRequests.set(uuid, () => {
34+
clearTimeout(timeout);
35+
pendingHealthCheckRequests.delete(uuid);
36+
resolve(target.host);
37+
});
38+
39+
socket.send(Buffer.from(uuid), target.port, target.host, (error) => {
40+
if (error) {
41+
clearTimeout(timeout);
42+
pendingHealthCheckRequests.delete(uuid);
43+
reject(error);
44+
}
45+
});
46+
});
47+
}
448

549
const ServerSchema = new Schema<IServer, ServerModel, IServerMethods>({
650
client_id: String,
7-
ip: String,
51+
ip: {
52+
type: String,
53+
required: false
54+
},
55+
ip_list: {
56+
type: [String],
57+
required: false
58+
},
859
port: Number,
960
service_name: String,
1061
service_type: String,
@@ -13,9 +64,60 @@ const ServerSchema = new Schema<IServer, ServerModel, IServerMethods>({
1364
access_mode: String,
1465
maintenance_mode: Boolean,
1566
device: Number,
16-
aes_key: String
67+
aes_key: String,
68+
health_check_port: {
69+
type: Number,
70+
required: false
71+
}
1772
});
1873

1974
ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
2075

76+
ServerSchema.method('getServerConnectInfo', async function (): Promise<IServerConnectInfo> {
77+
const ipList = [this.ip_list, this.ip].flat().filter((v): v is string => !!v);
78+
if (ipList.length === 0) {
79+
throw new Error(`No IP configured for server ${this._id}`);
80+
}
81+
82+
const randomIP = ipList[Math.floor(Math.random() * ipList.length)];
83+
84+
if (!this.health_check_port) {
85+
return {
86+
ip: randomIP,
87+
port: this.port
88+
};
89+
}
90+
91+
// * Remove the random IP from the race pool to remove the duplicate health check
92+
const healthCheckTargets = ipList.filter(ip => ip !== randomIP).map(ip => ({
93+
host: ip,
94+
port: this.health_check_port!
95+
}));
96+
97+
// * Default to the random IP in case nothing responded in time
98+
// * and just Hope For The Best:tm:
99+
let target = randomIP;
100+
101+
// * Check the random IP and start the race at the same time, preferring
102+
// * the result of the random IP should it succeed. Worst case scenario
103+
// * this takes 2 seconds to complete
104+
const [randomResult, raceResult] = await Promise.allSettled([
105+
healthCheck({ host: randomIP, port: this.health_check_port! }),
106+
Promise.race(healthCheckTargets.map(target => healthCheck(target)))
107+
]);
108+
109+
if (randomResult.status === 'rejected') {
110+
if (raceResult.status === 'fulfilled') {
111+
target = raceResult.value;
112+
} else {
113+
LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`);
114+
}
115+
}
116+
117+
return {
118+
ip: target,
119+
port: this.port
120+
};
121+
});
122+
21123
export const Server = model<IServer, ServerModel>('Server', ServerSchema);

src/provisioning.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ const serverProvisioningSchema = z.object({
1313
servers: z.array(z.object({
1414
id: z.string(),
1515
name: z.string(),
16-
ip: z.string(),
17-
port: z.coerce.number()
16+
ip: z.string().optional(),
17+
ipList: z.array(z.string()).optional(),
18+
port: z.coerce.number(),
19+
health_check_port: z.coerce.number().optional()
1820
}))
1921
});
2022

@@ -40,8 +42,10 @@ export async function handleServerProvisioning(): Promise<void> {
4042
$set: {
4143
_id: id,
4244
service_name: server.name,
45+
ipList: server.ipList,
4346
ip: server.ip,
44-
port: server.port
47+
port: server.port,
48+
health_check_port: server.health_check_port
4549
}
4650
});
4751
if (!result) {

src/services/nasc/routes/ac.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ router.post('/', async (request: express.Request, response: express.Response): P
5151
return;
5252
}
5353

54-
if (action === 'LOGIN' && server.port <= 0 && server.ip !== '0.0.0.0') {
54+
const connectInfo = await server.getServerConnectInfo();
55+
if (action === 'LOGIN' && connectInfo.port <= 0 && connectInfo.ip !== '0.0.0.0') {
5556
// * Addresses of 0.0.0.0:0 are allowed
5657
// * They are expected for titles with no NEX server
5758
response.status(200).send(nascError('110').toString());
@@ -85,8 +86,9 @@ async function processLoginRequest(server: HydratedServerDocument, pid: number,
8586
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
8687
const nexToken = nintendoBase64Encode(nexTokenBuffer || '');
8788

89+
const connectInfo = await server.getServerConnectInfo();
8890
return new URLSearchParams({
89-
locator: nintendoBase64Encode(`${server.ip}:${server.port}`),
91+
locator: nintendoBase64Encode(`${connectInfo.ip}:${connectInfo.port}`),
9092
retry: nintendoBase64Encode('0'),
9193
returncd: nintendoBase64Encode('001'),
9294
token: nexToken,

src/services/nnas/routes/provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,13 @@ router.get('/nex_token/@me', async (request: express.Request, response: express.
229229
nexToken = Buffer.from(nexToken || '', 'base64').toString('hex');
230230
}
231231

232+
const connectInfo = await server.getServerConnectInfo();
232233
response.send(xmlbuilder.create({
233234
nex_token: {
234-
host: server.ip,
235+
host: connectInfo.ip,
235236
nex_password: nexAccount.password,
236237
pid: nexAccount.pid,
237-
port: server.port,
238+
port: connectInfo.port,
238239
token: nexToken
239240
}
240241
}).end());

src/types/mongoose/server.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { Model, HydratedDocument } from 'mongoose';
22

33
export interface IServer {
44
client_id: string;
5-
ip: string;
5+
ip?: string;
6+
ip_list?: string[];
67
port: number;
78
service_name: string;
89
service_type: string;
@@ -12,9 +13,17 @@ export interface IServer {
1213
maintenance_mode: boolean;
1314
device: number;
1415
aes_key: string;
16+
health_check_port?: number;
17+
}
18+
19+
export interface IServerConnectInfo {
20+
ip: string;
21+
port: number;
1522
}
1623

17-
export interface IServerMethods {}
24+
export interface IServerMethods {
25+
getServerConnectInfo(): Promise<IServerConnectInfo>;
26+
}
1827

1928
interface IServerQueryHelpers {}
2029

0 commit comments

Comments
 (0)