← Playground docs/NETWORKING.md
raw

Networking

NURL reaches the network through a pure-POSIX socket layer in the C runtime (nurl_tcp_* / nurl_udp_* / nurl_dns_*) — no framework, and no libcurl at this layer. (The stdlib HTTP client ext/http.nu and the reverse proxy are separate, optional libcurl bridges.) Four primitives, every one dual-stack IPv4/IPv6 and integrated with the fiber scheduler's I/O layer for async I/O — the epoll netpoller on Linux, a reactor thread elsewhere (poll(2), WSAPoll on Windows; see ASYNC.md).

HTTP server stack on top (stdlib/ext/http_* — HTTP/1.1 **and HTTP/2 on every listener, HTTP/3 beside every TLS listener**, routing, static files, middleware, multipart, WebSockets, TLS with ALPN; see HTTPS / TLS, HTTP/2 and HTTP/3 below). tcp_listen_with host port @ TcpListenOpts { backlog reuse_port } sets the accept-queue length (default tcp_listen_default_backlog, 4096, clamped by the kernel to somaxconn) and SO_REUSEPORT (several listeners — or processes, e.g. an old and a new release during a restart — on one address, the kernel spreading connections across them). server_run_async runs one accept fiber per runtime worker on its listener (server_set_acceptors); server_drain stops a server gracefully.

handshake with SNI; the verify flag turns on peer-certificate chain + host-name verification against the system trust store — the primitive behind any outbound TLS application). tcp_connect_timeout host port ms bounds the whole connect — name lookup and TCP handshake — and reports NetTimeout once ms passes. `tcp_connect_tls_timeout host port sni verify alpn sess ms` is the TLS form: one deadline over the lookup, the TCP handshake and the TLS handshake (each handshake read waits for what is left of it rather than a fixed 20 s), NetTimeout wherever it runs out; tls_attach_full_until is the same for a socket already open. Every address a name resolves to is tried in order until one connects. On a fiber neither step holds the worker: a name is resolved by the system getaddrinfo (so /etc/hosts, nsswitch and resolv.conf apply as ever) on a small runtime resolver pool while the fiber parks on the netpoller, and the handshake is a non-blocking connect parked for writability — the other fibers on that worker keep running through a slow DNS answer or a SYN into a black hole. A numeric address never reaches the resolver. Off a fiber a connect blocks the thread as it always has (the timeout then uses poll). A failed TCP connect under tcp_connect_tls* reports its own error (NetTimeout, NetOther, …) rather than NetTlsHandshake.

udp_connect, udp_send_to / udp_recv_from, connected-mode udp_send / udp_recv, broadcast and multicast (udp_join_group / _leave_group / _set_multicast_ttl / _set_multicast_loop). Sync + fiber-aware async on every send/recv. A UdpSocket is a shared handle that closes itself when its last owner goes, as a TcpConn does; udp_close stops it early, and every copy then sees it closed. Address-carrying variants for a datagram-per-packet transport: udp_recv_into / udp_recv_into_deadline receive into a caller-owned buffer and report the peer as a 24-byte udp_addr_* value (no allocation, no name lookup per datagram; the deadline form parks with a timeout inside a fiber and applies SO_RCVTIMEO on a thread), udp_send_addr sends to such an address, udp_addr_resolve builds one from host:port once per peer, udp_setsockopt_int sets the integer options posix_const names (SO_REUSEPORT, UDP_SEGMENT, IP_TOS, …). The same encoding is produced by the in-process unikernel stack, so peers compare as bytes on every runtime.

getaddrinfo / getnameinfo, no c-ares dep. `dns_resolve host → ! (Vec String) NetErr` lists A/AAAA literals in the kernel's preferred order; dns_resolve_port host port formats each entry as "ip:port" (IPv4) or "[ip]:port" (IPv6, RFC 3986) ready for tcp_connect / udp_connect; dns_reverse ip → ? String runs NI_NAMEREQD. On a fiber the forward lookups (and the ones inside udp_connect, udp_send_to and udp_addr_resolve) run on the runtime's resolver threads with the fiber parked, as for tcp_connect; so does dns_reverse's getnameinfo.

TCP writes can share an absolute operation budget with tcp_set_write_deadline(conn, monotonic_ns). Zero clears it, and tcp_write_deadline returns the current value so a caller can save and restore an earlier bound. The deadline covers short writes, TLS records and fiber waits; partial progress never extends it. The independent read/idle timeout remains configured through tcp_set_timeout.

