diff --git a/docker-traefik-proxyprotocol/Dockerfile b/docker-traefik-proxyprotocol/Dockerfile new file mode 100644 index 0000000..953e947 --- /dev/null +++ b/docker-traefik-proxyprotocol/Dockerfile @@ -0,0 +1,23 @@ +# Specify the base image +FROM node:lts-alpine + +# Set the working directory inside the container +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package.json package-lock.json ./ + +# Install only production dependencies +RUN npm install --omit=dev + +# Copy the rest of application's source code +COPY src/ ./src + +# Copy local certificates +COPY *.pem . + +# Expose the port the app runs on +EXPOSE 3000 + +# Command to run your app +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/docker-traefik-proxyprotocol/docker-compose.yml b/docker-traefik-proxyprotocol/docker-compose.yml new file mode 100644 index 0000000..c76ed6e --- /dev/null +++ b/docker-traefik-proxyprotocol/docker-compose.yml @@ -0,0 +1,87 @@ +services: + traefik: + container_name: traefik + image: traefik:v3.3 + ports: + - 80:80 + - 443:443 + - 8000:8000 + - 8001:8001 + - 8002:8002 + networks: + - proxy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ~/certificates:/certificates + - /var/log:/var/log + command: + - --api.dashboard=true + - --api.insecure=true + - --log.level=DEBUG + #- --log.filepath=/var/log/traefik.log + - --accesslog=true + #- --accesslog.filepath=/var/log/traefik-access.log + - --providers.docker.network=proxy + - --providers.docker.exposedByDefault=false + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entryPoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.asDefault=true + - --entrypoints.websecure.http.tls.certresolver=myresolver + - --entrypoints.plaintcp0.address=:8000 + - --entrypoints.plaintcp1.address=:8001 + - --entrypoints.plaintcp2.address=:8002 + - --certificatesresolvers.myresolver.acme.email=mail@example.com + - --certificatesresolvers.myresolver.acme.tlschallenge=true + - --certificatesresolvers.myresolver.acme.storage=/certificates/acme.json + labels: + - traefik.enable=true + - traefik.http.routers.mydashboard.rule=Host(`traefik.example.com`) + - traefik.http.routers.mydashboard.service=api@internal + - traefik.http.routers.mydashboard.middlewares=myauth + - traefik.http.middlewares.myauth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/ + + whoami: + container_name: whoami + image: traefik/whoami:v1.10 + networks: + - proxy + labels: + - traefik.enable=true + - traefik.http.routers.mywhoami.rule=Host(`whoami.example.com`) + - traefik.http.services.mywhoami.loadbalancer.server.port=80 + + echo: + container_name: echo + build: + context: . + dockerfile: Dockerfile + networks: + - proxy + labels: + - traefik.enable=true + - traefik.http.routers.myecho.rule=Host(`echo.example.com`) + - traefik.http.routers.myecho.service=myecho + - traefik.http.services.myecho.loadbalancer.server.port=3000 + + - traefik.tcp.routers.myecho0.entrypoints=plaintcp0 + - traefik.tcp.routers.myecho0.rule=HostSNI(`*`) + - traefik.tcp.routers.myecho0.service=myecho0 + - traefik.tcp.services.myecho0.loadbalancer.server.port=3000 + + - traefik.tcp.routers.myecho1.entrypoints=plaintcp1 + - traefik.tcp.routers.myecho1.rule=HostSNI(`*`) + - traefik.tcp.routers.myecho1.service=myecho1 + - traefik.tcp.services.myecho1.loadbalancer.server.port=3000 + - traefik.tcp.services.myecho1.loadbalancer.proxyprotocol.version=1 + + - traefik.tcp.routers.myecho2.entrypoints=plaintcp2 + - traefik.tcp.routers.myecho2.rule=HostSNI(`*`) + - traefik.tcp.routers.myecho2.service=myecho2 + - traefik.tcp.services.myecho2.loadbalancer.server.port=3000 + - traefik.tcp.services.myecho2.loadbalancer.proxyprotocol.version=2 + +networks: + proxy: + name: proxy diff --git a/docker-traefik-proxyprotocol/package-lock.json b/docker-traefik-proxyprotocol/package-lock.json new file mode 100644 index 0000000..9f04914 --- /dev/null +++ b/docker-traefik-proxyprotocol/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "node-express-proxyprotocol-tls", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-express-proxyprotocol-tls", + "version": "1.0.0", + "license": "BSD" + } + } +} diff --git a/docker-traefik-proxyprotocol/package.json b/docker-traefik-proxyprotocol/package.json new file mode 100644 index 0000000..1a69409 --- /dev/null +++ b/docker-traefik-proxyprotocol/package.json @@ -0,0 +1,8 @@ +{ + "name": "node-express-proxyprotocol-tls", + "version": "1.0.0", + "license": "BSD", + "type": "module", + "dependencies": { + } +} diff --git a/docker-traefik-proxyprotocol/src/README.md b/docker-traefik-proxyprotocol/src/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docker-traefik-proxyprotocol/src/index.js b/docker-traefik-proxyprotocol/src/index.js new file mode 100644 index 0000000..4270bfe --- /dev/null +++ b/docker-traefik-proxyprotocol/src/index.js @@ -0,0 +1,211 @@ +// index.js (ESM) +import fs from 'fs'; +import net from 'net'; +import tls from 'tls'; +import { parseProxyProtocol } from './proxyprotocol.js'; + +// --------------------------------------------------------------------- +// Configuration: +const port = Number(process.env.PORT) || 3000; + +// If you have certs for TLS, put them in ./certs/key.pem & ./certs/cert.pem. +// If they don't exist, TLS connections will fail to handshake. +const keyPath = './private-key.pem'; +const certPath = './certificate.pem'; + +const haveTLSCerts = fs.existsSync(keyPath) && fs.existsSync(certPath); +let secureContext = null; +if (haveTLSCerts) { + secureContext = tls.createSecureContext({ + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }); +} + +// --------------------------------------------------------------------- +// Utility to parse an HTTP request from a buffer if it contains \r\n\r\n +function parseHttpRequest(buffer) { + const raw = buffer.toString('utf8'); + const headersEnd = raw.indexOf('\r\n\r\n'); + if (headersEnd === -1) { + // No complete HTTP header yet + return null; + } + // Separate the header part from any potential body + const headerPart = raw.slice(0, headersEnd); + const lines = headerPart.split('\r\n'); + + const [requestLine, ...headerLines] = lines; + const [method = '', path = '', version = ''] = requestLine.split(' '); + + const headers = {}; + for (const line of headerLines) { + const idx = line.indexOf(':'); + if (idx > 0) { + const key = line.slice(0, idx).trim().toLowerCase(); + const val = line.slice(idx + 1).trim(); + headers[key] = val; + } + } + + return { + http: { + method, + path, + version, + }, + headers, + // rawLength is how many bytes total were in the headers portion + rawLength: headersEnd + 4, // +4 for "\r\n\r\n" + }; +} + +// --------------------------------------------------------------------- +// Construct the JSON echo response +function buildHttpResponse(proxyInfo, isTLS, http, headers, socket) { + const responseData = { + proxyProtocolVersion: proxyInfo?.version || 'none', + connectionSourceIp: socket.remoteAddress, // from raw or TLS socket + proxyProtocolSourceIp: proxyInfo?.sourceIp || null, + isTLS, + http, + httpHeaders: headers, + }; + + const json = JSON.stringify(responseData, null, 2); + return ( + 'HTTP/1.1 200 OK\r\n' + + 'Content-Type: application/json\r\n' + + `Content-Length: ${Buffer.byteLength(json)}\r\n` + + '\r\n' + + json + ); +} + +// --------------------------------------------------------------------- +// Handle plain HTTP requests +function handlePlainHttp(socket, initialBuffer, proxyInfo) { + let buffer = initialBuffer; + + // Immediately check if the initial buffer already contains a full request + maybeRespond(); + + // If not complete, listen for further data + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + maybeRespond(); + }); + + function maybeRespond() { + const parsed = parseHttpRequest(buffer); + if (parsed) { + const { http, headers } = parsed; + const response = buildHttpResponse(proxyInfo, false, http, headers, socket); + socket.write(response); + socket.end(); + // Once we respond, we don't parse further requests on the same connection + } + } +} + +// --------------------------------------------------------------------- +// Handle TLS-wrapped HTTP requests +function handleTLSHttp(tlsSocket, initialBuffer, proxyInfo) { + let buffer = initialBuffer; + + // Check if the initial buffer (post-handshake) has a full request + maybeRespond(); + + tlsSocket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + maybeRespond(); + }); + + function maybeRespond() { + const parsed = parseHttpRequest(buffer); + if (parsed) { + const { http, headers } = parsed; + const response = buildHttpResponse(proxyInfo, true, http, headers, tlsSocket); + tlsSocket.write(response); + tlsSocket.end(); + } + } +} + +// --------------------------------------------------------------------- +// Main server: detect Proxy Protocol, then TLS vs. plain +function startServer() { + const server = net.createServer((rawSocket) => { + let buffer = Buffer.alloc(0); + let proxyInfo = null; + let protocolDetected = false; + + rawSocket.on('error', (err) => { + console.error('Raw socket error:', err); + }); + + rawSocket.on('data', (chunk) => { + // Accumulate data + buffer = Buffer.concat([buffer, chunk]); + + if (!protocolDetected) { + // 1) Attempt Proxy Protocol parse + const ppResult = parseProxyProtocol(buffer); + if (ppResult?.proxy) { + proxyInfo = ppResult.proxy; + buffer = buffer.slice(ppResult.bytesProcessed); + } + // If no valid PP, that's fine; we just continue + + // 2) Check if remainder looks like a TLS handshake + // Typically: 0x16 (Handshake), 0x03 (TLS major), 0x01..0x03 + let isTLS = false; + if (buffer.length >= 3) { + if (buffer[0] === 0x16 && buffer[1] === 0x03) { + isTLS = true; + } + } + + protocolDetected = true; + + // Switch to TLS or stay plain + if (isTLS && secureContext) { + // Wrap raw socket in a TLSSocket + const tlsSocket = new tls.TLSSocket(rawSocket, { + isServer: true, + secureContext, + }); + + // Immediately feed any data we've already read to the TLS engine + // (internal approach) + tlsSocket._handle?.receive(buffer); + + // Remove all listeners from raw socket so we don't double-consume data + rawSocket.removeAllListeners('data'); + + tlsSocket.on('error', (err) => { + console.error('TLS socket error:', err); + }); + + // Once TLS handshake completes: + tlsSocket.on('secure', () => { + // Now handle the actual HTTP + handleTLSHttp(tlsSocket, Buffer.alloc(0), proxyInfo); + }); + } else { + // Plain HTTP + rawSocket.removeAllListeners('data'); + handlePlainHttp(rawSocket, buffer, proxyInfo); + } + } + // If protocol already detected, do nothing here + }); + }); + + server.listen(port, () => { + console.log(`Server listening on port ${port}`); + console.log(`TLS certs found: ${haveTLSCerts ? 'Yes' : 'No'}`); + }); +} + +startServer(); diff --git a/docker-traefik-proxyprotocol/src/proxyprotocol.js b/docker-traefik-proxyprotocol/src/proxyprotocol.js new file mode 100644 index 0000000..62f668e --- /dev/null +++ b/docker-traefik-proxyprotocol/src/proxyprotocol.js @@ -0,0 +1,143 @@ +// proxyprotocol.js (ESM) + +export function parseProxyProtocol(buffer) { + // 1) Try Proxy Protocol v1 (starts with "PROXY ") + const text = buffer.toString('ascii', 0, Math.min(buffer.length, 108)); + if (text.startsWith('PROXY ')) { + const lineEnd = text.indexOf('\r\n'); + if (lineEnd === -1) { + return { error: 'Incomplete Proxy Protocol v1 header' }; + } + const line = text.substring(0, lineEnd); // e.g. "PROXY TCP4 1.2.3.4 5.6.7.8 12345 80" + const parts = line.split(' '); + if (parts.length >= 6) { + return { + proxy: { + version: 'v1', + protocol: parts[1], // e.g. "TCP4", "TCP6" + sourceIp: parts[2], + destinationIp: parts[3], + sourcePort: parts[4], + destinationPort: parts[5], + }, + bytesProcessed: lineEnd + 2, // +2 for "\r\n" + }; + } + return { error: 'Malformed Proxy Protocol v1 header' }; + } + + // 2) Try Proxy Protocol v2 + // Signature bytes: 0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A + if (buffer.length >= 16) { + const sig = buffer.slice(0, 12).toString('hex'); + const v2sig = '0d0a0d0a000d0a515549540a'; + if (sig === v2sig) { + const verCmd = buffer[12]; + const version = verCmd >> 4; // High 4 bits => version + if (version !== 2) { + return { error: 'Invalid Proxy Protocol v2 header (version != 2)' }; + } + + // Byte 13 => family & protocol + const familyByte = buffer[13]; + // Byte 14..15 => length of remaining header data + const len = buffer.readUInt16BE(14); + const totalV2HeaderLen = 16 + len; + if (buffer.length < totalV2HeaderLen) { + return { error: 'Incomplete Proxy Protocol v2 header' }; + } + + // The upper 4 bits = address family; the lower 4 bits = transport protocol + // 0x1x => AF_INET (IPv4) + // 0x2x => AF_INET6 (IPv6) + // 0x0x => AF_UNSPEC + // plus the lower bits: 1 => STREAM (TCP), 2 => DGRAM (UDP), etc. + const addressFamily = (familyByte & 0xf0) >> 4; // top nibble + const transportProto = familyByte & 0x0f; // bottom nibble + + let sourceIp = null; + let destinationIp = null; + let sourcePort = null; + let destinationPort = null; + + // The address block starts at offset 16 + // For IPv4 + TCP/UDP: 4 bytes src IP, 4 bytes dst IP, 2 bytes src port, 2 bytes dst port (total 12) + // For IPv6 + TCP/UDP: 16 bytes src IP,16 bytes dst IP, 2 bytes src port, 2 bytes dst port (total 36) + let offset = 16; + + if (addressFamily === 0x1) { + // IPv4 + if (len >= 12) { + // 4 bytes src IP + const srcBuf = buffer.slice(offset, offset + 4); + offset += 4; + // 4 bytes dst IP + const dstBuf = buffer.slice(offset, offset + 4); + offset += 4; + // 2 bytes src port + sourcePort = buffer.readUInt16BE(offset); + offset += 2; + // 2 bytes dst port + destinationPort = buffer.readUInt16BE(offset); + offset += 2; + + sourceIp = srcBuf.join('.'); + destinationIp = dstBuf.join('.'); + } + } else if (addressFamily === 0x2) { + // IPv6 + if (len >= 36) { + // 16 bytes src IP + const srcBuf = buffer.slice(offset, offset + 16); + offset += 16; + // 16 bytes dst IP + const dstBuf = buffer.slice(offset, offset + 16); + offset += 16; + // 2 bytes src port + sourcePort = buffer.readUInt16BE(offset); + offset += 2; + // 2 bytes dst port + destinationPort = buffer.readUInt16BE(offset + 2); + + // Convert buffer to IPv6 string + sourceIp = bufToIPv6(srcBuf); + destinationIp = bufToIPv6(dstBuf); + } + } else { + // Family was 0x0 (UNSPEC) or 0x4 (AF_UNIX), etc. + // We won't parse addresses here. + // In that case, the official spec says no address information is carried. + } + + return { + proxy: { + version: 'v2', + sourceIp, + destinationIp, + sourcePort, + destinationPort, + family: addressFamily, // 1 => IPv4, 2 => IPv6 + protocol: transportProto, // 1 => TCP, 2 => UDP, ... + }, + bytesProcessed: totalV2HeaderLen, + }; + } + } + + // If none recognized + return { error: 'No Proxy Protocol header' }; +} + +// Helper function to convert a 16-byte IPv6 buffer into a readable string +function bufToIPv6(buf) { + // Split into eight 16-bit words + const parts = []; + for (let i = 0; i < 16; i += 2) { + parts.push(buf.readUInt16BE(i).toString(16)); + } + // Then compress the longest run of zeros + const ipv6Str = parts.join(':') + .replace(/(^|:)0+([0-9a-f])/g, '$1$2') // remove leading zeros in each group + .replace(/(:0+)+:/, '::'); // collapse multiple groups of zeros + return ipv6Str; +}