Skip to content

Core 08 net

Core 08 - Net Module (TCP)

Basic Idea

Before HTTP , before WebSockets , before all application protocols - there's TCP net module gives you raw TCP sockets Everything from HTTP servers to Redis clients to database drivers is built on this

TCP Server with net.createServer()

const net = require('net')

const server = net.createServer((socket) => {
  console.log('client connected from:', socket.remoteAddress)

  socket.on('data', (data) => {
    console.log('received:', data.toString())
    socket.write('echo: ' + data.toString())
  })

  socket.on('end', () => {
    console.log('client disconnected')
  })

  socket.on('error', (err) => {
    console.error('socket error:', err.message)
  })
})

server.listen(9000, () => {
  console.log('TCP server listening on port 9000')
})

net.createServer() creates a raw TCP server Every connection gets a new Socket object No HTTP parsing , no framing - just raw bytes going in and out

TCP Client with net.connect()

const net = require('net')

const client = net.connect(9000, 'localhost', () => {
  console.log('connected to server')
  client.write('hello from client')
})

client.on('data', (data) => {
  console.log('server says:', data.toString())
  client.end()
})

client.on('end', () => {
  console.log('disconnected from server')
})

client.on('error', (err) => {
  console.error('connection failed:', err.message)
})

net.connect(port, host) creates an outgoing TCP connection Same socket API as the server side - data , end , error events The connection is established before the callback fires

Socket - The TCP Interface

const net = require('net')
const server = net.createServer()

server.on('connection', (socket) => {
  // socket properties
  console.log('remote:', socket.remoteAddress, ':', socket.remotePort)
  console.log('local:', socket.localAddress, ':', socket.localPort)

  // write data
  socket.write('welcome\n')

  // set encoding - turns Buffer into string automatically
  socket.setEncoding('utf-8')

  // timeout
  socket.setTimeout(30000)
  socket.on('timeout', () => {
    console.log('socket timed out')
    socket.end()
  })

  // keep connection alive
  socket.setKeepAlive(true, 60000)
})

server.listen(9000)

Socket has address info , timeout controls , keep-alive setTimeout() for idle connection cleanup - critical for production setKeepAlive() sends TCP keep-alive probes to detect dead peers

Handling Partial Data

// TCP is a stream protocol - no message boundaries
// If you send "HELLO" and "WORLD" , you might receive "HELLOWORLD" or "HEL" then "LOWORLD"

const net = require('net')

// solution: frame your protocol with message length prefixes
const server = net.createServer((socket) => {
  let buffer = Buffer.alloc(0)

  socket.on('data', (chunk) => {
    buffer = Buffer.concat([buffer, chunk])

    // check if we have a complete message
    while (buffer.length >= 4) {
      const msgLen = buffer.readUInt32BE(0) // first 4 bytes = length
      if (buffer.length < 4 + msgLen) break  // not enough data yet

      const message = buffer.slice(4, 4 + msgLen)
      console.log('complete message:', message.toString())

      buffer = buffer.slice(4 + msgLen) // remove processed data
    }
  })
})

server.listen(9000)

// client sends length-prefixed messages
const client = net.connect(9000, () => {
  const msg = Buffer.from('ping')
  const header = Buffer.alloc(4)
  header.writeUInt32BE(msg.length)
  client.write(Buffer.concat([header, msg]))
})

TCP doesn't preserve message boundaries - it's a stream If you send two messages , you might receive them as one chunk or twenty chunks Always implement message framing: length prefix , delimiter , or fixed-size packets

Real Example: Simple Echo Server and Chat

const net = require('net')

// echo server
const echoServer = net.createServer((socket) => {
  socket.write('echo server ready (type quit to exit)\n')
  socket.pipe(socket) // echo everything back (dangerous without framing)
})

echoServer.listen(9001)

// multi-client chat server
const clients = new Set()

const chatServer = net.createServer((socket) => {
  clients.add(socket)
  broadcast(`${socket.remoteAddress} joined\n`)

  socket.on('data', (data) => {
    const msg = data.toString().trim()
    if (msg === '/quit') {
      socket.end()
      return
    }
    broadcast(`${socket.remoteAddress}: ${msg}\n`)
  })

  socket.on('close', () => {
    clients.delete(socket)
    broadcast(`${socket.remoteAddress} left\n`)
  })

  socket.on('error', () => {
    clients.delete(socket)
  })
})

function broadcast(message) {
  for (const client of clients) {
    client.write(message)
  }
}

chatServer.listen(9002)
console.log('chat server on port 9002')

socket.pipe(socket) is the simplest echo server - reads from socket , writes back Never use pipe() without size limits in production - socket bombs are a thing Chat server broadcasts to all connected clients - shows the pattern for pub/sub over TCP

Security: Unencrypted TCP

// ALL DATA IS CLEARTEXT
const client = net.connect(3306, 'db.internal', () => {
  // sending password as plaintext over TCP
  client.write('PASSWORD hunter2\n')
  // anyone on the network can read this with tcpdump
})

// MITM attack on raw TCP is trivial
// $ tcpdump -i eth0 port 9000 -X
// 0x0000:  50 41 53 53 57 4F 52 44 20 68 75 6E 74 65 72 32  PASSWORD hunter2
//
// No encryption = no security. Anyone with access to the network reads everything.

Raw TCP has zero security - no encryption , no authentication , no integrity checks Anyone with tcpdump on the network path reads all data in cleartext Always use TLS (via tls module) for anything crossing network boundaries Even on localhost , internal network segmentation isn't guaranteed in cloud environments

Security: Connection Flood DoS

const net = require('net')

// VULNERABLE - no connection limits
const server = net.createServer((socket) => {
  // attacker opens 10000 connections - server runs out of file descriptors
})

// DEFENSE - track and limit connections
const MAX_CONNECTIONS = 1000
let connections = 0

const server = net.createServer((socket) => {
  if (connections >= MAX_CONNECTIONS) {
    socket.write('server busy\n')
    socket.destroy()
    return
  }

  connections++
  socket.write('welcome\n')

  socket.on('close', () => {
    connections--
  })

  socket.on('error', () => {
    connections--
  })
})

server.listen(9000)

TCP servers are vulnerable to connection flood DoS Each connection consumes a file descriptor - default ulimit is often 1024 Also vulnerable to slow loris: open connections that never complete , keeping sockets alive Always set connection limits , timeouts , and rate limits on TCP servers

Summary

  • net.createServer() for TCP servers , net.connect() for clients
  • TCP has no message boundaries - implement framing (length prefix , delimiter)
  • socket.pipe() is convenient but dangerous without size limits
  • Raw TCP has zero security - always encrypt with tls module
  • Connection limits prevent fd exhaustion DoS

Prerequisites

next -> core_09_dns.md