tcp_set_send_buffer(conn, bytes) requests the underlying socket's send buffer size on POSIX and Windows. It accepts 1 through 2147483647 bytes; the kernel may adjust the request. Providers without native buffer tuning return NetOther. The setting also applies to TLS connections.

For full duplex protocols, tcp_prepare_write returns owned wire bytes without sending them. Keep these buffers in FIFO order and advance a byte offset with tcp_try_write_wire; zero means would block. Encoding a TLS record advances its sequence once, so retry the same buffer rather than encoding it again. tcp_try_read_into appends available plaintext, returns zero when more ciphertext is needed, and retains partial TLS records. tcp_wait_io waits for read, write, or either direction, including buffered TLS plaintext. After each try-read, append tcp_prepare_control output to the same FIFO, even when no application bytes arrived: a TLS 1.3 KeyUpdate request can require a response. Both peer directions rotate their receive keys on KeyUpdate; the response uses the previous write keys before the next generation and resets that direction's sequence. A terminal write error requires closing the connection. These APIs work on native sockets, the WASI socket provider, and the freestanding socket layer.

MQTT 5.0 client — stdlib/ext/mqtt.nu

A production-grade MQTT 5.0 client built on the TCP/TLS layer. The whole packet codec is pure NURL over ( Vec u ) — no dependencies beyond the pure-NURL TLS stack. The client connects over TLS (default broker port 8883); the MqttConfig.tls_verify field (default on) controls certificate verification.

$ `stdlib/ext/mqtt.nu`

@ main → i {
    : !MqttClient MqttErr r ( mqtt_connect
        `broker.example.com` 8883 `client-id` `user` `pass` )
    ?? r {
        T cl → {
            : !v MqttErr s ( mqtt_subscribe cl `sensors/#` )
            ?? s { T → {} F e → {} }
            : !v MqttErr p ( mqtt_publish1 cl `sensors/temp` `21.4` )
            ?? p { T → {} F e → {} }
            ( mqtt_disconnect cl )
            ^ 0
        }
        F e → { ( nurl_eprint ( mqtt_err_name e ) ) ^ 1 }
    }
}

What it covers:

expiry, clean-start, and tls_verify (default on — leave it on over any untrusted network; disable only for a self-signed broker in a trusted environment).

retain flag, and MQTT 5 user properties.

mqtt_subscribe_many (one packet, N filters); mqtt_receive returns inbound messages and auto-acknowledges QoS 1/2. **Inbound QoS 2 is exactly-once** — a DUP retransmit is acknowledged but delivered once.

packets in one segment, is reassembled correctly.

packet-id allocator, mqtt_reconnect, and mqtt_listen — a background reader thread that feeds inbound messages through a channel while the application does other work.

rejections (MqttBadAuth, MqttNotAuthorized, …).

Not yet: pipelined (multiple-in-flight) publishing — calls are synchronous, one packet in flight.

HTTPS / TLS

The tcp_connect_tls / tcp_listen_tls family below runs on the pure-NURL TLS stack described in the next subsection — no libssl. The HTTP server stack picks it up transparently — swap tcp_listen for tcp_listen_tls.

CapabilityNotes
TLS server-side — tcp_listen_tls host port cert_path key_path → !TcpListener NetErrHttpServer integrates without code changes.
TLS client-side — tcp_connect_tls host port server_name verifyClient handshake with SNI; verify enables peer-certificate chain + host-name verification against the system trust store — or against $SSL_CERT_FILE when that is set, which replaces the system bundle (OpenSSL semantics) and fails closed if unreadable, so a private CA or a self-signed lab server no longer means editing /etc/ssl or giving up on verification. The primitive behind the MQTT client and any outbound TLS.
Protocol versions — TLS 1.3; the client alone falls back to TLS 1.2The server is TLS 1.3 only. The client's 1.2 path offers only ECDHE + AEAD suites and aborts on the RFC 8446 §4.1.3 downgrade sentinel. TLS 1.0 / 1.1 / SSL 3.0 are not implemented.
SNI (RFC 6066 §3) — tcp_tls_add_sni listener hostname cert keyNot yet supported on the pure-NURL TLS server: returns NetOther. A listener serves its one default cert (plus the optional ML-DSA cert of tcp_listen_tls_dual) whatever hostname the client asks for.
ALPN (RFC 7301) — tcp_listen_tls_with_alpn host port backlog cert key "h2 http/1.1"; tcp_alpn_protocol connServer-side selection in the pure TLS 1.3 server: the first protocol in the listener's order that the client also offered, announced in EncryptedExtensions; a client offering ALPN with nothing in common is refused with a fatal no_application_protocol alert; no ALPN from the client negotiates nothing (""). Required by HTTP/2-over-TLS (RFC 9113 §3.3).
Mutual TLS (mTLS) — tcp_tls_require_client_cert listener ca_bundle strict?; tcp_peer_cert_subject connNot yet supported on the pure-NURL TLS server, which never asks for a client certificate. tcp_tls_require_client_cert returns NetOther — deliberately, so no caller believes mTLS is enforced when it is not — and tcp_peer_cert_subject returns an empty String.
Live cert reload — tcp_tls_reload listener hostname cert keyNot yet supported on the pure-NURL TLS server: returns NetOther (the OpenSSL SSL_CTX hot-swap was removed with libssl). A listener reads its cert and key once, when it is opened; to rotate them, close it and open a new one.

