update selection algorithm

This commit is contained in:
miloschwartz 2025-06-22 17:19:32 -04:00
parent f3b44a3085
commit d45443258b
No known key found for this signature in database
4 changed files with 117 additions and 85 deletions

View file

@ -13,7 +13,7 @@ export const domains = pgTable("domains", {
domainId: varchar("domainId").primaryKey(), domainId: varchar("domainId").primaryKey(),
baseDomain: varchar("baseDomain").notNull(), baseDomain: varchar("baseDomain").notNull(),
configManaged: boolean("configManaged").notNull().default(false), configManaged: boolean("configManaged").notNull().default(false),
type: varchar("type").notNull(), // "ns", "cname", "a" type: varchar("type"), // "ns", "cname", "a"
}); });
export const orgs = pgTable("orgs", { export const orgs = pgTable("orgs", {

View file

@ -7,7 +7,7 @@ export const domains = sqliteTable("domains", {
configManaged: integer("configManaged", { mode: "boolean" }) configManaged: integer("configManaged", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
type: text("type").notNull(), // "ns", "cname", "a" type: text("type"), // "ns", "cname", "a"
}); });
export const orgs = sqliteTable("orgs", { export const orgs = sqliteTable("orgs", {

View file

@ -3,7 +3,7 @@ import { MessageHandler } from "../ws";
import { exitNodes, Newt } from "@server/db"; import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { ne, eq, and, count } from "drizzle-orm"; import { ne, eq, or, and, count } from "drizzle-orm";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => { export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@ -17,10 +17,11 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
} }
// TODO: pick which nodes to send and ping better than just all of them // TODO: pick which nodes to send and ping better than just all of them
const exitNodesList = await db let exitNodesList = await db
.select() .select()
.from(exitNodes) .from(exitNodes);
.where(ne(exitNodes.maxConnections, 0));
exitNodesList = exitNodesList.filter((node) => node.maxConnections !== 0);
let lastExitNodeId = null; let lastExitNodeId = null;
if (newt.siteId) { if (newt.siteId) {

View file

@ -11,13 +11,13 @@ import {
} from "@server/lib/ip"; } from "@server/lib/ip";
export type ExitNodePingResult = { export type ExitNodePingResult = {
exitNodeId: number; exitNodeId: number;
latencyMs: number; latencyMs: number;
weight: number; weight: number;
error?: string; error?: string;
exitNodeName: string; exitNodeName: string;
endpoint: string; endpoint: string;
wasPreviouslyConnected: boolean; wasPreviouslyConnected: boolean;
}; };
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
@ -38,20 +38,25 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId; const siteId = newt.siteId;
const { publicKey, pingResults, newtVersion, backwardsCompatible } = message.data; const { publicKey, pingResults, newtVersion, backwardsCompatible } =
message.data;
if (!publicKey) { if (!publicKey) {
logger.warn("Public key not provided"); logger.warn("Public key not provided");
return; return;
} }
if (backwardsCompatible) { if (backwardsCompatible) {
logger.debug("Backwards compatible mode detecting - not sending connect message and waiting for ping response."); logger.debug(
"Backwards compatible mode detecting - not sending connect message and waiting for ping response."
);
return; return;
} }
let exitNodeId: number | undefined; let exitNodeId: number | undefined;
if (pingResults) { if (pingResults) {
const bestPingResult = selectBestExitNode(pingResults as ExitNodePingResult[]); const bestPingResult = selectBestExitNode(
pingResults as ExitNodePingResult[]
);
if (!bestPingResult) { if (!bestPingResult) {
logger.warn("No suitable exit node found based on ping results"); logger.warn("No suitable exit node found based on ping results");
return; return;
@ -64,9 +69,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
await db await db
.update(newts) .update(newts)
.set({ .set({
version: newtVersion as string, version: newtVersion as string
}) })
.where(eq(newts.newtId, newt.newtId)) .where(eq(newts.newtId, newt.newtId));
} }
const [oldSite] = await db const [oldSite] = await db
@ -101,12 +106,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const blockSize = config.getRawConfig().gerbil.site_block_size; const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery.map((site) => site.subnet); const subnets = sitesQuery.map((site) => site.subnet);
subnets.push( subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
exitNode.address.replace(
/\/\d+$/,
`/${blockSize}`
)
);
const newSubnet = findNextAvailableCidr( const newSubnet = findNextAvailableCidr(
subnets, subnets,
blockSize, blockSize,
@ -258,70 +258,101 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
}; };
}; };
function selectBestExitNode(pingResults: ExitNodePingResult[]): ExitNodePingResult | null { /**
// Configuration constants - can be tweaked as needed * Selects the most suitable exit node from a list of ping results.
const LATENCY_PENALTY_EXPONENT = 1.5; // make latency matter more *
const LAST_NODE_SCORE_BOOST = 1.10; // 10% preference for the last used node * The selection algorithm follows these steps:
const SCORE_TOLERANCE_PERCENT = 5.0; // allow last node if within 5% of best score *
* 1. **Filter Invalid Nodes**: Excludes nodes with errors or zero weight.
*
* 2. **Sort by Latency**: Sorts valid nodes in ascending order of latency.
*
* 3. **Preferred Selection**:
* - If the lowest-latency node has sufficient capacity (10% weight),
* check if a previously connected node is also acceptable.
* - The previously connected node is preferred if its latency is within
* 30ms or 15% of the best nodes latency.
*
* 4. **Fallback to Next Best**:
* - If the lowest-latency node is under capacity, find the next node
* with acceptable capacity.
*
* 5. **Final Fallback**:
* - If no nodes meet the capacity threshold, fall back to the node
* with the highest weight (i.e., most available capacity).
*
*/
function selectBestExitNode(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
const MIN_CAPACITY_THRESHOLD = 0.1;
const LATENCY_TOLERANCE_MS = 30;
const LATENCY_TOLERANCE_PERCENT = 0.15;
let bestNode = null; // Filter out invalid nodes
let bestScore = -1e12; const validNodes = pingResults.filter((n) => !n.error && n.weight > 0);
let bestLatency = 1e12;
const candidateNodes = [];
// Calculate scores for each valid node if (validNodes.length === 0) {
for (const result of pingResults) { logger.error("No valid exit nodes available");
// Skip nodes with errors or invalid weight
if (result.error || result.weight <= 0) {
continue;
}
const latencyMs = result.latencyMs;
let score = result.weight / Math.pow(latencyMs, LATENCY_PENALTY_EXPONENT);
// Apply boost if this was the previously connected node
if (result.wasPreviouslyConnected === true) {
score *= LAST_NODE_SCORE_BOOST;
}
logger.info(`Exit node ${result.exitNodeName} with score: ${score.toFixed(2)} (latency: ${latencyMs}ms, weight: ${result.weight.toFixed(2)})`);
candidateNodes.push({
node: result,
score: score,
latency: latencyMs
});
// Track the best scoring node
if (score > bestScore) {
bestScore = score;
bestLatency = latencyMs;
bestNode = result;
} else if (score === bestScore && latencyMs < bestLatency) {
bestLatency = latencyMs;
bestNode = result;
}
}
// Check if the previously connected node is close enough in score to stick with it
for (const candidate of candidateNodes) {
if (candidate.node.wasPreviouslyConnected) {
const scoreDifference = bestScore - candidate.score;
const tolerance = bestScore * (SCORE_TOLERANCE_PERCENT / 100.0);
if (scoreDifference <= tolerance) {
logger.info(`Sticking with last used exit node: ${candidate.node.exitNodeName} (${candidate.node.endpoint}), score close enough to best`);
bestNode = candidate.node;
}
break;
}
}
if (bestNode === null) {
logger.error("No suitable exit node found");
return null; return null;
} }
logger.info(`Selected exit node: ${bestNode.exitNodeName} (${bestNode.endpoint})`); // Sort by latency (ascending)
return bestNode; const sortedNodes = validNodes
.slice()
.sort((a, b) => a.latencyMs - b.latencyMs);
const lowestLatencyNode = sortedNodes[0];
logger.info(
`Lowest latency node: ${lowestLatencyNode.exitNodeName} (${lowestLatencyNode.latencyMs} ms, weight=${lowestLatencyNode.weight.toFixed(2)})`
);
// If lowest latency node has enough capacity, check if previously connected node is acceptable
if (lowestLatencyNode.weight >= MIN_CAPACITY_THRESHOLD) {
const previouslyConnectedNode = sortedNodes.find(
(n) =>
n.wasPreviouslyConnected && n.weight >= MIN_CAPACITY_THRESHOLD
);
if (previouslyConnectedNode) {
const latencyDiff =
previouslyConnectedNode.latencyMs - lowestLatencyNode.latencyMs;
const percentDiff = latencyDiff / lowestLatencyNode.latencyMs;
if (
latencyDiff <= LATENCY_TOLERANCE_MS ||
percentDiff <= LATENCY_TOLERANCE_PERCENT
) {
logger.info(
`Sticking with previously connected node: ${previouslyConnectedNode.exitNodeName} ` +
`(${previouslyConnectedNode.latencyMs} ms), latency diff = ${latencyDiff.toFixed(1)}ms ` +
`/ ${(percentDiff * 100).toFixed(1)}%.`
);
return previouslyConnectedNode;
}
}
return lowestLatencyNode;
}
// Otherwise, find the next node (after the lowest) that has enough capacity
for (let i = 1; i < sortedNodes.length; i++) {
const node = sortedNodes[i];
if (node.weight >= MIN_CAPACITY_THRESHOLD) {
logger.info(
`Lowest latency node under capacity. Using next best: ${node.exitNodeName} ` +
`(${node.latencyMs} ms, weight=${node.weight.toFixed(2)})`
);
return node;
}
}
// Fallback: pick the highest weight node
const fallbackNode = validNodes.reduce((a, b) =>
a.weight > b.weight ? a : b
);
logger.warn(
`No nodes with ≥10% weight. Falling back to highest capacity node: ${fallbackNode.exitNodeName}`
);
return fallbackNode;
} }