punch.lua

April 24, 2026 · View on GitHub

Pure-Lua P2P NAT Traversal & Encrypted Channels

punch.lua is a lightweight library for establishing direct UDP connections between peers behind NAT/firewalls without manual port forwarding, VPNs, or external tunneling binaries.

Designed for LuaJIT scripts, automation tools, or Neovim plugins requiring secure P2P communication.

Features

  • NAT Traversal: Coordinated UDP hole punching via STUN (RFC 5389) and ICE-lite candidate logic.
  • Security: End-to-end encrypted channels using AES-256-GCM + X25519 ECDH key exchange (via OpenSSL FFI). No pre-shared key required.
  • Resilience: Automatic fallback to a WebSocket relay broker when symmetric NATs block direct traversal. UDP keepalives + dead-peer detection.
  • HTTP Signaling: Optional built-in local HTTP signaling server — expose it with ngrok, bore, or localhost.run for internet-wide rendezvous without a separate backend. HTTPS tunnels are supported transparently (requires OpenSSL).
  • Asynchronous: Non-blocking I/O via luv (libuv). Works with vim.uv/vim.loop (Neovim ≥ 0.10) or standalone luv.

Installation

luarocks install punch

Requirements

  • LuaJIT with FFI support
  • OpenSSL (libssl + libcrypto) for encryption, HTTPS, and WSS
  • libuv via luv (LuaRocks) or vim.uv (Neovim built-in)

Quick Start

Candidate gathering is asynchronous. Register your callbacks first, then start the session. When gathering completes the session enters the "ready" state and get_local_description() becomes available.

local uv  = require("luv")
local p2p = require("punch")

local s = p2p.start({
  stun = { "stun.l.google.com:19302", "stun1.l.google.com:19302" },
})

-- Step 1: once gathering is done, share your description out-of-band.
s:on("state_change", function(new)
  if new == "ready" then
    local desc = s:get_local_description()
    print("Send this to your peer:", desc)
  end
end)

-- Step 2: after receiving the remote description from your peer:
--   s:set_remote_description(their_desc)

s:on("open", function()
  local pair = s:get_selected_pair()
  if pair then
    print(string.format("P2P open: %s:%d → %s:%d",
      pair.local_cand.addr,  pair.local_cand.port,
      pair.remote_cand.addr, pair.remote_cand.port))
  end
  s:send("Hello!")
end)

s:on("message", function(data) print("Got:", data) end)
s:on("close",   function(why)  print("Closed:", why) end)
s:on("error",   function(err)  print("Error:", err.message or err) end)

uv.run()

Config reference

KeyDefaultDescription
stun"stun.l.google.com:19302"STUN server "host:port" or a list — tried in order
relaynilRelay broker "ws://…" (no relay fallback if nil)
relay_timeout30000ms to wait for relay peer
keyauto32-byte AES-256 key (auto-derived from ECDH if nil)
port0Local UDP port (0 = OS picks)
probe{interval=500, timeout=5000}Hole-punch probe timing (ms)
timeout0Global session timeout in ms (0 = no limit)
keepalive_interval5000ms between UDP keepalive pings
peer_timeout30000ms of silence before the session is closed
debugfalseWrite structured logs to debug_log path
debug_log"/tmp/punch.log"Log file path (when debug = true)

Session API

Method / propertyDescription
s:get_local_description()Returns the description string to share with the remote peer. Available after the "ready" state.
s:set_remote_description(str)Parses the remote description and starts connectivity checks.
s:on(event, fn)Register a callback. Events: "open", "message", "close", "error", "state_change"
s:get_selected_pair()Returns {local_cand, remote_cand} for the winning ICE pair, or nil if relay.
s:send(data)Send a binary payload (only valid in "open" state).
s:close()Terminate the session.
s.stateCurrent state: "new""gathering""ready""connecting""open""closed"

state_change event: fn(new_state, old_state) — fired on every state transition.

HTTP Signaling Server

For internet-wide testing without a dedicated backend, punch.lua includes a minimal HTTP signaling server you can expose via ngrok or any other tunneling service.

Host:

local sig = require("punch.signaling_server")

local srv = sig.new({ port = 0 })  -- srv.url → "http://127.0.0.1:PORT"
srv:set_host_desc(s:get_local_description())

srv:on_guest(function(slot, guest_desc)
  s:set_remote_description(guest_desc)
end)
-- In another terminal: ngrok http PORT
-- Share the https://...ngrok-free.app URL with the guest

Guest:

local sig = require("punch.signaling_server")

sig.fetch_host("https://xxxx.ngrok-free.app", 60000, function(err, host_desc, slot)
  s:set_remote_description(host_desc)
  sig.post_guest("https://xxxx.ngrok-free.app", slot, s:get_local_description(),
    function(post_err) end)
end)

HTTPS is handled transparently when the URL scheme is https:// (requires OpenSSL). The TLS client negotiates http/1.1 via ALPN so HTTPS reverse proxies (nginx, localhost.run, etc.) do not upgrade to HTTP/2, which would break both the long-poll and the WebSocket relay upgrade.

Relay fallback

When direct UDP traversal fails (symmetric NAT, strict firewall), the session automatically connects through a relay broker:

local s = p2p.start({
  stun  = "stun.l.google.com:19302",
  relay = "wss://your-relay-broker.com",
})

See docs/broker.md for a reference broker implementation (~60 lines of Node.js or Go).

Project Structure

ModulePurpose
punch/init.luaPublic API: p2p.start(config)
punch/session.luaState machine; orchestrates gather → check → open
punch/ice.luaCandidate gathering, pairing, priority, connectivity checks
punch/stun.luaSTUN Binding request/response (RFC 5389)
punch/punch.luaUDP hole-punch probe loop
punch/signal.luaEncode/decode description JSON
punch/channel.luaData channel: frame prefix, AES-GCM, keepalive, dead-peer timer
punch/crypto.luaAES-256-GCM + X25519 via LuaJIT FFI → OpenSSL
punch/relay.luaWebSocket relay fallback
punch/ws.luaMinimal WebSocket client (text + binary), optional TLS
punch/tls.luaTLS wrapper over a uv TCP handle using OpenSSL memory BIOs; negotiates http/1.1 via ALPN
punch/signaling_server.luaLocal HTTP signaling server + HTTPS client helpers
punch/log.luaStructured debug logging to file

Testing

The project uses busted for unit and integration tests.

Install dependencies (once):

luarocks --lua-version 5.1 install busted --local
luarocks --lua-version 5.1 install luv --local
eval "$(luarocks --lua-version 5.1 path --bin)"

Run the full suite:

busted

Run a single spec:

busted spec/crypto_spec.lua

License

This project is licensed under the GNU GPLv3. Any derivative works must also be open-sourced under the same license.