Pure-NURL TLS (no OpenSSL)

TLS is a pure-NURL TLS 1.3 client and server in the standard library (std/tls.nu / std/tls_server.nu; full design in docs/CRYPTO.md) — no libssl/OpenSSL anywhere. It implements the handshake, the record layer and full certificate verification from scratch in NURL, with **no FFI beyond the libc TCP socket**. It negotiates ChaCha20-Poly1305 / AES-128-GCM over X25519 or NIST P-256, verifies the chain against the system trust store by default, and runs on a host with nothing installed. tcp_starttls upgrades an already-connected TcpConn in safe code and verifies it the same way, which is what STARTTLS-style protocols need (tls_accept_conn accepts on a TcpConn; the low-level raw-descriptor tls_attach* and tls_accept* helpers are raw for unsafe callers).

Post-quantum. Key exchange prefers X25519MLKEM768 (hybrid ML-KEM-768) on both the client and the server, falling back to X25519 / P-256 for a peer without it; tcp_is_post_quantum conn says which. Authentication can be post-quantum too: the server accepts an ML-DSA (FIPS 204) leaf, either alone (tcp_listen_tls with an ML-DSA PKCS#8 key — the key form is auto-detected) or beside a classical leaf with `tcp_listen_tls_dual host port backlog cert key pq_cert pq_key alpn`, in which case every ClientHello is shown the leaf its signature_algorithms can verify (RFC 8446 §4.4.2.2) — ML-DSA to the clients that list it, ECDSA / RSA to the rest. The selection is made on the handshake state the QUIC server shares, so HTTP/3 gets it as well. tcp_tls_sig_scheme conn reports the scheme a connection was signed with. The client offers mldsa44/65/87 first and verifies ML-DSA chains (std/tls_verify.nu).

tcp_connect_tls / tcp_listen_tls add no library: a TLS client or server links libc only, exactly like a plain-TCP program. The psql package builds on the pure-NURL TLS client to reach PostgreSQL securely with no libpq and no OpenSSL.

The record layer seals and opens each record where it lies: outgoing records are built in a per-connection buffer and sealed in place, incoming ones are opened inside the receive buffer and their plaintext copied once — straight into the caller's buffer with tcp_read_into / tls_read_into / tls_server_read_into. The AES-GCM key of each traffic secret is prepared once (round keys and GHASH powers, rebuilt on KeyUpdate), and a steady-state record allocates nothing. On a CPU with AES-NI and PCLMULQDQ the server prefers AES-128-GCM (hardware crypto tier, docs/spec.md §4.1b); elsewhere ChaCha20-Poly1305.

kTLS and sendfile (opt-in, Linux)

After the pure-NURL handshake a connection can hand its TLS 1.3 record protection to the kernel: tcp_tls_ktls conn / tls_ktls conn (or every connection, with NURL_KTLS=1 in the environment or tls_ktls_default T). It returns what the kernel now protects — 1 the write side, 2 the read side, 3 both, 0 neither — and a direction that cannot go keeps the userspace record layer, so it is always safe to ask. Requirements: Linux 6.14 or later (the kernel must take a new key in an installed direction, which KeyUpdate needs — older kernels are refused rather than broken by the first KeyUpdate) and the tls ULP (modprobe tls; an unprivileged process cannot load it). A read side goes only when no partial record is buffered; it follows once the buffer drains. Under kTLS, alerts, KeyUpdate and NewSessionTicket travel as records of their own type (TLS_SET_RECORD_TYPE / TLS_GET_RECORD_TYPE), tls_key_update conn request_peer works in both modes, and kernel and userspace ends interoperate in either combination (compiler/tests/tls_ktls.nu).

tcp_sendfile conn file off len sends a PFile range: on plain TCP and on a TLS connection whose write side the kernel seals it is sendfile(2) — the bytes never enter the process — and otherwise it reads 64 KB slices into one reused buffer and writes them through the record layer. (The runtime blocks the SIGPIPE sendfile would raise on a closed peer; every other socket write passes MSG_NOSIGNAL. A kTLS socket can also flush queued records later — in close(), after the peer has gone — where no call can mask the signal, so a process that installs kTLS sets SIGPIPE to ignored unless the program has its own handler; write errors then come back as EPIPE, as they already do on every socket write.)

Measured — why it is opt-in. i7-5930K (Haswell), Linux 7.0, the kernel's AES-GCM being gcm-aesni-avx, server pinned to 6 cores, server CPU µs per keep-alive response over TLS (AES-128-GCM):

bodyuserspace, from memoryuserspace, pread + sealkTLS + sendfilekTLS, write() from memory
1 MB, C=17838818081134
1 MB, C=101202125512021720
16 KB, C=1036596266

With write() the kernel copies the plaintext in and seals it into separate pages, which costs more than NURL sealing in place; with sendfile it ties the in-memory userspace path and beats reading the file into the process. The kernel's own AES-GCM is what decides it: on a CPU where the kernel uses VAES / AVX-512 (Linux 6.11+, Ice Lake / Zen 3 and later), or with NIC TLS offload, kTLS + sendfile is the faster path for large files — measure on the deployment hardware before turning it on.

HTTP/2

Every HTTP server listener — server_run, server_run_pool, server_run_async, and so the packages/http HttpApp facade (http_app_listen / http_app_listen_tls) — serves **HTTP/2 (RFC 9113 + HPACK, RFC 7541) alongside HTTP/1.1**, with the same ( @ HttpResponse HttpRequest ) handler, the same routes and middleware, the same DoS gate, idle timeout and body limit. Nothing in application code chooses a protocol; the connection does:

How a client reaches HTTP/2What decidesWhere
TLS, ALPN h2 (RFC 9113 §3.3)http_app_listen_tls advertises h2 http/1.1; per connection tcp_alpn_is conn "h2" routes to the HTTP/2 state machine before a byte is parsedbrowsers, curl --http2, oha --http2 over https
Cleartext, prior knowledge (§3.4)the keep-alive loop recognises the 24-byte PRI * HTTP/2.0 preface in the first bytes and hands the connection — bytes and all — to h2_conn_new_buffered; anything else is HTTP/1.1curl --http2-prior-knowledge, h2load, oha --http2 over http
Upgrade: h2cnot implemented — deprecated by RFC 9113—

On a cleartext port shared with HTTP/1.1 a first line that is neither the preface nor HTTP gets HTTP/1.1's 400 Bad Request; h2spec's §3.5/2 ("invalid connection preface") expects a GOAWAY there and is the one case the shared-port run fails, by design. Over ALPN-h2 the connection is HTTP/2 from its first byte and the GOAWAY is sent.

The implementation is pure NURL: stdlib/ext/http2_frame.nu (framing, buffered reads), http2_hpack.nu (static + dynamic table, Huffman), http2_conn.nu (connection + stream state machine, flow control, request assembly, response emission), http2_client.nu (multiplexed client: h2_client_connect_tls / h2_client_connect_h2c, h2_client_submit). http2_serve (stdlib/ext/http2_server.nu) drives one HTTP/2 connection for a program with its own accept loop (examples/h2c_server.nu).

For RPCs and incremental bodies, h2_conn_next returns one owned H2Event per frame: headers, DATA, trailers, reset, GOAWAY, close, or control progress. The connection retains no DATA body. h2_conn_next_until and h2_conn_new_until take an absolute monotonic deadline; a read timeout keeps partial frame bytes so another stream can continue after an RPC expires. An event is dropped with its binding (h2_event_free releases one early). The response APIs h2_stream_headers, h2_stream_data, and h2_stream_trailers keep metadata, message bytes, and final status separate. DATA returns the accepted byte count; after zero credit, process another event and retry the unsent suffix. Each operation takes the connection inout, preserving flow-control and compression state. A write error requires closing the connection because a partially emitted HTTP/2 frame cannot be retried as a new frame.

The buffered HttpApp adapter uses the same dispatcher and queues responses while waiting for flow credit. It continues to process other streams, SETTINGS, PING, and cancellation without truncating an outstanding response. Request and queued response bodies share a 64 MiB connection bound, in addition to the configured per-request body limit and 256-stream cap.

Conformance is a CI gate, not a claim: tools/h2spec_gate.sh runs h2spec 2.6.0 against the HttpApp TLS listener and the HTTP/2-only example (146/146, strict 147/147) and the shared plaintext port (145/146 with §3.5/2 pinned by name), then confirms the negotiated protocol with curl. compiler/tests/http2_in_http_server.nu proves the same with the in-repo client. Performance is measured separately in bench/HTTP2_RESULTS.md (bench/run_http2.sh), next to the HTTP/1.1 report.

Limits: the handler runs synchronously inside the connection's frame loop, one stream at a time — a slow handler delays the other streams on that connection (each connection is its own fiber, so other connections are unaffected). The incremental event API permits streaming application work. Server push is disabled; servers do not send the client-only SETTINGS_ENABLE_PUSH setting. The HttpApp WebSocket upgrade hooks remain HTTP/1.1-only. An idle HTTP/2 connection whose deadline fires is closed with GOAWAY(NO_ERROR).

HTTP/3

Every TLS listener made by http_app_listen_tls also serves **HTTP/3 (RFC 9114) over QUIC (RFC 9000 / 9001 / 9002)**: the same host:port is bound over UDP, a QUIC loop runs on a thread of its own, and every request reaches the same router and handler as HTTP/1.1 and HTTP/2. The TCP side announces it — every HTTP/1.1 and HTTP/2 response from that listener carries Alt-Svc: h3=":port"; ma=86400 (RFC 9114 §3.1.1) — so a browser or curl --http3 moves over on its next request. http_app_set_http3 a 0 keeps a listener TCP-only; if the UDP port cannot be bound (or the credentials cannot be loaded for QUIC) the listener logs HTTP/3 off and keeps serving over TCP.

The whole stack is pure NURL. std/quic_packet.nu (packet protection, header protection, Initial secrets, Retry tag — byte-exact against RFC 9001 Appendix A), std/quic_frame.nu, std/quic_tp.nu (transport parameters), std/quic_tls.nu (RFC 9001 §4: the TLS 1.3 handshake fed from CRYPTO frames — the same message-level machine std/tls_server.nu uses for TCP; the client side has its twin, CliHs in std/tls.nu, which the TCP record layer and QuicTlsCli in the same file both drive), std/quic_recovery.nu (RFC 9002: RTT, loss detection, PTO, NewReno), std/quic_conn.nu (the connection: every frame, stream states, both flow-control levels, connection IDs, key update, close/drain, idle, anti-amplification), std/quic_server.nu (UDP listener, connection table, Version Negotiation); the same connection in its client role (quic_conn_new_client, quic_conn_open_bidi — Retry, Version Negotiation, NEW_TOKEN and HANDSHAKE_DONE handled, certificate verified through std/tls_verify.nu) with std/quic_client.nu as its socket and loop (quic_client_connect / _wait_readable / _close) and ext/http3_client.nu on top — an HTTP/3 client (h3_client_connect / h3_client_request) that packages/http-client switches to when a response advertises Alt-Svc: h3; ext/http3_qpack.nu (RFC 9204, static table + literals — this endpoint advertises a zero-size dynamic table), ext/http3_frame.nu, ext/http3_conn.nu (control / QPACK streams, request streams → HttpRequest), ext/http3_server.nu. A program that wants HTTP/3 alone calls http3_server_new directly (see examples/h3_server.nu).

Conformance is a CI gate, not a claim: tools/h3spec_gate.sh runs h3spec 0.1.13 — 34 QUIC-transport and 15 HTTP/3 + QPACK error cases — against the HttpApp TLS listener (49/49) and checks Alt-Svc on the TCP responses; locally it also drives one curl --http3-only request through the ymuski/curl-http3 docker image. Performance is measured separately in bench/HTTP3_RESULTS.md (bench/run_http3.sh, h2load --h3 from an nghttp2 image built with ngtcp2 + nghttp3), next to the HTTP/1.1 and HTTP/2 reports.

Limits, stated rather than hidden: no 0-RTT, no Retry / address-validation tokens, no preferred_address, no stateless-reset emission, no ECN marking, no path migration beyond following a validated peer address, no server push, no extended CONNECT; 1-RTT packets that arrive before the client's Finished are dropped (the peer's PTO resends them); the handler runs synchronously inside the connection's loop, as it does for HTTP/2. The QUIC transport (quic_conn_new_server, quic_conn_recv / quic_conn_send, streams) is sans-IO and reusable without HTTP/3.