Skip to content

Commit 474e7e4

Browse files
committed
feat(server): PROXY protocol v1/v2 support
1 parent 4325ee6 commit 474e7e4

6 files changed

Lines changed: 611 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
44

55
### Unreleased
66

7+
- feat(server): PROXY protocol v1/v2 support #81
8+
79
### 2.2.1 - 2026-05-25
810

911
- fix(packet): use crypto.randomInt for Packet.uuid (RFC 5452)

lib/proxy-protocol.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
'use strict';
2+
3+
// PROXY protocol parser (HAProxy/Nginx).
4+
// Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
5+
//
6+
// parse(buffer) returns:
7+
// - { header, headerLength } when a complete header is at the start of buffer
8+
// - null when the buffer is a valid prefix but more bytes are needed
9+
// and throws when the bytes are not a valid PROXY header.
10+
11+
const V2_SIGNATURE = Buffer.from([
12+
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D,
13+
0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
14+
]);
15+
const V1_PREFIX = Buffer.from('PROXY ');
16+
const V1_MAX_LEN = 108;
17+
18+
const FAMILY = { 0x10: 'IPv4', 0x20: 'IPv6', 0x30: 'Unix' };
19+
const TRANSPORT = { 0x01: 'STREAM', 0x02: 'DGRAM' };
20+
21+
function parse(buffer) {
22+
if (buffer.length >= 12) {
23+
if (buffer.slice(0, 12).equals(V2_SIGNATURE)) return parseV2(buffer);
24+
} else if (V2_SIGNATURE.slice(0, buffer.length).equals(buffer)) {
25+
return null;
26+
}
27+
28+
if (buffer.length >= 6) {
29+
if (buffer.slice(0, 6).equals(V1_PREFIX)) return parseV1(buffer);
30+
} else if (V1_PREFIX.slice(0, buffer.length).equals(buffer)) {
31+
return null;
32+
}
33+
34+
throw new Error('PROXY protocol: header missing or malformed');
35+
}
36+
37+
function parseV1(buffer) {
38+
const search = buffer.slice(0, Math.min(buffer.length, V1_MAX_LEN));
39+
const newline = search.indexOf('\r\n');
40+
if (newline === -1) {
41+
if (buffer.length >= V1_MAX_LEN) {
42+
throw new Error('PROXY v1: header exceeds maximum length');
43+
}
44+
return null;
45+
}
46+
const line = buffer.slice(0, newline).toString('ascii');
47+
const parts = line.split(' ');
48+
if (parts[0] !== 'PROXY') throw new Error('PROXY v1: malformed header');
49+
const headerLength = newline + 2;
50+
51+
if (parts[1] === 'UNKNOWN') {
52+
return { header: { version: 1, command: 'UNKNOWN' }, headerLength };
53+
}
54+
if (parts.length !== 6) throw new Error('PROXY v1: malformed header');
55+
const [ , proto, sourceAddress, destinationAddress, srcPort, dstPort ] = parts;
56+
if (proto !== 'TCP4' && proto !== 'TCP6') {
57+
throw new Error(`PROXY v1: unsupported protocol ${proto}`);
58+
}
59+
return {
60+
header: {
61+
version : 1,
62+
command : 'PROXY',
63+
family : proto === 'TCP4' ? 'IPv4' : 'IPv6',
64+
transport : 'STREAM',
65+
sourceAddress,
66+
sourcePort : parseInt(srcPort, 10),
67+
destinationAddress,
68+
destinationPort : parseInt(dstPort, 10),
69+
},
70+
headerLength,
71+
};
72+
}
73+
74+
function parseV2(buffer) {
75+
if (buffer.length < 16) return null;
76+
const verCmd = buffer[12];
77+
const version = verCmd >> 4;
78+
const command = verCmd & 0x0F;
79+
if (version !== 2) throw new Error(`PROXY v2: unsupported version ${version}`);
80+
if (command !== 0 && command !== 1) {
81+
throw new Error(`PROXY v2: unknown command ${command}`);
82+
}
83+
84+
const famProto = buffer[13];
85+
const addressLength = buffer.readUInt16BE(14);
86+
const headerLength = 16 + addressLength;
87+
if (buffer.length < headerLength) return null;
88+
89+
if (command === 0) {
90+
// LOCAL — no real client info (e.g. proxy-originated health check).
91+
return { header: { version: 2, command: 'LOCAL' }, headerLength };
92+
}
93+
94+
const family = FAMILY[famProto & 0xF0];
95+
const transport = TRANSPORT[famProto & 0x0F];
96+
97+
let sourceAddress, destinationAddress, sourcePort, destinationPort;
98+
if (family === 'IPv4' && addressLength >= 12) {
99+
sourceAddress = `${buffer[16]}.${buffer[17]}.${buffer[18]}.${buffer[19]}`;
100+
destinationAddress = `${buffer[20]}.${buffer[21]}.${buffer[22]}.${buffer[23]}`;
101+
sourcePort = buffer.readUInt16BE(24);
102+
destinationPort = buffer.readUInt16BE(26);
103+
} else if (family === 'IPv6' && addressLength >= 36) {
104+
sourceAddress = ipv6FromBytes(buffer.slice(16, 32));
105+
destinationAddress = ipv6FromBytes(buffer.slice(32, 48));
106+
sourcePort = buffer.readUInt16BE(48);
107+
destinationPort = buffer.readUInt16BE(50);
108+
} else {
109+
throw new Error(`PROXY v2: unsupported address family/protocol 0x${famProto.toString(16)}`);
110+
}
111+
112+
return {
113+
header: {
114+
version : 2,
115+
command : 'PROXY',
116+
family,
117+
transport,
118+
sourceAddress,
119+
sourcePort,
120+
destinationAddress,
121+
destinationPort,
122+
},
123+
headerLength,
124+
};
125+
}
126+
127+
function ipv6FromBytes(bytes) {
128+
const segments = [];
129+
for (let i = 0; i < 16; i += 2) {
130+
segments.push(bytes.readUInt16BE(i).toString(16));
131+
}
132+
return segments.join(':');
133+
}
134+
135+
// Test helpers — build wire-format headers used by tests and example code.
136+
function buildV1({ family = 'TCP4', sourceAddress, destinationAddress, sourcePort, destinationPort }) {
137+
return Buffer.from(`PROXY ${family} ${sourceAddress} ${destinationAddress} ${sourcePort} ${destinationPort}\r\n`, 'ascii');
138+
}
139+
140+
function buildV2Ipv4({ sourceAddress, destinationAddress, sourcePort, destinationPort, transport = 'STREAM' }) {
141+
const buf = Buffer.alloc(16 + 12);
142+
V2_SIGNATURE.copy(buf, 0);
143+
buf[12] = 0x21; // version 2 | PROXY command
144+
buf[13] = 0x10 | (transport === 'DGRAM' ? 0x02 : 0x01); // IPv4 | STREAM/DGRAM
145+
buf.writeUInt16BE(12, 14);
146+
sourceAddress.split('.').forEach((o, i) => { buf[16 + i] = parseInt(o, 10); });
147+
destinationAddress.split('.').forEach((o, i) => { buf[20 + i] = parseInt(o, 10); });
148+
buf.writeUInt16BE(sourcePort, 24);
149+
buf.writeUInt16BE(destinationPort, 26);
150+
return buf;
151+
}
152+
153+
module.exports = { parse, parseV1, parseV2, buildV1, buildV2Ipv4 };

