Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const config: NodelinkConfig = {
baseCapacity: 500,
refillRatePerSecond: 100,
maxConcurrentSockets: 25,
maxProxySockets: 1024,
ipv6SubnetMask: 64,
blockScoreThreshold: 30,
blockDurationMs: 300000,
Expand Down
1 change: 1 addition & 0 deletions dist/config.default.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const config = {
baseCapacity: 500,
refillRatePerSecond: 100,
maxConcurrentSockets: 25,
maxProxySockets: 1024,
ipv6SubnetMask: 64,
blockScoreThreshold: 30,
blockDurationMs: 300000,
Expand Down
10 changes: 6 additions & 4 deletions dist/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PATH_VERSION } from '../constants.js';
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { logger, sendErrorResponse, sendResponse, verifyMethod } from '../utils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -171,10 +172,11 @@ async function requestHandler(nodelink, req, res) {
}
nodelink.statsManager.incrementApiRequest(parsedUrl.pathname);
const trace = parsedUrl.searchParams.get('trace') === 'true';
const remoteAddress = req.socket?.remoteAddress ?? 'unknown';
const remotePort = req.socket?.remotePort;
const isInternal = ['127.0.0.1', '::1', 'localhost'].includes(remoteAddress);
const clientAddress = `${isInternal ? '[Internal]' : '[External]'} (${remoteAddress}:${remotePort ?? 'unknown'})`;
const remoteAddress = nodelink.admissionManager.resolveClientAddress(req) ?? 'unknown';
const isProxied = nodelink.admissionManager.isTrustedProxy(req.socket?.remoteAddress);
const remotePort = isProxied ? undefined : req.socket?.remotePort;
const isInternal = isLoopbackRequest(req.socket?.remoteAddress, req.headers);
const clientAddress = `${isInternal ? '[Internal]' : '[External]'} (${remoteAddress}${remotePort ? `:${remotePort}` : ''})`;
const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const originalEnd = res.end.bind(res);
res.end = (...args) => {
Expand Down
10 changes: 3 additions & 7 deletions dist/src/api/profiler.file.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { sendErrorResponse, sendResponse } from '../utils.js';
/**
* Loopback addresses allowed to access the profiler endpoints when external
* access is disabled.
*/
const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Returns whether the provided body value is a plain object record.
*
Expand Down Expand Up @@ -210,8 +206,8 @@ async function handler(nodelink, req, res, _sendResponse, parsedUrl) {
sendErrorResponse(req, res, 403, 'Forbidden', 'Profiler endpoint is disabled.', parsedUrl.pathname);
return;
}
const remoteAddress = req.socket?.remoteAddress ?? '';
if (!endpointConfig.allowExternalPatch && !LOOPBACKS.has(remoteAddress)) {
const isLocal = isLoopbackRequest(req.socket?.remoteAddress, req.headers);
if (!endpointConfig.allowExternalPatch && !isLocal) {
sendErrorResponse(req, res, 403, 'Forbidden', 'External access to profiler file endpoint is blocked.', parsedUrl.pathname);
return;
}
Expand Down
6 changes: 3 additions & 3 deletions dist/src/api/profiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import fsPromises from 'node:fs/promises';
import inspector from 'node:inspector';
import os from 'node:os';
import v8 from 'node:v8';
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { sendErrorResponse, sendResponse } from '../utils.js';
const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const { NODELINK_PROFILER_DIR: profilerDirectoryEnv } = process.env;
const profilerBaseDir = profilerDirectoryEnv || '.profiles';
let activeMasterCpu = null;
Expand Down Expand Up @@ -190,8 +190,8 @@ function validateAccess(nodelink, req, suppliedCode) {
if (!endpointConfig.patchEnabled) {
return { ok: false, error: 'Profiler endpoint is disabled.' };
}
const remoteAddress = req.socket?.remoteAddress || '';
if (!endpointConfig.allowExternalPatch && !LOOPBACKS.has(remoteAddress)) {
const isLocal = isLoopbackRequest(req.socket?.remoteAddress, req.headers);
if (!endpointConfig.allowExternalPatch && !isLocal) {
return {
ok: false,
error: 'External access to profiler endpoint is blocked.'
Expand Down
6 changes: 3 additions & 3 deletions dist/src/api/profiler.ui.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { sendErrorResponse } from '../utils.js';
const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
Expand Down Expand Up @@ -2366,8 +2366,8 @@ async function handler(nodelink, req, res, _sendResponse, parsedUrl) {
if (!endpointConfig.patchEnabled) {
return sendErrorResponse(req, res, 403, 'Forbidden', 'Profiler endpoint is disabled.', parsedUrl.pathname);
}
const remoteAddress = req.socket?.remoteAddress || '';
if (!endpointConfig.allowExternalPatch && !LOOPBACKS.has(remoteAddress)) {
const isLocal = isLoopbackRequest(req.socket?.remoteAddress, req.headers);
if (!endpointConfig.allowExternalPatch && !isLocal) {
return sendErrorResponse(req, res, 403, 'Forbidden', 'External access to profiler UI is blocked.', parsedUrl.pathname);
}
const code = parsedUrl.searchParams.get('code');
Expand Down
10 changes: 3 additions & 7 deletions dist/src/api/workers.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { sendErrorResponse, sendResponse } from '../utils.js';
/**
* Loopback addresses allowed to access the workers patch endpoint when
* external patching is disabled.
*/
const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Creates a strongly typed runtime view for the workers endpoint.
*
Expand Down Expand Up @@ -151,8 +147,8 @@ function handlePatch(nodelink, req, res, parsedUrl) {
sendErrorResponse(req, res, 403, 'Forbidden', 'Workers patch endpoint is disabled.', parsedUrl.pathname);
return;
}
const remoteAddress = req.socket?.remoteAddress ?? '';
if (!endpointConfig.allowExternalPatch && !LOOPBACKS.has(remoteAddress)) {
const isLocal = isLoopbackRequest(req.socket?.remoteAddress, req.headers);
if (!endpointConfig.allowExternalPatch && !isLocal) {
sendErrorResponse(req, res, 403, 'Forbidden', 'External access to the workers patch endpoint is blocked.', parsedUrl.pathname);
return;
}
Expand Down
130 changes: 114 additions & 16 deletions dist/src/managers/admissionManager.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeAddress, resolveClientAddress, TrustedProxyList } from '../utils/clientAddress.js';
import { logger } from '../utils.js';
const DEFAULT_CONFIG = {
enabled: true,
Expand Down Expand Up @@ -38,6 +39,7 @@ const DEFAULT_CONFIG = {
baseCapacity: 500,
refillRatePerSecond: 100,
maxConcurrentSockets: 25,
maxProxySockets: 1024,
ipv6SubnetMask: 64,
blockScoreThreshold: 30,
blockDurationMs: 300000,
Expand Down Expand Up @@ -90,6 +92,8 @@ export default class AdmissionManager {
guildStates;
sessionStates;
ipStates;
proxySockets;
trustedProxies;
globalState;
cleanupInterval;
constructor(nodelink, config) {
Expand All @@ -98,6 +102,8 @@ export default class AdmissionManager {
this.guildStates = new Map();
this.sessionStates = new Map();
this.ipStates = new Map();
this.proxySockets = new Map();
this.trustedProxies = this._buildTrustedProxies();
const now = performance.now();
this.globalState = {
tokens: 300,
Expand Down Expand Up @@ -207,6 +213,92 @@ export default class AdmissionManager {
}
return this._buildAllowedDecision('ip', 100, 100);
}
/**
* Resolves the originating client address, honoring forwarding headers only
* from trusted proxies. Unlike admission keys, the result is not subnet-masked.
* @param req - Raw or shimmed API request.
*/
resolveClientAddress(req) {
return resolveClientAddress(req.socket?.remoteAddress, req.headers, this.trustedProxies);
}
/**
* Checks whether a TCP peer is a configured trusted proxy.
* @param rawAddress - Peer address.
*/
isTrustedProxy(rawAddress) {
return this.trustedProxies.contains(rawAddress);
}
/**
* Admits a new TCP connection. Trusted proxies share one aggregate pool, since
* per-client bans and limits are enforced per request once headers are known.
* Other peers are checked against their own block state and socket pool.
* @param rawAddress - Peer address.
* @returns Whether the connection may proceed; call releaseConnection on close.
*/
admitConnection(rawAddress) {
if (!this.trustedProxies.contains(rawAddress)) {
if (this.isIpBlocked(rawAddress))
return false;
return this.incrementActiveSockets(rawAddress);
}
const proxy = normalizeAddress(rawAddress);
const active = this.proxySockets.get(proxy) ?? 0;
const maxSockets = this.config.ip.maxProxySockets;
if (active >= maxSockets) {
logger('warn', 'AdmissionManager', `Trusted proxy ${proxy} exceeded aggregate socket pool (${active}/${maxSockets}). Dropping connection.`);
return false;
}
this.proxySockets.set(proxy, active + 1);
return true;
}
/**
* Releases a connection admitted by admitConnection.
* @param rawAddress - Peer address.
*/
releaseConnection(rawAddress) {
if (!this.trustedProxies.contains(rawAddress)) {
this.decrementActiveSockets(rawAddress);
return;
}
const proxy = normalizeAddress(rawAddress);
const next = (this.proxySockets.get(proxy) ?? 0) - 1;
if (next > 0) {
this.proxySockets.set(proxy, next);
}
else {
this.proxySockets.delete(proxy);
}
}
/**
* Reserves socket capacity for a WebSocket upgrade. Proxied clients share the
* proxy's TCP pool, so their own per-client pool is charged here.
* @param peerAddress - TCP peer address.
* @param clientKey - Resolved client admission key (AdmissionContext.ip).
* @param includePeer - Also admit the peer connection itself, for runtimes
* without a TCP-level connection hook (Bun).
* @returns An idempotent release callback, or null if capacity is exhausted.
*/
reserveUpgrade(peerAddress, clientKey, includePeer) {
const releases = [];
const release = () => {
while (releases.length > 0) {
releases.pop()?.();
}
};
if (includePeer) {
if (!this.admitConnection(peerAddress))
return null;
releases.push(() => this.releaseConnection(peerAddress));
}
if (clientKey && this.trustedProxies.contains(peerAddress)) {
if (!this.incrementActiveSockets(clientKey)) {
release();
return null;
}
releases.push(() => this.decrementActiveSockets(clientKey));
}
return release;
}
/**
* Tracks an incoming TCP socket. Returns false if IP connection pool is exhausted.
* @param rawAddress - Remote IP address.
Expand Down Expand Up @@ -878,26 +970,32 @@ export default class AdmissionManager {
return match?.[1] ?? null;
}
/**
* Resolves remote IP with edge proxy header precedence.
* Resolves the admission key for a request's client address.
* @internal
*/
_resolveIp(req) {
const socketAddress = req.socket?.remoteAddress;
const trustProxyEnabled = this.config.trustProxy === true;
if (!trustProxyEnabled) {
return this._normalizeIp(socketAddress);
return this._normalizeIp(this.resolveClientAddress(req));
}
/**
* Builds the trusted proxy matcher and reports misconfiguration.
* @internal
*/
_buildTrustedProxies() {
const { trustProxy, trustedProxies } = this.config;
if (!trustProxy) {
if (trustedProxies.length > 0) {
logger('warn', 'AdmissionManager', 'admission.trustedProxies is set but admission.trustProxy is false; forwarding headers are ignored.');
}
return new TrustedProxyList();
}
const list = new TrustedProxyList(trustedProxies);
for (const entry of list.invalidEntries) {
logger('warn', 'AdmissionManager', `Ignoring invalid admission.trustedProxies entry: ${entry}`);
}
if (list.size === 0) {
logger('warn', 'AdmissionManager', 'admission.trustProxy is enabled but admission.trustedProxies has no valid entries; forwarding headers are ignored.');
}
const headers = req.headers;
const cfConnectingIp = this._getHeader(headers, 'cf-connecting-ip');
const trueClientIp = this._getHeader(headers, 'true-client-ip');
const xRealIp = this._getHeader(headers, 'x-real-ip');
const forwardedFor = this._getHeader(headers, 'x-forwarded-for');
const candidate = cfConnectingIp ??
trueClientIp ??
xRealIp ??
forwardedFor?.split(',')?.[0]?.trim() ??
socketAddress;
return this._normalizeIp(candidate);
return list;
}
/**
* Normalizes IP and applies IPv6 /64 subnet mask.
Expand Down
36 changes: 27 additions & 9 deletions dist/src/server/bunServer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
import { isLoopbackRequest } from '../utils/clientAddress.js';
import { logger, parseClient, verifyDiscordID } from '../utils.js';
const VOICE_PATH_RE = /^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/;
const LIVE_PATH_RE = /^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/;
Expand Down Expand Up @@ -134,7 +135,7 @@ export function createBunServer(context, getRequestHandler) {
: url.pathname;
if (pathname === '/v4/profiler/socket') {
const remoteAddress = server.requestIP(req)?.address || 'unknown';
const isInternal = /^(::1|localhost|127\.0\.0\.1)/.test(remoteAddress);
const isInternal = isLoopbackRequest(remoteAddress, Object.fromEntries(req.headers));
const endpoint = context.options.cluster?.endpoint || {};
const patchEnabled = endpoint.patchEnabled === true;
const allowExternalPatch = endpoint.allowExternalPatch === true;
Expand Down Expand Up @@ -182,7 +183,15 @@ export function createBunServer(context, getRequestHandler) {
const liveMatch = pathname.match(LIVE_PATH_RE);
const isMainWs = pathname === '/v4/websocket';
if (isMainWs || voiceMatch || liveMatch) {
const remoteAddress = server.requestIP(req)?.address || 'unknown';
const peerAddress = server.requestIP(req)?.address || 'unknown';
const upgradeReqShim = {
method: req.method,
url: req.url,
headers: Object.fromEntries(req.headers),
socket: { remoteAddress: peerAddress }
};
const remoteAddress = context.admissionManager.resolveClientAddress(upgradeReqShim) ??
peerAddress;
const clientAddress = `[External] (${remoteAddress})`;
const isIpBlocked = context.admissionManager.isIpBlocked(remoteAddress);
if (isIpBlocked) {
Expand All @@ -191,12 +200,6 @@ export function createBunServer(context, getRequestHandler) {
statusText: 'Forbidden'
});
}
const upgradeReqShim = {
method: req.method,
url: req.url,
headers: Object.fromEntries(req.headers),
socket: { remoteAddress }
};
const admissionContext = context.admissionManager.resolveContext(upgradeReqShim, url);
const admissionDecision = context.admissionManager.admit(admissionContext);
if (!admissionDecision.allowed) {
Expand Down Expand Up @@ -284,6 +287,18 @@ export function createBunServer(context, getRequestHandler) {
logger('warn', 'Server', `Session-ID provided by ${clientAddress} does not exist or is not resumable: ${sessionId}, creating a new session`);
sessionId = null;
}
/* INFO: Bun has no TCP connection hook, so peer and per-client capacity are reserved per upgrade */
const releaseCapacity = context.admissionManager.reserveUpgrade(peerAddress, admissionContext.ip, true);
if (!releaseCapacity) {
return new Response('Too many concurrent connections.', {
status: 429,
statusText: 'Too Many Requests',
headers: {
'Nodelink-Api-Version': '4',
IamNodelink: 'true'
}
});
}
const success = server.upgrade(req, {
data: {
clientInfo,
Expand All @@ -293,11 +308,13 @@ export function createBunServer(context, getRequestHandler) {
url: req.url,
pathname,
eventName,
routeId
routeId,
releaseCapacity
}
});
if (success)
return undefined;
releaseCapacity();
return new Response('WebSocket upgrade failed', {
status: 400,
headers: {
Expand Down Expand Up @@ -475,6 +492,7 @@ export function createBunServer(context, getRequestHandler) {
wrapper._handleMessage(message);
},
close(ws, code, reason) {
ws.data?.releaseCapacity?.();
const wrapper = ws.data?.wrapper;
if (!wrapper) {
logger('debug', 'WebSocket', `Bun close received without wrapper (code: ${code}, remote: ${ws.data?.remoteAddress || 'unknown'})`);
Expand Down
Loading
Loading