From 84094ed232dd81a4c48325ef0c385d1d9bff931a Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Sun, 11 Jan 2026 17:45:23 -0500 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 }; });