server/tcp.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
const tcp = require('node:net');
22
const Packet = require('../packet');
3+
const proxyProtocol = require('../lib/proxy-protocol');
34

45
class Server extends tcp.Server {
56
constructor(options) {
67
super();
8+
let proxyProtocolEnabled = false;
9+
if (typeof options === 'object' && options !== null) {
10+
proxyProtocolEnabled = options.proxyProtocol ?? false;
11+
}
712
if (typeof options === 'function') {
813
this.on('request', options);
914
}
15+
this.proxyProtocol = proxyProtocolEnabled;
1016
this.on('connection', this.handle.bind(this));
1117
}
1218

1319
async handle(client) {
1420
try {
21+
if (this.proxyProtocol) {
22+
const header = await consumeProxyHeader(client);
23+
client.proxy = header;
24+
if (header.command === 'PROXY') {
25+
client.proxyAddress = header.sourceAddress;
26+
client.proxyPort = header.sourcePort;
27+
}
28+
}
1529
const data = await Packet.readStream(client);
1630
const message = Packet.parse(data);
1731
this.emit('request', message, this.response.bind(this, client), client);
@@ -31,4 +45,61 @@ class Server extends tcp.Server {
3145
}
3246
}
3347

48+
// Read and consume the PROXY header from the front of the socket's stream.
49+
// Any bytes that arrive past the header are unshifted back into the socket
50+
// so the next reader (Packet.readStream) sees them.
51+
function consumeProxyHeader(socket) {
52+
return new Promise((resolve, reject) => {
53+
const chunks = [];
54+
let chunklen = 0;
55+
let done = false;
56+
57+
const cleanup = () => {
58+
socket.removeListener('readable', onReadable);
59+
socket.removeListener('end', onEnd);
60+
socket.removeListener('error', onError);
61+
};
62+
const onError = err => {
63+
if (done) return;
64+
done = true;
65+
cleanup();
66+
reject(err);
67+
};
68+
const onEnd = () => {
69+
if (done) return;
70+
done = true;
71+
cleanup();
72+
reject(new Error('PROXY protocol: stream ended before header complete'));
73+
};
74+
const onReadable = () => {
75+
if (done) return;
76+
let chunk;
77+
while ((chunk = socket.read()) !== null) {
78+
chunks.push(chunk);
79+
chunklen += chunk.length;
80+
}
81+
if (chunklen === 0) return;
82+
const buffer = Buffer.concat(chunks, chunklen);
83+
let parsed;
84+
try {
85+
parsed = proxyProtocol.parse(buffer);
86+
} catch (e) {
87+
return onError(e);
88+
}
89+
if (!parsed) return;
90+
done = true;
91+
cleanup();
92+
const leftover = buffer.slice(parsed.headerLength);
93+
if (leftover.length) socket.unshift(leftover);
94+
resolve(parsed.header);
95+
};
96+
97+
socket.on('readable', onReadable);
98+
socket.on('end', onEnd);
99+
socket.on('error', onError);
100+
// Drain anything already buffered before our 'readable' listener attached.
101+
onReadable();
102+
});
103+
}
104+
34105
module.exports = Server;

server/udp.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const udp = require('node:dgram');
22
const Packet = require('../packet');
3+
const proxyProtocol = require('../lib/proxy-protocol');
34

45
/**
56
* [Server description]
@@ -9,10 +10,13 @@ const Packet = require('../packet');
910
class Server extends udp.Socket {
1011
constructor(options) {
1112
let type = 'udp4';
12-
if (typeof options === 'object') {
13+
let proxyProtocolEnabled = false;
14+
if (typeof options === 'object' && options !== null) {
1315
type = options.type ?? type;
16+
proxyProtocolEnabled = options.proxyProtocol ?? false;
1417
}
1518
super(type);
19+
this.proxyProtocol = proxyProtocolEnabled;
1620
if (typeof options === 'function') {
1721
this.on('request', options);
1822
}
@@ -21,8 +25,28 @@ class Server extends udp.Socket {
2125

2226
handle(data, rinfo) {
2327
try {
28+
// Response is always sent back to the immediate sender (the proxy when
29+
// proxyProtocol is enabled); the parsed client info is exposed to the
30+
// request handler so it can log/authorize against the real peer.
31+
const responder = rinfo;
32+
let clientInfo = rinfo;
33+
if (this.proxyProtocol) {
34+
const parsed = proxyProtocol.parse(data);
35+
if (!parsed) throw new Error('PROXY protocol: incomplete header');
36+
if (parsed.header.command === 'PROXY') {
37+
clientInfo = {
38+
...rinfo,
39+
address : parsed.header.sourceAddress,
40+
port : parsed.header.sourcePort,
41+
proxy : parsed.header,
42+
};
43+
} else {
44+
clientInfo = { ...rinfo, proxy: parsed.header };
45+
}
46+
data = data.slice(parsed.headerLength);
47+
}
2448
const message = Packet.parse(data);
25-
this.emit('request', message, this.response.bind(this, rinfo), rinfo);
49+
this.emit('request', message, this.response.bind(this, responder), clientInfo);
2650
} catch (e) {
2751
this.emit('requestError', e);
2852
}

0 commit comments

Comments
 (0)