From a924002d0b45ecd6cd4cb6ce2362397977e05c4d Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 11 Jan 2026 17:15:15 +0100 Subject: [PATCH 1/9] feat: add support for multiple server IPs --- src/models/server.ts | 16 +++++++++++++++- src/provisioning.ts | 4 +++- src/services/nasc/routes/ac.ts | 6 ++++-- src/services/nnas/routes/provider.ts | 5 +++-- src/types/mongoose/server.ts | 12 ++++++++++-- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index 8639281..c769e29 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -1,10 +1,11 @@ import { Schema, model } from 'mongoose'; import uniqueValidator from 'mongoose-unique-validator'; -import type { IServer, IServerMethods, ServerModel } from '@/types/mongoose/server'; +import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server'; const ServerSchema = new Schema({ client_id: String, ip: String, + ipList: [String], // If specified, clients will be given a random IP from this list port: Number, service_name: String, service_type: String, @@ -18,4 +19,17 @@ const ServerSchema = new Schema({ ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' }); +ServerSchema.method('getServerConnectInfo', async function (): Promise { + const ipList = (this.ipList ?? [this.ip]).filter((v): v is string => !!v); + if (ipList.length === 0) { + throw new Error(`No IP configured for server ${this._id}`); + } + + const randomIp = ipList[Math.floor(Math.random() * ipList.length)]; + return { + ip: randomIp, + port: this.port + }; +}); + export const Server = model('Server', ServerSchema); diff --git a/src/provisioning.ts b/src/provisioning.ts index 43a4fc7..c4b37c6 100644 --- a/src/provisioning.ts +++ b/src/provisioning.ts @@ -13,7 +13,8 @@ const serverProvisioningSchema = z.object({ servers: z.array(z.object({ id: z.string(), name: z.string(), - ip: z.string(), + ip: z.string().optional(), + ipList: z.array(z.string()).optional(), port: z.coerce.number() })) }); @@ -40,6 +41,7 @@ export async function handleServerProvisioning(): Promise { $set: { _id: id, service_name: server.name, + ipList: server.ipList, ip: server.ip, port: server.port } diff --git a/src/services/nasc/routes/ac.ts b/src/services/nasc/routes/ac.ts index 1d16a65..1c5fa11 100644 --- a/src/services/nasc/routes/ac.ts +++ b/src/services/nasc/routes/ac.ts @@ -51,7 +51,8 @@ router.post('/', async (request: express.Request, response: express.Response): P return; } - if (action === 'LOGIN' && server.port <= 0 && server.ip !== '0.0.0.0') { + const connectInfo = await server.getServerConnectInfo(); + if (action === 'LOGIN' && connectInfo.port <= 0 && connectInfo.ip !== '0.0.0.0') { // * Addresses of 0.0.0.0:0 are allowed // * They are expected for titles with no NEX server response.status(200).send(nascError('110').toString()); @@ -85,8 +86,9 @@ async function processLoginRequest(server: HydratedServerDocument, pid: number, const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions); const nexToken = nintendoBase64Encode(nexTokenBuffer || ''); + const connectInfo = await server.getServerConnectInfo(); return new URLSearchParams({ - locator: nintendoBase64Encode(`${server.ip}:${server.port}`), + locator: nintendoBase64Encode(`${connectInfo.ip}:${connectInfo.port}`), retry: nintendoBase64Encode('0'), returncd: nintendoBase64Encode('001'), token: nexToken, diff --git a/src/services/nnas/routes/provider.ts b/src/services/nnas/routes/provider.ts index 133acbf..f59b1df 100644 --- a/src/services/nnas/routes/provider.ts +++ b/src/services/nnas/routes/provider.ts @@ -229,12 +229,13 @@ router.get('/nex_token/@me', async (request: express.Request, response: express. nexToken = Buffer.from(nexToken || '', 'base64').toString('hex'); } + const connectInfo = await server.getServerConnectInfo(); response.send(xmlbuilder.create({ nex_token: { - host: server.ip, + host: connectInfo.ip, nex_password: nexAccount.password, pid: nexAccount.pid, - port: server.port, + port: connectInfo.port, token: nexToken } }).end()); diff --git a/src/types/mongoose/server.ts b/src/types/mongoose/server.ts index 7984918..33b1a4a 100644 --- a/src/types/mongoose/server.ts +++ b/src/types/mongoose/server.ts @@ -2,7 +2,8 @@ import type { Model, HydratedDocument } from 'mongoose'; export interface IServer { client_id: string; - ip: string; + ip?: string; + ipList?: string[]; port: number; service_name: string; service_type: string; @@ -14,7 +15,14 @@ export interface IServer { aes_key: string; } -export interface IServerMethods {} +export interface IServerConnectInfo { + ip: string; + port: number; +} + +export interface IServerMethods { + getServerConnectInfo(): Promise; +} interface IServerQueryHelpers {} From 0ffe8643846da7885003df82e3bc36ae2361ce77 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 11 Jan 2026 17:45:40 +0100 Subject: [PATCH 2/9] fix: update server model to properly account for nullable properties --- src/models/server.ts | 12 +++++++++--- src/types/mongoose/server.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index c769e29..ef10163 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -4,8 +4,14 @@ import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@ const ServerSchema = new Schema({ client_id: String, - ip: String, - ipList: [String], // If specified, clients will be given a random IP from this list + ip: { + type: String, + required: false + }, + ip_list: { + type: [String], + required: false + }, port: Number, service_name: String, service_type: String, @@ -20,7 +26,7 @@ const ServerSchema = new Schema({ ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' }); ServerSchema.method('getServerConnectInfo', async function (): Promise { - const ipList = (this.ipList ?? [this.ip]).filter((v): v is string => !!v); + const ipList = [this.ip_list, this.ip].flat().filter((v): v is string => !!v); if (ipList.length === 0) { throw new Error(`No IP configured for server ${this._id}`); } diff --git a/src/types/mongoose/server.ts b/src/types/mongoose/server.ts index 33b1a4a..2b19850 100644 --- a/src/types/mongoose/server.ts +++ b/src/types/mongoose/server.ts @@ -3,7 +3,7 @@ import type { Model, HydratedDocument } from 'mongoose'; export interface IServer { client_id: string; ip?: string; - ipList?: string[]; + ip_list?: string[]; port: number; service_name: string; service_type: string; From 84094ed232dd81a4c48325ef0c385d1d9bff931a Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Sun, 11 Jan 2026 17:45:23 -0500 Subject: [PATCH 3/9] feat: add basic health check before returning server address in getServerConnectInfo --- src/models/server.ts | 67 ++++++++++++++++++++++++++++++++++-- src/types/mongoose/server.ts | 1 + 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index ef10163..9057977 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -1,7 +1,50 @@ +import dgram from 'node:dgram'; +import crypto from 'node:crypto'; import { Schema, model } from 'mongoose'; import uniqueValidator from 'mongoose-unique-validator'; import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server'; +// * Kinda ugly to slap this in with the Mongoose stuff but it's fine for now +// TODO - Maybe move this one day? +const socket = dgram.createSocket('udp4'); +const pendingHealthCheckRequests = new Map void>(); + +socket.on('message', (msg: Buffer, _rinfo: dgram.RemoteInfo) => { + const uuid = msg.toString(); + const resolve = pendingHealthCheckRequests.get(uuid); + + if (resolve) { + resolve(); + } +}); + +socket.bind(); + +function healthCheck(target: { host: string; port: number }): Promise { + return new Promise((resolve, reject) => { + const uuid = crypto.randomUUID(); + + const timeout = setTimeout(() => { + pendingHealthCheckRequests.delete(uuid); + reject(new Error('No valid response received')); + }, 5 * 1000); // TODO - Make this configurable? 5 seconds seems fine for now + + pendingHealthCheckRequests.set(uuid, () => { + clearTimeout(timeout); + pendingHealthCheckRequests.delete(uuid); + resolve(target.host); + }); + + socket.send(Buffer.from(uuid), target.port, target.host, (error) => { + if (error) { + clearTimeout(timeout); + pendingHealthCheckRequests.delete(uuid); + reject(error); + } + }); + }); +} + const ServerSchema = new Schema({ client_id: String, ip: { @@ -20,7 +63,8 @@ const ServerSchema = new Schema({ access_mode: String, maintenance_mode: Boolean, device: Number, - aes_key: String + aes_key: String, + health_check_port: Number }); ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' }); @@ -31,9 +75,26 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise ({ + host: ip, + port: this.health_check_port + })); + + let target: string | undefined; + + try { + // * Pick the first address that wins the health check. If no address responds in 5 seconds + // * nothing is returned + target = await Promise.race(healthCheckTargets.map(target => healthCheck(target))); + } catch { + // * Eat error for now, this means that no address responded in time + // TODO - Handle this + } + + const randomIP = ipList[Math.floor(Math.random() * ipList.length)]; + return { - ip: randomIp, + ip: target || randomIP, // * Just use a random IP if nothing responded in time and Hope For The Best:tm: port: this.port }; }); diff --git a/src/types/mongoose/server.ts b/src/types/mongoose/server.ts index 2b19850..3271713 100644 --- a/src/types/mongoose/server.ts +++ b/src/types/mongoose/server.ts @@ -13,6 +13,7 @@ export interface IServer { maintenance_mode: boolean; device: number; aes_key: string; + health_check_port: number; } export interface IServerConnectInfo { From 38c6e6ecdfb8246119254cd1d0226b4a58a922a1 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Mon, 12 Jan 2026 12:33:29 -0500 Subject: [PATCH 4/9] feat: make server health_check_port optional --- src/models/server.ts | 18 ++++++++++++++---- src/types/mongoose/server.ts | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index 9057977..d18168e 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -64,7 +64,10 @@ const ServerSchema = new Schema({ maintenance_mode: Boolean, device: Number, aes_key: String, - health_check_port: Number + health_check_port: { + type: Number, + required: false + } }); ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' }); @@ -75,9 +78,18 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise ({ host: ip, - port: this.health_check_port + port: this.health_check_port! })); let target: string | undefined; @@ -91,8 +103,6 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise Date: Mon, 12 Jan 2026 12:35:55 -0500 Subject: [PATCH 5/9] chore: log warning when server health check fails --- src/models/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/models/server.ts b/src/models/server.ts index d18168e..7a6f9d4 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -3,6 +3,7 @@ import crypto from 'node:crypto'; import { Schema, model } from 'mongoose'; import uniqueValidator from 'mongoose-unique-validator'; import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server'; +import { LOG_WARN } from '@/logger'; // * Kinda ugly to slap this in with the Mongoose stuff but it's fine for now // TODO - Maybe move this one day? @@ -101,6 +102,7 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise Date: Mon, 12 Jan 2026 12:38:04 -0500 Subject: [PATCH 6/9] feat: add health_check_port to provisioning config --- src/provisioning.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/provisioning.ts b/src/provisioning.ts index c4b37c6..1bfe597 100644 --- a/src/provisioning.ts +++ b/src/provisioning.ts @@ -15,7 +15,8 @@ const serverProvisioningSchema = z.object({ name: z.string(), ip: z.string().optional(), ipList: z.array(z.string()).optional(), - port: z.coerce.number() + port: z.coerce.number(), + health_check_port: z.coerce.number().optional() })) }); @@ -43,7 +44,8 @@ export async function handleServerProvisioning(): Promise { service_name: server.name, ipList: server.ipList, ip: server.ip, - port: server.port + port: server.port, + health_check_port: server.health_check_port } }); if (!result) { From 31582521661274064d52e2d7a0372937afd72311 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Mon, 12 Jan 2026 12:38:55 -0500 Subject: [PATCH 7/9] feat: change server health check ping-pong time to 2s --- src/models/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index 7a6f9d4..5de270a 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -28,7 +28,7 @@ function healthCheck(target: { host: string; port: number }): Promise { const timeout = setTimeout(() => { pendingHealthCheckRequests.delete(uuid); reject(new Error('No valid response received')); - }, 5 * 1000); // TODO - Make this configurable? 5 seconds seems fine for now + }, 2 * 1000); // TODO - Make this configurable? 2 seconds seems fine for now pendingHealthCheckRequests.set(uuid, () => { clearTimeout(timeout); @@ -96,7 +96,7 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise healthCheck(target))); } catch { From cd5755745c27a6a658ab4e5ae72a69d24d96f024 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Mon, 12 Jan 2026 12:40:58 -0500 Subject: [PATCH 8/9] chore: fix import order in server mongoose schema --- src/models/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/server.ts b/src/models/server.ts index 5de270a..d3c9105 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -2,8 +2,8 @@ import dgram from 'node:dgram'; import crypto from 'node:crypto'; import { Schema, model } from 'mongoose'; import uniqueValidator from 'mongoose-unique-validator'; -import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server'; import { LOG_WARN } from '@/logger'; +import type { IServer, IServerConnectInfo, IServerMethods, ServerModel } from '@/types/mongoose/server'; // * Kinda ugly to slap this in with the Mongoose stuff but it's fine for now // TODO - Maybe move this one day? From 5b44d9f9aa1342803a5a8125a4076028ddfb224b Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Mon, 12 Jan 2026 12:53:46 -0500 Subject: [PATCH 9/9] feat: prefer random IP during health check --- src/models/server.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/models/server.ts b/src/models/server.ts index d3c9105..65f14ea 100644 --- a/src/models/server.ts +++ b/src/models/server.ts @@ -88,25 +88,34 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise ({ + // * Remove the random IP from the race pool to remove the duplicate health check + const healthCheckTargets = ipList.filter(ip => ip !== randomIP).map(ip => ({ host: ip, port: this.health_check_port! })); - let target: string | undefined; + // * Default to the random IP in case nothing responded in time + // * and just Hope For The Best:tm: + let target = randomIP; - try { - // * Pick the first address that wins the health check. If no address responds in 2 seconds - // * nothing is returned - target = await Promise.race(healthCheckTargets.map(target => healthCheck(target))); - } catch { - // * Eat error for now, this means that no address responded in time - // TODO - Handle this - LOG_WARN(`Server ${this.service_name} faield to find healthy NEX server. Falling back to random IP`); + // * Check the random IP and start the race at the same time, preferring + // * the result of the random IP should it succeed. Worst case scenario + // * this takes 2 seconds to complete + const [randomResult, raceResult] = await Promise.allSettled([ + healthCheck({ host: randomIP, port: this.health_check_port! }), + Promise.race(healthCheckTargets.map(target => healthCheck(target))) + ]); + + if (randomResult.status === 'rejected') { + if (raceResult.status === 'fulfilled') { + target = raceResult.value; + } else { + LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`); + } } return { - ip: target || randomIP, // * Just use a random IP if nothing responded in time and Hope For The Best:tm: + ip: target, port: this.port }; });