diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c0f5c7..84fe052 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,8 @@ add_library(ioxd lib/json/json.c lib/tls/certs.c lib/tls/handshake.c + lib/quic/quic.c + lib/quic/stream.c third_party/picohttpparser/picohttpparser.c) add_library(ioxd::ioxd ALIAS ioxd) @@ -89,6 +91,35 @@ if(IOXD_TLS) else() target_compile_definitions(ioxd PRIVATE IOXD_TLS=0) endif() +# QUIC: ngtcp2 over OpenSSL 3.5's QUIC TLS API. AUTO takes it when pkg-config finds libngtcp2 and +# its ossl backend; ON insists; OFF leaves it out. Needs IOXD_TLS. +set(IOXD_QUIC AUTO CACHE STRING "QUIC ports (needs libngtcp2 with its OpenSSL backend, OpenSSL 3.5+): AUTO, ON or OFF") +set(IOXD_HAVE_QUIC OFF) +if(NOT IOXD_QUIC STREQUAL "OFF" AND IOXD_TLS) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + if(IOXD_QUIC STREQUAL "ON") + pkg_check_modules(NGTCP2 REQUIRED libngtcp2 libngtcp2_crypto_ossl) + else() + pkg_check_modules(NGTCP2 QUIET libngtcp2 libngtcp2_crypto_ossl) + endif() + if(NGTCP2_FOUND) + set(IOXD_HAVE_QUIC ON) + endif() + elseif(IOXD_QUIC STREQUAL "ON") + message(FATAL_ERROR "IOXD_QUIC=ON needs pkg-config to find libngtcp2") + endif() +elseif(IOXD_QUIC STREQUAL "ON") + message(FATAL_ERROR "IOXD_QUIC=ON needs IOXD_TLS: QUIC is TLS 1.3 from the same certificate store") +endif() +if(IOXD_HAVE_QUIC) + target_compile_definitions(ioxd PRIVATE IOXD_QUIC=1) + target_include_directories(ioxd PRIVATE ${NGTCP2_INCLUDE_DIRS}) + target_link_libraries(ioxd PUBLIC ${NGTCP2_LINK_LIBRARIES}) + message(STATUS "QUIC: ngtcp2 ${NGTCP2_libngtcp2_VERSION}") +else() + target_compile_definitions(ioxd PRIVATE IOXD_QUIC=0) +endif() target_compile_options(ioxd PRIVATE -Wall -Wextra) # Fat LTO objects when supported: the archive stays plain-linkable, and consumers that link with # -flto get cross-file inlining (the demo below does). @@ -144,7 +175,11 @@ if(IOXD_EXAMPLES) enable_testing() set(IOXD_CHECK_PORT 8099 CACHE STRING "first port the fixture listens on; it takes the two after it") set(IOXD_PIPE_PORT 8102 CACHE STRING "port the pipe fixture listens on") - set(IOXD_TLS_PYTHON python3 CACHE STRING "a python with tlslite-ng, for tests/tls_early.py") + set(IOXD_TLS_PYTHON python3 CACHE STRING "a python with tlslite-ng and aioquic, for tests/tls_early.py and tests/quic.py") + set(IOXD_QUIC_FLAG 0) + if(IOXD_HAVE_QUIC) + set(IOXD_QUIC_FLAG 1) + endif() add_test(NAME unit COMMAND ioxd-unit) add_test(NAME router COMMAND ioxd-router-test) add_test(NAME suites COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/run-suites.sh @@ -154,9 +189,10 @@ if(IOXD_EXAMPLES) --tls-python ${IOXD_TLS_PYTHON} --work ${CMAKE_CURRENT_BINARY_DIR}/check/suites) add_test(NAME pipes COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/run-suites.sh - --suite pipes + --suite pipes --suite quic --quic ${IOXD_QUIC_FLAG} --pipe-port ${IOXD_PIPE_PORT} --pipe-server $ + --tls-python ${IOXD_TLS_PYTHON} --work ${CMAKE_CURRENT_BINARY_DIR}/check/pipes) set_tests_properties(suites pipes PROPERTIES RUN_SERIAL TRUE) add_custom_target(check diff --git a/Makefile b/Makefile index cf8d79c..4a0058d 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # # make build libioxd.a, libioxd.so and the examples # make lib just the libraries -# make check the unit test and the suites, against the fixture servers +# make check the unit test and the suites, against the fixture servers (QUIC's too, in a QUIC build) # make check-tiny the stress suite against a build with the buffers starved on purpose # make check-all both # make tidy clang-tidy over the library @@ -33,14 +33,29 @@ WARN := -Wall -Wextra $(STD) HARDEN := $(shell $(CC) -Werror -fstack-clash-protection -x c -c /dev/null -o /dev/null 2>/dev/null && echo -fstack-clash-protection) CPP := -D_GNU_SOURCE -Iinclude -Ilib -Ithird_party/picohttpparser # TLS: OpenSSL for the handshake only; the kernel does the records. make TLS=0 leaves it out. +# pkg-config finds the OpenSSL to build against when there is one to find (PKG_CONFIG_PATH picks +# a private build); otherwise the toolchain's default is what -lssl names. TLS ?= 1 ifeq ($(TLS),1) -CPP += -DIOXD_TLS=1 -LIBS := -lssl -lcrypto +CPP += -DIOXD_TLS=1 $(shell pkg-config --cflags openssl 2>/dev/null) +LIBS := $(shell pkg-config --libs openssl 2>/dev/null || echo -lssl -lcrypto) else CPP += -DIOXD_TLS=0 LIBS := endif +# QUIC: ngtcp2 over OpenSSL 3.5's QUIC TLS API (libngtcp2_crypto_ossl). In by default when +# pkg-config finds both; make QUIC=1 insists, QUIC=0 leaves it out. Needs TLS=1. +QUIC ?= $(if $(filter 1,$(TLS)),$(shell pkg-config --exists 'libngtcp2 libngtcp2_crypto_ossl' 2>/dev/null && echo 1 || echo 0),0) +ifeq ($(QUIC),1) +ifeq ($(TLS),0) +$(error QUIC=1 needs TLS=1: QUIC is TLS 1.3 from the same certificate store) +endif +CPP += -DIOXD_QUIC=1 $(shell pkg-config --cflags libngtcp2 libngtcp2_crypto_ossl) +LIBS += $(shell pkg-config --libs libngtcp2 libngtcp2_crypto_ossl) +else +CPP += -DIOXD_QUIC=0 +endif +LDFLAGS ?= HDRS := $(wildcard include/*.h include/ioxd/*.h lib/*/*.h) PTHREAD := -pthread @@ -52,7 +67,7 @@ LIBDIR := $(PREFIX)/lib INCDIR := $(PREFIX)/include PCDIR := $(LIBDIR)/pkgconfig -UNITS := io/uring io/coro io/bufring io/conn io/proactor io/pipe clients/timer clients/socket http/engine http/api http/router http/run json/json tls/certs tls/handshake +UNITS := io/uring io/coro io/bufring io/conn io/proactor io/pipe clients/timer clients/socket http/engine http/api http/router http/run json/json tls/certs tls/handshake quic/quic quic/stream OBJ := $(addprefix obj/,$(addsuffix .o,$(UNITS))) obj/io/switch_x86_64.o obj/picohttpparser.o PICOBJ := $(addprefix obj/pic/,$(addsuffix .o,$(UNITS))) obj/pic/io/switch_x86_64.o obj/pic/picohttpparser.o @@ -68,7 +83,7 @@ MAP := cmake/ioxd.map # Every flag an object is built with, in a file. TLS=0/1 - or a different CC or CFLAGS - changes # what the objects must be, and a stamp they all depend on is what makes that a build dependency: # the recipe rewrites it only when it differs, so an unchanged build stays untouched. -FLAGS := $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LIBS) +FLAGS := $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $(LIBS) .PHONY: all lib examples check check-tiny check-all tidy manual clean install uninstall force all: lib examples @@ -86,7 +101,7 @@ libioxd.a: $(OBJ) # The version script keeps the library's own names out of the dynamic symbol table: what a # consumer may bind to is ioxd_*, and nothing else (nm -D libioxd.so says so). libioxd.so: $(PICOBJ) $(MAP) - $(CC) $(CFLAGS) -shared -Wl,-soname,$(SONAME) -Wl,--version-script,$(MAP) -o $@ $(PICOBJ) $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(LDFLAGS) -shared -Wl,-soname,$(SONAME) -Wl,--version-script,$(MAP) -o $@ $(PICOBJ) $(PTHREAD) $(LIBS) # --- static objects (used by libioxd.a and the examples) --- obj/%.o: lib/%.c $(HDRS) obj/flags @@ -114,20 +129,20 @@ obj/pic/picohttpparser.o: third_party/picohttpparser/picohttpparser.c obj/flags examples: $(EXAMPLES) # Link the static archive directly so the example runs in-tree without installing the .so. ioxd-hello: playground/hello/main.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) # The manual's examples, playground/examples/.c, each a whole program: ioxd-example-. ioxd-example-%: playground/examples/%.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) # --- tests: the unit test, then the fixture server with both suites against it --- $(TESTSRV): tests/server.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) $(UNIT): tests/unit.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) $(PIPESRV): tests/pipe-server.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) $(ROUTER): tests/router_test.c libioxd.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LDFLAGS) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) CHECK_PORT ?= 8099 PIPE_PORT ?= 8102 # the fixture takes CHECK_PORT and the two after (plain, TLS) @@ -136,7 +151,7 @@ TLSFUZZER ?= # a tlsfuzzer checkout, for `make check # The sequence itself is tests/run-suites.sh, so CMake's `check` target runs exactly this one. check: $(TESTSRV) $(UNIT) $(PIPESRV) $(ROUTER) @./$(ROUTER) || exit 1; \ - sh tests/run-suites.sh --port $(CHECK_PORT) --pipe-port $(PIPE_PORT) \ + sh tests/run-suites.sh --port $(CHECK_PORT) --pipe-port $(PIPE_PORT) --quic $(QUIC) \ --unit ./$(UNIT) --server ./$(TESTSRV) --pipe-server ./$(PIPESRV) --tls-python $(TLS_PYTHON) # --- the same stress suite, against a build starved on purpose --- @@ -161,7 +176,7 @@ obj-tiny/picohttpparser.o: third_party/picohttpparser/picohttpparser.c obj/flags libioxd-tiny.a: $(TINYOBJ) $(AR) rcs $@ $^ $(TINYSRV): tests/server.c libioxd-tiny.a - $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(TINY) $(PTHREAD) $< libioxd-tiny.a -o $@ $(PTHREAD) $(LIBS) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(TINY) $(PTHREAD) $(LDFLAGS) $< libioxd-tiny.a -o $@ $(PTHREAD) $(LIBS) check-tiny: $(TINYSRV) @IOXD_RECV_BUFFERS=8 IOXD_RECV_BUFFER_SIZE=64 sh tests/run-suites.sh --suite stress --port $(TINY_PORT) --server ./$(TINYSRV) --work obj-tiny/check @@ -173,7 +188,7 @@ check-all: check check-tiny # checks). CLion's bundled binary ships without clang's builtin headers, so gcc's own are handed # to it: make tidy TIDY=/bin/clang/linux/x64/bin/clang-tidy --- TIDY ?= clang-tidy -TIDY_ARGS ?= --extra-arg=-isystem$(shell $(CC) -print-file-name=include) +TIDY_ARGS ?= --extra-arg=-isystem$(shell $(CC) -print-file-name=include) $(if $(filter 1,$(QUIC)),--extra-arg=-isystem$(shell pkg-config --variable=includedir libngtcp2)) TIDY_SRC := $(wildcard lib/*/*.c) tests/server.c tests/pipe-server.c tests/unit.c tidy: $(TIDY) $(TIDY_ARGS) $(TIDY_SRC) -- $(STD) $(CPP) $(PTHREAD) diff --git a/README.md b/README.md index 7d21ec5..769d05e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # libioxd An HTTP/1.1 server library in C for Linux: io_uring underneath, a thread per core, a stackful -coroutine per connection, TLS 1.3 terminated in the kernel. A handler reads the request and writes -the reply in straight-line code; the runtime does the waiting. +coroutine per connection, TLS 1.3 terminated in the kernel, QUIC on ngtcp2 with every stream a pipe. +A handler reads the request and writes the reply in straight-line code; the runtime does the waiting. ```c static void hello(ioxd_ctx *ctx) diff --git a/include/ioxd.h b/include/ioxd.h index ab485dd..7a72863 100644 --- a/include/ioxd.h +++ b/include/ioxd.h @@ -33,3 +33,4 @@ #include "ioxd/timer.h" /* a delay that parks the connection, not the worker */ #include "ioxd/socket.h" /* outbound connections, as pipes */ #include "ioxd/tls.h" /* certificates, for a TLS listener */ +#include "ioxd/quic.h" /* a QUIC port: its streams as pipes */ diff --git a/include/ioxd/quic.h b/include/ioxd/quic.h new file mode 100644 index 0000000..debf2ef --- /dev/null +++ b/include/ioxd/quic.h @@ -0,0 +1,20 @@ +/* + * ioxd/quic.h - QUIC: a UDP port whose connections carry streams, each stream handed to the pipe + * handler as a pipe of its own. TLS 1.3 comes from the same certificate store a TLS port uses, + * run by ngtcp2 with OpenSSL; the transport lives in the library, since the kernel offers no + * QUIC of its own. + */ +#pragma once + +#include "ioxd/tls.h" + +/* Bind a UDP port for QUIC, with a certificate store (ioxd/tls.h) and the application protocols + * the port answers, most preferred first, ended by NULL - QUIC requires one, so a client offering + * none of them is refused at the handshake. Every stream a peer opens is served by the handler of + * ioxd_run_pipes (ioxd/run.h) as a pipe: what the peer sent on the stream is what the pipe reads, + * what the handler writes goes back on it, and the handler returning ends the stream. A stream + * the peer opened one-way reads but cannot be written. ioxd_run, the HTTP server, does not serve + * a QUIC port yet - HTTP/3 is a layer that is not there - and refuses to start with one bound. + * -1 if refused: a bad port, no store, no protocol, the table full, or a build without QUIC + * (make QUIC=1 needs libngtcp2 with its OpenSSL backend, and OpenSSL 3.5 or newer). */ +int ioxd_bind_quic(int port, ioxd_certs *certs, const char *const *alpn); diff --git a/lib/clients/socket.c b/lib/clients/socket.c index 9082def..193bc8a 100644 --- a/lib/clients/socket.c +++ b/lib/clients/socket.c @@ -67,7 +67,7 @@ ioxd_pipe *ioxd__socket_connect(proactor_t *p, const struct sockaddr *sa, sockle ioxd__conn_setsockopt(c, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); ioxd__conn_arm_recv(p, c); cp->conn = c; - ioxd__pipe_init(&cp->pipe, c, cp->gather, sizeof cp->gather, cp->slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, IOXD_PIPE_SLACK); + ioxd__pipe_init(&cp->pipe, c, &ioxd__conn_link, c, cp->gather, sizeof cp->gather, cp->slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, IOXD_PIPE_SLACK); *err = 0; return &cp->pipe; } diff --git a/lib/http/run.c b/lib/http/run.c index b563776..4061848 100644 --- a/lib/http/run.c +++ b/lib/http/run.c @@ -100,7 +100,15 @@ int ioxd_bind(int port, ioxd_certs *certs) return 0; } -static int run_workers(int workers, handler_fn handler) +static bool any_quic(void) +{ + for (int i = 0; i < g_n_listeners; i++) + if (g_listeners[i].quic) + return true; + return false; +} + +static int run_workers(int workers, handler_fn handler, handler_fn stream_handler) { if (workers <= 0) workers = cpu_count(); @@ -108,6 +116,10 @@ static int run_workers(int workers, handler_fn handler) fprintf(stderr, "ioxd_run: nothing bound: ioxd_bind a port first\n"); return 2; } + if (!stream_handler && any_quic()) { + fprintf(stderr, "ioxd_run: a QUIC port is bound, and HTTP/3 is not here yet: ioxd_run_pipes serves QUIC streams\n"); + return 2; + } g_stop = 0; raise_nofile(); @@ -135,6 +147,8 @@ static int run_workers(int workers, handler_fn handler) ws[i].cpu = i; ws[i].cfg = cfg; ws[i].handler = handler; + ws[i].stream_handler = stream_handler; + ws[i].n_workers = workers; ws[i].n_listeners = g_n_listeners; memcpy(ws[i].listeners, g_listeners, sizeof g_listeners); ws[i].stop = &g_stop; @@ -149,7 +163,8 @@ static int run_workers(int workers, handler_fn handler) if (started) { fprintf(stderr, "ioxd: %d workers on", started); for (int i = 0; i < g_n_listeners; i++) - fprintf(stderr, " :%u%s", g_listeners[i].port, g_listeners[i].certs ? "/tls" : ""); + fprintf(stderr, " :%u%s", g_listeners[i].port, + g_listeners[i].quic ? "/quic" : g_listeners[i].certs ? "/tls" : ""); fputc('\n', stderr); } @@ -186,7 +201,7 @@ int ioxd__run_http(int workers, size_t ctx_size) return 1; } ioxd__router_build(); - return run_workers(workers, serve_http); + return run_workers(workers, serve_http, nullptr); } static ioxd_pipe_handler g_pipe_handler; @@ -203,5 +218,49 @@ static void serve_pipe(ioxd_pipe *pipe) int ioxd_run_pipes(int workers, ioxd_pipe_handler fn) { g_pipe_handler = fn; - return run_workers(workers, serve_pipe); + return run_workers(workers, serve_pipe, fn); +} + +#if IOXD_QUIC +int ioxd_bind_quic(int port, ioxd_certs *certs, const char *const *alpn) +{ + if (port < 1 || port > 65535 || g_n_listeners == IOXD_MAX_LISTENERS) { + fprintf(stderr, "ioxd_bind_quic: port %d refused (1..65535, at most %d ports)\n", port, IOXD_MAX_LISTENERS); + return -1; + } + if (!certs) { + fprintf(stderr, "ioxd_bind_quic: port %d refused: QUIC is always TLS, and no certificate store was given\n", port); + return -1; + } + size_t total = 0; + int n = 0; + for (; alpn && alpn[n]; n++) { + size_t len = strlen(alpn[n]); + if (len == 0 || len > 255) { + fprintf(stderr, "ioxd_bind_quic: port %d refused: a protocol name must be 1 to 255 bytes\n", port); + return -1; + } + total += 1 + len; + } + if (n == 0 || total > 65535) { + fprintf(stderr, "ioxd_bind_quic: port %d refused: QUIC needs an application protocol to answer (\"h3\", or one of your own)\n", port); + return -1; + } + uint8_t *wire = malloc(2 + total); + if (!wire) { + perror("ioxd_bind_quic"); + return -1; + } + wire[0] = (uint8_t)(total & 0xFF); + wire[1] = (uint8_t)(total >> 8); + size_t at = 2; + for (int i = 0; i < n; i++) { + size_t len = strlen(alpn[i]); + wire[at++] = (uint8_t)len; + memcpy(wire + at, alpn[i], len); + at += len; + } + g_listeners[g_n_listeners++] = (struct listener){ .port = (uint16_t)port, .certs = certs, .quic = true, .alpn = wire, .alpn_len = total }; + return 0; } +#endif diff --git a/lib/io/conn.c b/lib/io/conn.c index 0d80587..4d7bf1b 100644 --- a/lib/io/conn.c +++ b/lib/io/conn.c @@ -285,13 +285,42 @@ void ioxd__conn_close(conn_t *c) conn_unref(c); } +static int link_recv_item(void *link, struct rx_item *out) +{ + return ioxd__conn_recv_item(link, out); +} + +static bool link_has_item(void *link) +{ + conn_t *c = link; + return !ioxd__spsc_empty(&c->rx); +} + +static void link_release(void *link, const struct rx_item *item) +{ + conn_t *c = link; + ioxd__bufring_return(&c->p->bufs, item->buf_id); +} + +static int link_send(void *link, const void *data, size_t n) +{ + return ioxd__conn_send(link, data, n); +} + +const ioxd_pipe_link ioxd__conn_link = { + .recv_item = link_recv_item, + .has_item = link_has_item, + .release = link_release, + .send = link_send, +}; + void ioxd__conn_main(void *arg) { conn_t *c = arg; char gather[IOXD_PIPE_GATHER]; char slab[IOXD_PIPE_LEAD + IOXD_PIPE_CAP + IOXD_PIPE_SLACK]; struct ioxd_pipe pipe; - ioxd__pipe_init(&pipe, c, gather, sizeof gather, slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, IOXD_PIPE_SLACK); + ioxd__pipe_init(&pipe, c, &ioxd__conn_link, c, gather, sizeof gather, slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, IOXD_PIPE_SLACK); c->p->handler(&pipe); ioxd__pipe_close(&pipe); ioxd__conn_close(c); diff --git a/lib/io/conn.h b/lib/io/conn.h index 00862b8..01f556e 100644 --- a/lib/io/conn.h +++ b/lib/io/conn.h @@ -62,6 +62,10 @@ int ioxd__conn_recv_exact (conn_t *c, void *dst, size_t n); /* n bytes into d int ioxd__conn_setsockopt (conn_t *c, int level, int name, const void *val, size_t len); /* 0 or -errno; over the ring */ int ioxd__conn_sendmsg (conn_t *c, const struct msghdr *msg); /* one sendmsg, for a message with control data */ +/* The connection as a pipe's link (io/pipe.h): its delivered buffers in, its socket out. */ +struct ioxd_pipe_link; +extern const struct ioxd_pipe_link ioxd__conn_link; + /* For the loop (proactor.c): a connection's life from accept to the pool. */ conn_t *ioxd__conn_new(proactor_t *p, struct listener *l, int fd); /* from the pool, or fresh */ void ioxd__conn_main(void *arg); /* the connection's coroutine body */ diff --git a/lib/io/internal.h b/lib/io/internal.h index 4d56a0f..95f73ae 100644 --- a/lib/io/internal.h +++ b/lib/io/internal.h @@ -29,8 +29,16 @@ enum { TAG_ACCEPT = 3, /* the listener's multishot accept */ TAG_CLOSE = 4, /* a socket's close: only a failure is news */ TAG_DRAIN = 5, /* the shutdown's blanket cancel */ + TAG_CALL = 6, /* an ioxd_cqe_target: it is called with the CQE */ }; +/* A completion that calls back: the first member of whatever staged the SQE (a QUIC socket's + * recv, one of its sends, its timer), so one tag serves every operation the loop has no case + * for. The loop calls on_cqe with the result and the flags. */ +typedef struct ioxd_cqe_target { + void (*on_cqe)(struct ioxd_cqe_target *target, int res, unsigned flags); +} ioxd_cqe_target; + #define UD(ptr, tag) ((uintptr_t)(ptr) | (uintptr_t)(tag)) #define UD_PTR(ud) ((void *)(uintptr_t)((ud) & ~(uint64_t)7)) #define UD_TAG(ud) ((unsigned)((ud) & 7U)) diff --git a/lib/io/pipe.c b/lib/io/pipe.c index 4c25a1f..018b3ed 100644 --- a/lib/io/pipe.c +++ b/lib/io/pipe.c @@ -3,9 +3,11 @@ #include -void ioxd__pipereader_init(ioxd_pipereader *pr, conn_t *conn, char *buf, size_t cap) +void ioxd__pipereader_init(ioxd_pipereader *pr, void *link, const ioxd_pipe_link *ops, conn_t *conn, char *buf, size_t cap) { *pr = (ioxd_pipereader){}; + pr->link = link; + pr->ops = ops; pr->conn = conn; pr->buf = buf; pr->cap = cap; @@ -25,7 +27,7 @@ static void cur_done(ioxd_pipereader *pr) if (!pr->has_cur || pr->cur_pos < pr->cur.len || (pr->run_in_cur && pr->run_len)) return; if (!pr->cur_is_pinned) - ioxd__bufring_return(&pr->conn->p->bufs, pr->cur.buf_id); + pr->ops->release(pr->link, &pr->cur); pr->has_cur = false; pr->cur_is_pinned = false; pr->cur_pos = 0; @@ -71,7 +73,7 @@ static bool gather(ioxd_pipereader *pr) static int refuse(ioxd_pipereader *pr, const struct rx_item *item) { - ioxd__bufring_return(&pr->conn->p->bufs, item->buf_id); + pr->ops->release(pr->link, item); pr->error = IOXD_PIPE_FULL; return IOXD_PIPE_FULL; } @@ -81,7 +83,7 @@ static int more(ioxd_pipereader *pr) if (pr->eof) return 0; struct rx_item item; - int rc = ioxd__conn_recv_item(pr->conn, &item); + int rc = pr->ops->recv_item(pr->link, &item); if (rc <= 0) { pr->eof = true; if (rc < 0) @@ -104,7 +106,7 @@ static int more(ioxd_pipereader *pr) } memcpy(pr->buf + pr->buf_end, item.ptr, item.len); pr->buf_end += item.len; - ioxd__bufring_return(&pr->conn->p->bufs, item.buf_id); + pr->ops->release(pr->link, &item); return 1; } @@ -239,7 +241,7 @@ void ioxd__pipereader_release(ioxd_pipereader *pr) if (pr->cur_is_pinned) pr->cur_is_pinned = false; else - ioxd__bufring_return(&pr->conn->p->bufs, pr->pinned.buf_id); + pr->ops->release(pr->link, &pr->pinned); pr->has_pinned = false; } cur_done(pr); @@ -248,9 +250,9 @@ void ioxd__pipereader_release(ioxd_pipereader *pr) void ioxd__pipereader_close(ioxd_pipereader *pr) { if (pr->has_cur && !pr->cur_is_pinned) - ioxd__bufring_return(&pr->conn->p->bufs, pr->cur.buf_id); + pr->ops->release(pr->link, &pr->cur); if (pr->has_pinned) - ioxd__bufring_return(&pr->conn->p->bufs, pr->pinned.buf_id); + pr->ops->release(pr->link, &pr->pinned); pr->has_cur = pr->has_pinned = pr->cur_is_pinned = false; } @@ -278,7 +280,7 @@ int ioxd__pipereader_avail(ioxd_pipereader *pr, ioxd_slice *live) return pr->error; ioxd_slice l = live_span(pr); while (l.len <= pr->examined) { - if (ioxd__spsc_empty(&pr->conn->rx)) + if (!pr->ops->has_item(pr->link)) return 0; int rc = more(pr); if (rc <= 0) @@ -312,10 +314,11 @@ bool ioxd__pipereader_inject(ioxd_pipereader *pr, const void *data, size_t n) return true; } -void ioxd__pipewriter_init(ioxd_pipewriter *pw, conn_t *conn, char *buf, size_t lead, size_t cap, size_t slack) +void ioxd__pipewriter_init(ioxd_pipewriter *pw, void *link, const ioxd_pipe_link *ops, char *buf, size_t lead, size_t cap, size_t slack) { *pw = (ioxd_pipewriter){}; - pw->conn = conn; + pw->link = link; + pw->ops = ops; pw->buf = buf; pw->lead = lead; pw->cap = cap; @@ -334,7 +337,7 @@ int ioxd__pipewriter_flush(ioxd_pipewriter *pw) size_t total = pw->head + pw->len + pw->tail; if (total == 0) return 0; - int rc = ioxd__conn_send(pw->conn, pw->buf + pw->lead - pw->head, total); + int rc = pw->ops->send(pw->link, pw->buf + pw->lead - pw->head, total); pw->head = pw->len = pw->tail = 0; if (rc < 0) { pw->failed = true; @@ -379,7 +382,7 @@ int ioxd__pipewriter_through(ioxd_pipewriter *pw, const void *data, size_t n) { if (pw->failed) return -1; - if (ioxd__conn_send(pw->conn, data, n) < 0) { + if (pw->ops->send(pw->link, data, n) < 0) { pw->failed = true; return -1; } @@ -405,10 +408,10 @@ int ioxd__pipewriter_send(ioxd_pipewriter *pw, const void *data, size_t n) return ioxd__pipewriter_write(pw, data, n) < 0 ? -1 : ioxd__pipewriter_flush(pw); } -void ioxd__pipe_init(ioxd_pipe *p, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack) +void ioxd__pipe_init(ioxd_pipe *p, void *link, const ioxd_pipe_link *ops, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack) { - ioxd__pipereader_init(&p->in, conn, gather, gather_cap); - ioxd__pipewriter_init(&p->out, conn, slab, lead, cap, slack); + ioxd__pipereader_init(&p->in, link, ops, conn, gather, gather_cap); + ioxd__pipewriter_init(&p->out, link, ops, slab, lead, cap, slack); } void ioxd__pipe_close(ioxd_pipe *p) diff --git a/lib/io/pipe.h b/lib/io/pipe.h index ac3ff02..55ebdbf 100644 --- a/lib/io/pipe.h +++ b/lib/io/pipe.h @@ -17,8 +17,20 @@ * bytes and the run in progress. */ // BUF_SIZE can be increased to keep entire requests in a single rx_item, avoiding buffering +/* What a pipe reads from and writes to: a TCP connection (io/conn.c's ioxd__conn_link) or a QUIC + * stream (quic/stream.c). Every call suspends the calling coroutine when it has to wait; the + * link's owner resumes it from the loop. */ +typedef struct ioxd_pipe_link { + int (*recv_item)(void *link, struct rx_item *out); /* 1 with the next delivered buffer, 0 at the end, <0 -errno */ + bool (*has_item)(void *link); /* one is queued now: avail need not wait */ + void (*release)(void *link, const struct rx_item *item); /* a delivered buffer is done with */ + int (*send)(void *link, const void *data, size_t n); /* all of it: n, else <0 */ +} ioxd_pipe_link; + typedef struct ioxd_pipereader { - conn_t *conn; + void *link; /* what recv_item and release act on */ + const ioxd_pipe_link *ops; + conn_t *conn; /* the TCP connection behind the link, or nullptr: for the protocol prologues that read the socket directly */ char *buf; /* the gathering buffer, the consumer's */ size_t cap; size_t floor; /* buf[0, floor): kept bytes */ @@ -37,7 +49,7 @@ typedef struct ioxd_pipereader { int error; /* 0, or IOXD_PIPE_GONE / IOXD_PIPE_FULL, sticky */ } ioxd_pipereader; -void ioxd__pipereader_init (ioxd_pipereader *pr, conn_t *conn, char *buf, size_t cap); +void ioxd__pipereader_init (ioxd_pipereader *pr, void *link, const ioxd_pipe_link *ops, conn_t *conn, char *buf, size_t cap); void ioxd__pipereader_close (ioxd_pipereader *pr); /* returns the buffers it holds */ int ioxd__pipereader_read (ioxd_pipereader *pr, ioxd_slice *live); /* 1: live bytes with something unexamined; waits for more otherwise; 0 at the end of input; <0 error */ void ioxd__pipereader_examine (ioxd_pipereader *pr, size_t n); /* looked at n live bytes: the next read waits for more */ @@ -55,7 +67,8 @@ bool ioxd__pipereader_inject (ioxd_pipereader *pr, const void *data, si * after it, and one flush sends the whole span. The HTTP reply puts its head and a chunk's size * line in front and a chunk's CRLF behind; a raw pipe may never touch them. */ typedef struct ioxd_pipewriter { - conn_t *conn; + void *link; /* what send acts on */ + const ioxd_pipe_link *ops; char *buf; /* [lead][cap][slack] */ size_t lead, cap, slack; size_t head; /* bytes of the lead in use: a frame's front */ @@ -64,7 +77,7 @@ typedef struct ioxd_pipewriter { bool failed; /* the peer is gone: every call fails from here on */ } ioxd_pipewriter; -void ioxd__pipewriter_init (ioxd_pipewriter *pw, conn_t *conn, char *buf, size_t lead, size_t cap, size_t slack); +void ioxd__pipewriter_init (ioxd_pipewriter *pw, void *link, const ioxd_pipe_link *ops, char *buf, size_t lead, size_t cap, size_t slack); void ioxd__pipewriter_reset (ioxd_pipewriter *pw); /* drop everything pending */ void *ioxd__pipewriter_reserve(ioxd_pipewriter *pw, size_t n); /* n bytes at the tail, flushing first when they do not fit; nullptr on failure or n > cap */ void ioxd__pipewriter_advance(ioxd_pipewriter *pw, size_t n); @@ -103,7 +116,7 @@ struct ioxd_pipe { ioxd_pipereader in; ioxd_pipewriter out; }; -void ioxd__pipe_init (ioxd_pipe *p, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack); +void ioxd__pipe_init (ioxd_pipe *p, void *link, const ioxd_pipe_link *ops, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack); void ioxd__pipe_close(ioxd_pipe *p); /* ── pipe.c: the notes ──────────────────────────────────────────────────────────────────── */ diff --git a/lib/io/proactor.c b/lib/io/proactor.c index 46276fa..9256365 100644 --- a/lib/io/proactor.c +++ b/lib/io/proactor.c @@ -1,4 +1,5 @@ #include "io/internal.h" +#include "quic/quic.h" #include #include @@ -162,6 +163,11 @@ static void dispatch(proactor_t *p, struct io_uring_cqe *cqe) p->cancel_each = true; } break; + case TAG_CALL: { + ioxd_cqe_target *target = ptr; + target->on_cqe(target, cqe->res, cqe->flags); + break; + } default: break; } @@ -193,6 +199,10 @@ static void rearm_starved(proactor_t *p) { unsigned room = p->bufs.returned; p->bufs.returned = 0; + if (room) + for (int i = 0; i < p->n_listeners; i++) + if (p->listeners[i].quic) + ioxd__quic_rearm(&p->listeners[i]); if (p->nstarved == 0 || room == 0) return; @@ -236,6 +246,11 @@ static void begin_drain(proactor_t *p) p->draining = true; for (int i = 0; i < p->n_listeners; i++) { struct listener *l = &p->listeners[i]; + if (l->quic) { + ioxd__quic_drain(l); + l->stalled = true; + continue; + } if (!l->stalled) { struct io_uring_sqe *sqe = ioxd__proactor_sqe(p); sqe->opcode = IORING_OP_ASYNC_CANCEL; @@ -324,10 +339,16 @@ void ioxd__proactor_run(proactor_t *p) size_t at = 0; for (int i = 0; i < p->n_listeners; i++) { struct listener *l = &p->listeners[i]; - l->p = p; - l->fd = listener_open(l->port); - arm_accept(l); - int n = snprintf(ports + at, sizeof ports - at, "%s:%u%s", i ? " " : "", l->port, l->certs ? "/tls" : ""); + l->p = p; + if (l->quic) { + if (ioxd__quic_open(p, l) < 0) + abort(); + } else { + l->fd = listener_open(l->port); + arm_accept(l); + } + int n = snprintf(ports + at, sizeof ports - at, "%s:%u%s", i ? " " : "", l->port, + l->quic ? "/quic" : l->certs ? "/tls" : ""); if (n < 0) break; at += (size_t)n < sizeof ports - at ? (size_t)n : sizeof ports - at - 1; @@ -349,6 +370,7 @@ void ioxd__proactor_run(proactor_t *p) break; run_ready(p); + ioxd__quic_service(p); rearm_starved(p); ioxd__bufring_publish(&p->bufs); @@ -377,8 +399,12 @@ void ioxd__proactor_run(proactor_t *p) p->id, (unsigned long long)p->accepted, p->live, (unsigned long long)p->ring.cq_overflows); - for (int i = 0; i < p->n_listeners; i++) - close(p->listeners[i].fd); + for (int i = 0; i < p->n_listeners; i++) { + if (p->listeners[i].quic) + ioxd__quic_close(&p->listeners[i]); + else + close(p->listeners[i].fd); + } ioxd__bufring_unregister(&p->bufs, &p->ring); ioxd__uring_exit(&p->ring); if (p->live == 0) { diff --git a/lib/io/proactor.h b/lib/io/proactor.h index c4497fc..4c71d2e 100644 --- a/lib/io/proactor.h +++ b/lib/io/proactor.h @@ -50,17 +50,21 @@ typedef void (*handler_fn)(struct ioxd_pipe *pipe); /* a connection, as a pipe * the port's connections across workers; an accept CQE carries the listener it came from. */ struct listener { proactor_t *p; - int fd; /* the socket, or its file slot under fixed files */ - uint16_t port; void *certs; /* the port's certificate store, or nullptr: plain */ + const uint8_t *alpn; /* a QUIC port's application protocols, wire form, most preferred first */ + size_t alpn_len; + struct quic_listener *ql; /* a QUIC port's state on this worker, once open */ /* accept back-pressure: out of descriptors, file slots or memory, re-arming at once would * spin, so the accept is left unarmed until there is room again (see rearm_stalled). */ - bool stalled; - unsigned stalled_live; /* p->live when it stalled: re-arm once that drops */ - time_t retry_at; /* ... or at this second, whichever comes first */ + time_t retry_at; /* re-arm at this second, whichever comes first */ time_t err_log_at; /* the next second an accept error may be logged */ uint64_t err_since_log; /* accept errors swallowed since the last line */ + int fd; /* the socket, or its file slot under fixed files */ + unsigned stalled_live; /* p->live when it stalled: re-arm once that drops */ + uint16_t port; + bool quic; /* UDP, a QUIC port (quic/quic.c): certs required */ + bool stalled; }; struct proactor { @@ -69,7 +73,9 @@ struct proactor { int cpu; /* pin the thread here; -1 = don't */ struct listener listeners[IOXD_MAX_LISTENERS]; /* port and certs set by the creator */ int n_listeners; - handler_fn handler; + int n_workers; /* how many there are: a QUIC connection id names its owner */ + handler_fn handler; /* a TCP connection, as a pipe */ + handler_fn stream_handler; /* a QUIC stream, as a pipe: the raw pipe handler, or nullptr */ volatile sig_atomic_t *stop; ioxd_config cfg; /* every field filled in: the run resolved the defaults */ diff --git a/lib/json/json.c b/lib/json/json.c index 39530f1..1fc7865 100644 --- a/lib/json/json.c +++ b/lib/json/json.c @@ -144,7 +144,7 @@ static bool put_string(ioxd_json *j, size_t comma, const char *p, size_t n, char if (need <= RUN_MAX && clean_run(p, n) == n) { size_t room; char *at = tail(j, &room); - if (need <= room && !sink_failed(j)) { + if (at && need <= room && !sink_failed(j)) { if (comma) *at++ = ','; *at++ = '"'; diff --git a/lib/quic/quic.c b/lib/quic/quic.c new file mode 100644 index 0000000..70ff639 --- /dev/null +++ b/lib/quic/quic.c @@ -0,0 +1,1137 @@ +#include "quic/quic.h" + +#if IOXD_QUIC + +#include "io/bufring.h" +#include "io/coro.h" +#include "tls/certs.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define IDLE_TIMEOUT (30 * NGTCP2_SECONDS) +#define HANDSHAKE_TIMEOUT (10 * NGTCP2_SECONDS) +#define CLOSE_REPEAT 8 +#define RESETS_PER_SECOND 64 +#define IDLE_SENDS 64 +#define STREAM_WINDOW (256UL * 1024) +#define CONN_WINDOW (1024UL * 1024) +#define MAX_BIDI_STREAMS 100 +#define MAX_UNI_STREAMS 3 + +ngtcp2_tstamp ioxd__quic_now(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (ngtcp2_tstamp)ts.tv_sec * NGTCP2_SECONDS + (ngtcp2_tstamp)ts.tv_nsec; +} + +static pthread_once_t crypto_once = PTHREAD_ONCE_INIT; +static void crypto_init(void) +{ + ngtcp2_crypto_ossl_init(); +} + +static void note(struct quic_listener *ql, const char *what, int err) +{ + fprintf(stderr, "[w%d] quic :%u: %s: %s\n", ql->p->id, ql->l->port, what, + err > 0 ? ioxd__io_errstr(err) : ngtcp2_strerror(err)); +} + +/* ── the connection id table ───────────────────────────────────────────────────────────── */ + +static uint64_t cid_hash(const uint8_t *cid, size_t len) +{ + uint64_t h = 1469598103934665603ULL; + for (size_t i = 0; i < len; i++) + h = (h ^ cid[i]) * 1099511628211ULL; + return h; +} + +static struct cid_slot *table_slot(struct quic_listener *ql, const uint8_t *cid, size_t len, bool insert) +{ + struct cid_slot *tomb = nullptr; + for (uint64_t i = cid_hash(cid, len);; i++) { + struct cid_slot *s = &ql->table[i & ql->table_mask]; + if (s->len == 0) { + if (!s->tomb) + return insert ? (tomb ? tomb : s) : nullptr; + if (!tomb) + tomb = s; + continue; + } + if (s->len == len && memcmp(s->cid, cid, len) == 0) + return s; + } +} + +static bool table_grow(struct quic_listener *ql) +{ + unsigned cap = (ql->table_mask + 1) * 2; + struct cid_slot *old = ql->table, *fresh = calloc(cap, sizeof *fresh); + unsigned old_cap = ql->table_mask + 1; + if (!fresh) + return false; + ql->table = fresh; + ql->table_mask = cap - 1; + ql->table_tombs = 0; + for (unsigned i = 0; i < old_cap; i++) + if (old[i].len) + *table_slot(ql, old[i].cid, old[i].len, true) = old[i]; + free(old); + return true; +} + +static bool table_add(struct quic_listener *ql, const ngtcp2_cid *cid, struct quic_conn *qc) +{ + if ((ql->table_used + ql->table_tombs + 1) * 2 > ql->table_mask + 1 && !table_grow(ql)) + return false; + struct cid_slot *s = table_slot(ql, cid->data, cid->datalen, true); + if (s->len) + return s->qc == qc; + if (s->tomb) + ql->table_tombs--; + memcpy(s->cid, cid->data, cid->datalen); + s->len = (uint8_t)cid->datalen; + s->tomb = false; + s->qc = qc; + ql->table_used++; + return true; +} + +static void table_remove(struct quic_listener *ql, const ngtcp2_cid *cid) +{ + struct cid_slot *s = table_slot(ql, cid->data, cid->datalen, false); + if (!s) + return; + s->len = 0; + s->tomb = true; + s->qc = nullptr; + ql->table_used--; + ql->table_tombs++; +} + +static struct quic_conn *table_find(struct quic_listener *ql, const uint8_t *cid, size_t len) +{ + if (len == 0 || len > NGTCP2_MAX_CIDLEN) + return nullptr; + struct cid_slot *s = table_slot(ql, cid, len, false); + return s ? s->qc : nullptr; +} + +static bool cid_register(struct quic_conn *qc, const ngtcp2_cid *cid) +{ + if (qc->n_cids == sizeof qc->cids / sizeof qc->cids[0] || !table_add(qc->ql, cid, qc)) + return false; + qc->cids[qc->n_cids++] = *cid; + return true; +} + +static void cid_forget(struct quic_conn *qc, const ngtcp2_cid *cid) +{ + table_remove(qc->ql, cid); + for (unsigned i = 0; i < qc->n_cids; i++) + if (ngtcp2_cid_eq(&qc->cids[i], cid)) { + qc->cids[i] = qc->cids[--qc->n_cids]; + return; + } +} + +/* ── the sends ─────────────────────────────────────────────────────────────────────────── */ + +static struct quic_send *send_get(struct quic_listener *ql) +{ + struct quic_send *s = ql->free_sends; + if (s) { + ql->free_sends = s->next; + ql->n_free--; + return s; + } + s = malloc(sizeof *s); + if (!s) { + note(ql, "send", ENOMEM); + return nullptr; + } + s->target.on_cqe = nullptr; + s->ql = ql; + ql->n_sends++; + return s; +} + +static void send_put(struct quic_listener *ql, struct quic_send *s) +{ + if (ql->n_free >= IDLE_SENDS) { + free(s); + ql->n_sends--; + return; + } + s->next = ql->free_sends; + ql->free_sends = s; + ql->n_free++; +} + +static void send_on_cqe(ioxd_cqe_target *t, int res, unsigned flags) +{ + (void)flags; + struct quic_send *s = (struct quic_send *)t; + struct quic_listener *ql = s->ql; + if (res < 0 && res != -ECANCELED) { + if ((res == -EMSGSIZE || res == -EINVAL || res == -EIO) && s->msg.msg_controllen && !ql->no_gso) { + ql->no_gso = true; + note(ql, "GSO refused, sending one datagram at a time", -res); + } else if (res != -EMSGSIZE) { + note(ql, "sendmsg", -res); + } + } + send_put(ql, s); +} + +static void send_submit(struct quic_listener *ql, struct quic_send *s, const struct sockaddr *to, socklen_t tolen, + size_t n, size_t gso) +{ + if (tolen > sizeof s->to) + tolen = sizeof s->to; + memcpy(&s->to, to, tolen); + s->iov = (struct iovec){ s->buf, n }; + s->msg = (struct msghdr){ .msg_name = &s->to, .msg_namelen = tolen, .msg_iov = &s->iov, .msg_iovlen = 1 }; + if (gso && n > gso && !ql->no_gso) { + s->msg.msg_control = s->control; + s->msg.msg_controllen = sizeof s->control; + struct cmsghdr *cm = CMSG_FIRSTHDR(&s->msg); + cm->cmsg_level = SOL_UDP; + cm->cmsg_type = UDP_SEGMENT; + cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); + uint16_t seg = (uint16_t)gso; + memcpy(CMSG_DATA(cm), &seg, sizeof seg); + } + s->target.on_cqe = send_on_cqe; + struct io_uring_sqe *sqe = ioxd__proactor_sqe(ql->p); + sqe->opcode = IORING_OP_SENDMSG; + sqe->fd = ql->fd; + sqe->addr = (uintptr_t)&s->msg; + sqe->len = 1; + sqe->msg_flags = MSG_NOSIGNAL; + sqe->user_data = UD(&s->target, TAG_CALL); +} + +/* ── the timer: one kernel timeout at the earliest expiry ─────────────────────────────── */ + +static void heap_swap(struct quic_listener *ql, unsigned a, unsigned b) +{ + struct quic_conn *t = ql->heap[a]; + ql->heap[a] = ql->heap[b]; + ql->heap[b] = t; + ql->heap[a]->heap_idx = a; + ql->heap[b]->heap_idx = b; +} + +static void heap_up(struct quic_listener *ql, unsigned i) +{ + while (i > 0) { + unsigned parent = (i - 1) / 2; + if (ql->heap[parent]->expiry <= ql->heap[i]->expiry) + break; + heap_swap(ql, i, parent); + i = parent; + } +} + +static void heap_down(struct quic_listener *ql, unsigned i) +{ + for (;;) { + unsigned l = 2 * i + 1, r = l + 1, m = i; + if (l < ql->n_heap && ql->heap[l]->expiry < ql->heap[m]->expiry) + m = l; + if (r < ql->n_heap && ql->heap[r]->expiry < ql->heap[m]->expiry) + m = r; + if (m == i) + return; + heap_swap(ql, i, m); + i = m; + } +} + +static void heap_update(struct quic_listener *ql, struct quic_conn *qc) +{ + if (qc->heap_idx == UINT_MAX) { + if (ql->n_heap == ql->cap_heap) { + unsigned cap = ql->cap_heap ? ql->cap_heap * 2 : 64; + struct quic_conn **grown = realloc(ql->heap, cap * sizeof *grown); + if (!grown) { + note(ql, "timer heap", ENOMEM); + abort(); + } + ql->heap = grown; + ql->cap_heap = cap; + } + qc->heap_idx = ql->n_heap; + ql->heap[ql->n_heap++] = qc; + heap_up(ql, qc->heap_idx); + return; + } + heap_up(ql, qc->heap_idx); + heap_down(ql, qc->heap_idx); +} + +static void heap_remove(struct quic_listener *ql, struct quic_conn *qc) +{ + unsigned i = qc->heap_idx; + if (i == UINT_MAX) + return; + qc->heap_idx = UINT_MAX; + ql->n_heap--; + if (i == ql->n_heap) + return; + ql->heap[i] = ql->heap[ql->n_heap]; + ql->heap[i]->heap_idx = i; + heap_up(ql, i); + heap_down(ql, i); +} + +static void timer_arm(struct quic_listener *ql, ngtcp2_tstamp deadline) +{ + struct quic_timer *t = &ql->timer; + ngtcp2_tstamp now = ioxd__quic_now(); + ngtcp2_tstamp delta = deadline > now ? deadline - now : 1; + t->ts = (struct __kernel_timespec){ .tv_sec = (int64_t)(delta / NGTCP2_SECONDS), .tv_nsec = (int64_t)(delta % NGTCP2_SECONDS) }; + struct io_uring_sqe *sqe = ioxd__proactor_sqe(ql->p); + sqe->opcode = IORING_OP_TIMEOUT; + sqe->fd = -1; + sqe->addr = (uintptr_t)&t->ts; + sqe->len = 1; + sqe->user_data = UD(&t->target, TAG_CALL); + t->armed = true; + t->deadline = deadline; +} + +static void timer_cancel(struct quic_listener *ql) +{ + struct quic_timer *t = &ql->timer; + struct io_uring_sqe *sqe = ioxd__proactor_sqe(ql->p); + sqe->opcode = IORING_OP_TIMEOUT_REMOVE; + sqe->fd = -1; + sqe->addr = UD(&t->target, TAG_CALL); + sqe->user_data = TAG_IGNORE; + t->cancelling = true; +} + +static void timer_update(struct quic_listener *ql) +{ + struct quic_timer *t = &ql->timer; + ngtcp2_tstamp want = ql->n_heap ? ql->heap[0]->expiry : UINT64_MAX; + if (!t->armed) { + if (want != UINT64_MAX && !ql->p->draining) + timer_arm(ql, want); + } else if (want < t->deadline && !t->cancelling) { + timer_cancel(ql); + } +} + +/* ── connections ───────────────────────────────────────────────────────────────────────── */ + +static void wake_streams(struct quic_listener *ql); +static void conn_free(struct quic_conn *qc); +static bool conn_close(struct quic_conn *qc); + +static void conn_reschedule(struct quic_conn *qc) +{ + if (qc->state != QUIC_OPEN) + return; + ngtcp2_tstamp expiry = ngtcp2_conn_get_expiry(qc->conn); + if (expiry == qc->expiry && qc->heap_idx != UINT_MAX) + return; + qc->expiry = expiry; + heap_update(qc->ql, qc); + timer_update(qc->ql); +} + +static void send_close(struct quic_conn *qc) +{ + struct quic_send *s = send_get(qc->ql); + if (!s) + return; + memcpy(s->buf, qc->close_pkt, qc->close_len); + send_submit(qc->ql, s, (const struct sockaddr *)&qc->remote, qc->remote_len, qc->close_len, 0); +} + +static void conn_leave(struct quic_conn *qc, enum quic_state state) +{ + qc->state = state; + qc->expiry = ioxd__quic_now() + 3 * ngtcp2_conn_get_pto(qc->conn); + heap_update(qc->ql, qc); + timer_update(qc->ql); + ioxd__stream_abandon(qc); +} + +static bool conn_close(struct quic_conn *qc) +{ + if (qc->state != QUIC_OPEN) + return true; + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi = {}; + ngtcp2_path_storage_zero(&ps); + uint8_t *buf = malloc(NGTCP2_MAX_UDP_PAYLOAD_SIZE); + ngtcp2_ssize n = buf ? ngtcp2_conn_write_connection_close(qc->conn, &ps.path, &pi, buf, NGTCP2_MAX_UDP_PAYLOAD_SIZE, + &qc->err, ioxd__quic_now()) + : 0; + if (n <= 0) { + free(buf); + conn_free(qc); + return false; + } + qc->close_pkt = buf; + qc->close_len = (size_t)n; + send_close(qc); + conn_leave(qc, QUIC_CLOSING); + return true; +} + +static void conn_error(struct quic_conn *qc, int rv) +{ + trace("[w%d] quic conn error %d (%s)\n", qc->ql->p->id, rv, ngtcp2_strerror(rv)); + switch (rv) { + case NGTCP2_ERR_DRAINING: + conn_leave(qc, QUIC_DRAINING); + return; + case NGTCP2_ERR_DROP_CONN: + case NGTCP2_ERR_RETRY: + case NGTCP2_ERR_IDLE_CLOSE: + case NGTCP2_ERR_HANDSHAKE_TIMEOUT: + conn_free(qc); + return; + case NGTCP2_ERR_CRYPTO: + if (!qc->err.error_code) + ngtcp2_ccerr_set_tls_alert(&qc->err, ngtcp2_conn_get_tls_alert(qc->conn), nullptr, 0); + break; + default: + if (!qc->err.error_code) + ngtcp2_ccerr_set_liberr(&qc->err, rv, nullptr, 0); + break; + } + conn_close(qc); +} + +void ioxd__quic_flush(struct quic_conn *qc) +{ + struct quic_listener *ql = qc->ql; + if (qc->state != QUIC_OPEN) + return; + for (;;) { + struct quic_send *s = send_get(ql); + if (!s) + return; + ngtcp2_tstamp now = ioxd__quic_now(); + size_t quantum = ngtcp2_conn_get_send_quantum(qc->conn); + size_t one = ngtcp2_conn_get_max_tx_udp_payload_size(qc->conn); + size_t buflen = quantum > one ? quantum : one; + if (buflen > sizeof s->buf) + buflen = sizeof s->buf; + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi = {}; + size_t gso = 0; + ngtcp2_path_storage_zero(&ps); + ngtcp2_ssize n = ngtcp2_conn_write_aggregate_pkt2(qc->conn, &ps.path, &pi, s->buf, buflen, &gso, + ioxd__stream_write_pkt, ql->no_gso ? 1 : 0, now); + if (n < 0) { + send_put(ql, s); + conn_error(qc, (int)n); + return; + } + ngtcp2_conn_update_pkt_tx_time(qc->conn, now); + if (n == 0) { + send_put(ql, s); + break; + } + send_submit(ql, s, (const struct sockaddr *)ps.path.remote.addr, (socklen_t)ps.path.remote.addrlen, (size_t)n, gso); + } + conn_reschedule(qc); +} + +static void conn_read(struct quic_conn *qc, const struct sockaddr *from, socklen_t fromlen, const uint8_t *pkt, size_t len) +{ + struct quic_listener *ql = qc->ql; + ngtcp2_path path = { + .local = { (ngtcp2_sockaddr *)&ql->local, sizeof ql->local }, + .remote = { (ngtcp2_sockaddr *)from, fromlen }, + }; + ngtcp2_pkt_info pi = {}; + int rv = ngtcp2_conn_read_pkt(qc->conn, &path, &pi, pkt, len, ioxd__quic_now()); + if (rv != 0) { + conn_error(qc, rv); + return; + } + const ngtcp2_path *now = ngtcp2_conn_get_path(qc->conn); + if (now->remote.addrlen && now->remote.addrlen <= sizeof qc->remote) { + memcpy(&qc->remote, now->remote.addr, now->remote.addrlen); + qc->remote_len = now->remote.addrlen; + } + ioxd__quic_flush(qc); +} + +static ngtcp2_conn *get_conn(ngtcp2_crypto_conn_ref *ref) +{ + return ((struct quic_conn *)ref->user_data)->conn; +} + +static void cb_rand(uint8_t *dest, size_t n, const ngtcp2_rand_ctx *ctx) +{ + (void)ctx; + if (RAND_bytes(dest, (int)n) != 1) + abort(); +} + +static void stamp_worker(uint8_t *cid, const proactor_t *p) +{ + unsigned n = (unsigned)p->n_workers; + if (n <= 1 || n > 256) + return; + unsigned v = (unsigned)cid[0] - (unsigned)cid[0] % n + (unsigned)p->id; + if (v > 255) + v -= n; + cid[0] = (uint8_t)v; +} + +static void mint_cid(struct quic_conn *qc, ngtcp2_cid *cid) +{ + cid->datalen = QUIC_CIDLEN; + cb_rand(cid->data, QUIC_CIDLEN, nullptr); + stamp_worker(cid->data, qc->ql->p); +} + +static int cb_new_cid(ngtcp2_conn *conn, ngtcp2_cid *cid, ngtcp2_stateless_reset_token *token, size_t cidlen, void *user_data) +{ + (void)conn; + (void)cidlen; + struct quic_conn *qc = user_data; + mint_cid(qc, cid); + if (ngtcp2_crypto_generate_stateless_reset_token(token->data, qc->ql->secret, sizeof qc->ql->secret, cid) != 0 + || !cid_register(qc, cid)) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +static int cb_remove_cid(ngtcp2_conn *conn, const ngtcp2_cid *cid, void *user_data) +{ + (void)conn; + cid_forget(user_data, cid); + return 0; +} + +static int cb_stream_open(ngtcp2_conn *conn, int64_t id, void *user_data) +{ + (void)conn; + ioxd__stream_open(user_data, id); + return 0; +} + +static int cb_recv_stream_data(ngtcp2_conn *conn, uint32_t flags, int64_t id, uint64_t offset, const uint8_t *data, + size_t len, void *user_data, void *stream_user_data) +{ + (void)conn; + (void)id; + (void)offset; + (void)user_data; + if (stream_user_data) + ioxd__stream_recv(stream_user_data, data, len, flags & NGTCP2_STREAM_DATA_FLAG_FIN); + return 0; +} + +static int cb_acked(ngtcp2_conn *conn, int64_t id, uint64_t offset, uint64_t len, void *user_data, void *stream_user_data) +{ + (void)conn; + (void)id; + (void)user_data; + if (stream_user_data) + ioxd__stream_acked(stream_user_data, offset, len); + return 0; +} + +static int cb_stream_close(ngtcp2_conn *conn, uint32_t flags, int64_t id, uint64_t rx_err, uint64_t tx_err, + void *user_data, void *stream_user_data) +{ + (void)flags; + (void)rx_err; + (void)tx_err; + (void)user_data; + if (stream_user_data) + ioxd__stream_closed(stream_user_data); + if (!ngtcp2_conn_is_local_stream(conn, id)) { + if (ngtcp2_is_bidi_stream(id)) + ngtcp2_conn_extend_max_streams_bidi(conn, 1); + else + ngtcp2_conn_extend_max_streams_uni(conn, 1); + } + return 0; +} + +static int cb_stream_reset(ngtcp2_conn *conn, int64_t id, uint64_t final_size, uint64_t err, void *user_data, + void *stream_user_data) +{ + (void)conn; + (void)id; + (void)final_size; + (void)err; + (void)user_data; + if (stream_user_data) + ioxd__stream_reset(stream_user_data); + return 0; +} + +static int cb_stop_sending(ngtcp2_conn *conn, int64_t id, uint64_t err, void *user_data, void *stream_user_data) +{ + (void)conn; + (void)id; + (void)user_data; + if (stream_user_data) + ioxd__stream_stop(stream_user_data, err); + return 0; +} + +static int cb_extend_max_stream_data(ngtcp2_conn *conn, int64_t id, uint64_t max_data, void *user_data, + void *stream_user_data) +{ + (void)conn; + (void)id; + (void)max_data; + (void)user_data; + if (stream_user_data) + ioxd__stream_unblock(stream_user_data); + return 0; +} + +static const ngtcp2_callbacks callbacks = { + .recv_client_initial = ngtcp2_crypto_recv_client_initial_cb, + .recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb, + .encrypt = ngtcp2_crypto_encrypt_cb, + .decrypt = ngtcp2_crypto_decrypt_cb, + .hp_mask = ngtcp2_crypto_hp_mask_cb, + .recv_stream_data = cb_recv_stream_data, + .acked_stream_data_offset = cb_acked, + .stream_open = cb_stream_open, + .rand = cb_rand, + .remove_connection_id = cb_remove_cid, + .update_key = ngtcp2_crypto_update_key_cb, + .stream_reset = cb_stream_reset, + .extend_max_stream_data = cb_extend_max_stream_data, + .delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb, + .delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb, + .stream_stop_sending = cb_stop_sending, + .version_negotiation = ngtcp2_crypto_version_negotiation_cb, + .get_new_connection_id2 = cb_new_cid, + .get_path_challenge_data2 = ngtcp2_crypto_get_path_challenge_data2_cb, + .stream_close2 = cb_stream_close, +}; + +static void conn_free(struct quic_conn *qc) +{ + struct quic_listener *ql = qc->ql; + heap_remove(ql, qc); + for (unsigned i = 0; i < qc->n_cids; i++) + table_remove(ql, &qc->cids[i]); + qc->n_cids = 0; + if (qc->prev) + qc->prev->next = qc->next; + else + ql->conns = qc->next; + if (qc->next) + qc->next->prev = qc->prev; + qc->next = qc->prev = nullptr; + ql->n_conns--; + ql->p->live--; + if (qc->conn) { + ngtcp2_conn_del(qc->conn); + qc->conn = nullptr; + } + if (qc->ssl) { + SSL_set_app_data(qc->ssl, nullptr); + SSL_free(qc->ssl); + qc->ssl = nullptr; + } + if (qc->ossl) { + ngtcp2_crypto_ossl_ctx_del(qc->ossl); + qc->ossl = nullptr; + } + if (qc->certs) { + ioxd__certs_release(ql->l->certs, qc->certs); + qc->certs = nullptr; + } + free(qc->close_pkt); + qc->close_pkt = nullptr; + qc->state = QUIC_DEAD; + ioxd__stream_abandon(qc); + timer_update(ql); + if (qc->live_coros == 0) + free(qc); +} + +static void accept_conn(struct quic_listener *ql, const struct sockaddr *from, socklen_t fromlen, const uint8_t *pkt, size_t len) +{ + ngtcp2_pkt_hd hd; + if (ngtcp2_accept(&hd, pkt, len) != 0 || ql->p->draining) + return; + struct quic_conn *qc = calloc(1, sizeof *qc); + if (!qc) { + note(ql, "accept", ENOMEM); + return; + } + qc->ql = ql; + qc->heap_idx = UINT_MAX; + qc->ref = (ngtcp2_crypto_conn_ref){ .get_conn = get_conn, .user_data = qc }; + if (fromlen > sizeof qc->remote) + fromlen = sizeof qc->remote; + memcpy(&qc->remote, from, fromlen); + qc->remote_len = fromlen; + ngtcp2_ccerr_default(&qc->err); + + ngtcp2_settings settings; + ngtcp2_settings_default(&settings); + settings.initial_ts = ioxd__quic_now(); + settings.handshake_timeout = HANDSHAKE_TIMEOUT; + + ngtcp2_cid scid; + mint_cid(qc, &scid); + + ngtcp2_transport_params params; + ngtcp2_transport_params_default(¶ms); + params.initial_max_stream_data_bidi_local = STREAM_WINDOW; + params.initial_max_stream_data_bidi_remote = STREAM_WINDOW; + params.initial_max_stream_data_uni = STREAM_WINDOW; + params.initial_max_data = CONN_WINDOW; + params.initial_max_streams_bidi = MAX_BIDI_STREAMS; + params.initial_max_streams_uni = MAX_UNI_STREAMS; + params.max_idle_timeout = IDLE_TIMEOUT; + params.active_connection_id_limit = 7; + params.original_dcid = hd.dcid; + params.original_dcid_present = 1; + params.stateless_reset_token_present = 1; + if (ngtcp2_crypto_generate_stateless_reset_token(params.stateless_reset_token, ql->secret, sizeof ql->secret, &scid) != 0) { + free(qc); + return; + } + + ngtcp2_path path = { + .local = { (ngtcp2_sockaddr *)&ql->local, sizeof ql->local }, + .remote = { (ngtcp2_sockaddr *)&qc->remote, qc->remote_len }, + }; + int rv = ngtcp2_conn_server_new(&qc->conn, &hd.scid, &scid, &path, hd.version, &callbacks, &settings, ¶ms, nullptr, qc); + trace("[w%d] quic accept: server_new %d\n", ql->p->id, rv); + if (rv != 0) { + note(ql, "ngtcp2_conn_server_new", rv); + free(qc); + return; + } + + qc->certs = ioxd__certs_acquire(ql->l->certs); + qc->ssl = SSL_new(ioxd__certs_fallback_quic(qc->certs)); + if (!qc->ssl || ngtcp2_crypto_ossl_ctx_new(&qc->ossl, qc->ssl) != 0 + || ngtcp2_crypto_ossl_configure_server_session(qc->ssl) != 0) { + note(ql, "TLS session", ENOMEM); + ERR_clear_error(); + if (qc->ossl) + ngtcp2_crypto_ossl_ctx_del(qc->ossl); + SSL_free(qc->ssl); + ioxd__certs_release(ql->l->certs, qc->certs); + ngtcp2_conn_del(qc->conn); + free(qc); + return; + } + SSL_set_app_data(qc->ssl, &qc->ref); + SSL_set_accept_state(qc->ssl); + ioxd__certs_bind(qc->ssl, qc->certs); + ioxd__certs_bind_alpn(qc->ssl, ql->l->alpn, ql->l->alpn_len); + ngtcp2_conn_set_tls_native_handle(qc->conn, qc->ossl); + + qc->next = ql->conns; + if (ql->conns) + ql->conns->prev = qc; + ql->conns = qc; + ql->n_conns++; + ql->p->live++; + ql->accepted++; + if (!cid_register(qc, &scid) || !cid_register(qc, &hd.dcid)) { + note(ql, "connection id table", ENOMEM); + conn_free(qc); + return; + } + conn_read(qc, from, fromlen, pkt, len); +} + +/* ── datagrams nobody owns ─────────────────────────────────────────────────────────────── */ + +static void send_version_negotiation(struct quic_listener *ql, const struct sockaddr *from, socklen_t fromlen, + const ngtcp2_version_cid *vc) +{ + struct quic_send *s = send_get(ql); + if (!s) + return; + uint8_t rnd; + uint32_t versions[2] = { 0x0a0a0a0aU, NGTCP2_PROTO_VER_V1 }; + cb_rand(&rnd, 1, nullptr); + ngtcp2_ssize n = ngtcp2_pkt_write_version_negotiation(s->buf, sizeof s->buf, rnd, vc->scid, vc->scidlen, + vc->dcid, vc->dcidlen, versions, 2); + if (n <= 0) { + send_put(ql, s); + return; + } + send_submit(ql, s, from, fromlen, (size_t)n, 0); +} + +static void send_stateless_reset(struct quic_listener *ql, const struct sockaddr *from, socklen_t fromlen, + const ngtcp2_version_cid *vc, size_t pktlen) +{ + if (pktlen < QUIC_CIDLEN + 22) + return; + time_t now = time(nullptr); + if (now != ql->resets_at) { + ql->resets_at = now; + ql->resets_left = RESETS_PER_SECOND; + } + if (ql->resets_left == 0) + return; + ql->resets_left--; + ngtcp2_cid cid; + ngtcp2_cid_init(&cid, vc->dcid, vc->dcidlen); + ngtcp2_stateless_reset_token token; + if (ngtcp2_crypto_generate_stateless_reset_token(token.data, ql->secret, sizeof ql->secret, &cid) != 0) + return; + uint8_t rnd[NGTCP2_MAX_CIDLEN + 22 - NGTCP2_STATELESS_RESET_TOKENLEN]; + size_t rndlen = pktlen <= 43 ? pktlen - NGTCP2_STATELESS_RESET_TOKENLEN - 1 : sizeof rnd; + cb_rand(rnd, rndlen, nullptr); + struct quic_send *s = send_get(ql); + if (!s) + return; + ngtcp2_ssize n = ngtcp2_pkt_write_stateless_reset2(s->buf, sizeof s->buf, &token, rnd, rndlen); + if (n <= 0) { + send_put(ql, s); + return; + } + send_submit(ql, s, from, fromlen, (size_t)n, 0); +} + +static void dispatch(struct quic_listener *ql, const struct sockaddr *from, socklen_t fromlen, const uint8_t *pkt, size_t len) +{ + ngtcp2_version_cid vc; + int rv = ngtcp2_pkt_decode_version_cid(&vc, pkt, len, QUIC_CIDLEN); + trace("[w%d] quic datagram %zu bytes from len %u: decode %d version %#x dcidlen %zu\n", ql->p->id, len, fromlen, rv, + rv == 0 ? vc.version : 0, rv == 0 ? vc.dcidlen : 0); + if (rv == NGTCP2_ERR_VERSION_NEGOTIATION) { + send_version_negotiation(ql, from, fromlen, &vc); + return; + } + if (rv != 0 || ((pkt[0] & 0x80U) && vc.version == 0)) + return; + struct quic_conn *qc = table_find(ql, vc.dcid, vc.dcidlen); + if (!qc) { + if (pkt[0] & 0x80U) + accept_conn(ql, from, fromlen, pkt, len); + else + send_stateless_reset(ql, from, fromlen, &vc, len); + return; + } + switch (qc->state) { + case QUIC_OPEN: + conn_read(qc, from, fromlen, pkt, len); + break; + case QUIC_CLOSING: + if (++qc->close_sent_since >= CLOSE_REPEAT) { + qc->close_sent_since = 0; + send_close(qc); + } + break; + default: + break; + } +} + +/* ── the recv ──────────────────────────────────────────────────────────────────────────── */ + +static void recv_arm(struct quic_listener *ql) +{ + struct quic_recv *r = &ql->recv; + r->msg = (struct msghdr){ .msg_name = &r->name, .msg_namelen = sizeof r->name }; + struct io_uring_sqe *sqe = ioxd__proactor_sqe(ql->p); + sqe->opcode = IORING_OP_RECVMSG; + sqe->fd = ql->fd; + sqe->addr = (uintptr_t)&r->msg; + sqe->len = 1; + sqe->flags = IOSQE_BUFFER_SELECT; + sqe->ioprio = IORING_RECV_MULTISHOT; + sqe->buf_group = BGID; + sqe->user_data = UD(&r->target, TAG_CALL); + ql->recv_armed = true; + ql->recv_starved = false; +} + +static void recv_on_cqe(ioxd_cqe_target *t, int res, unsigned flags) +{ + struct quic_recv *r = (struct quic_recv *)t; + struct quic_listener *ql = r->ql; + proactor_t *p = ql->p; + bool more = flags & IORING_CQE_F_MORE; + bool has_buf = flags & IORING_CQE_F_BUFFER; + uint16_t buf_id = (uint16_t)(flags >> (unsigned)IORING_CQE_BUFFER_SHIFT); + + trace("[w%d] quic recvmsg res=%d more=%d buf=%d\n", p->id, res, more, has_buf); + if (res < 0) { + if (has_buf) + ioxd__bufring_return(&p->bufs, buf_id); + if (res == -ENOBUFS) { + ql->recv_armed = false; + ql->recv_starved = true; + } else if (res == -ECANCELED) { + ql->recv_armed = false; + } else { + note(ql, "recvmsg", -res); + if (!more) { + ql->recv_armed = false; + if (!p->draining) + recv_arm(ql); + } + } + wake_streams(ql); + return; + } + if (has_buf) { + uint8_t *buf = ioxd__bufring_at(&p->bufs, buf_id); + if ((size_t)res >= sizeof(struct io_uring_recvmsg_out)) { + struct io_uring_recvmsg_out *o = (struct io_uring_recvmsg_out *)buf; + uint8_t *name = buf + sizeof *o; + uint8_t *payload = name + r->msg.msg_namelen + r->msg.msg_controllen; + uint8_t *end = buf + res; + size_t len = o->payloadlen; + if (payload <= end && !(o->flags & MSG_TRUNC) && o->namelen <= r->msg.msg_namelen) { + if (len > (size_t)(end - payload)) + len = (size_t)(end - payload); + if (len) + dispatch(ql, (const struct sockaddr *)name, (socklen_t)o->namelen, payload, len); + } + } + ioxd__bufring_return(&p->bufs, buf_id); + } + if (!more) { + ql->recv_armed = false; + if (!p->draining) + recv_arm(ql); + } + wake_streams(ql); +} + +/* ── the timer's completion, and the streams woken by a cycle ─────────────────────────── */ + +static void timer_on_cqe(ioxd_cqe_target *t, int res, unsigned flags) +{ + (void)res; + (void)flags; + struct quic_timer *tm = (struct quic_timer *)t; + struct quic_listener *ql = tm->ql; + tm->armed = tm->cancelling = false; + ngtcp2_tstamp now = ioxd__quic_now(); + while (ql->n_heap && ql->heap[0]->expiry <= now) { + struct quic_conn *qc = ql->heap[0]; + if (qc->state != QUIC_OPEN) { + conn_free(qc); + continue; + } + int rv = ngtcp2_conn_handle_expiry(qc->conn, now); + if (rv != 0) { + conn_error(qc, rv); + continue; + } + ioxd__quic_flush(qc); + if (qc->state == QUIC_OPEN) { + ngtcp2_tstamp expiry = ngtcp2_conn_get_expiry(qc->conn); + qc->expiry = expiry > now ? expiry : now + NGTCP2_MILLISECONDS; + heap_update(ql, qc); + } + } + timer_update(ql); + wake_streams(ql); +} + +void ioxd__quic_wake(struct quic_stream *s) +{ + struct quic_listener *ql = s->qc->ql; + if (s->waking) + return; + s->waking = true; + s->wake_next = nullptr; + if (ql->wake_tail) + ql->wake_tail->wake_next = s; + else + ql->wake_head = s; + ql->wake_tail = s; +} + +void ioxd__quic_unwake(struct quic_stream *s) +{ + struct quic_listener *ql = s->qc->ql; + if (!s->waking) + return; + struct quic_stream *prev = nullptr; + for (struct quic_stream *w = ql->wake_head; w; prev = w, w = w->wake_next) { + if (w != s) + continue; + if (prev) + prev->wake_next = w->wake_next; + else + ql->wake_head = w->wake_next; + if (ql->wake_tail == w) + ql->wake_tail = prev; + break; + } + s->waking = false; + s->wake_next = nullptr; +} + +static void wake_streams(struct quic_listener *ql) +{ + while (ql->wake_head) { + struct quic_stream *s = ql->wake_head; + ql->wake_head = s->wake_next; + if (!ql->wake_head) + ql->wake_tail = nullptr; + s->waking = false; + s->wake_next = nullptr; + ioxd__stream_resume(s); + } +} + +void ioxd__quic_service(proactor_t *p) +{ + for (int i = 0; i < p->n_listeners; i++) + if (p->listeners[i].ql) + wake_streams(p->listeners[i].ql); +} + +void ioxd__quic_rearm(struct listener *l) +{ + struct quic_listener *ql = l->ql; + if (ql && ql->recv_starved && !ql->p->draining) + recv_arm(ql); +} + +/* ── the port's life ───────────────────────────────────────────────────────────────────── */ + +static int socket_open(struct quic_listener *ql, uint16_t port) +{ + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); + if (fd < 0) + return -errno; + int one = 1, pmtu = IP_PMTUDISC_DO, bufsz = 4 * 1024 * 1024; + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); + setsockopt(fd, IPPROTO_IP, IP_MTU_DISCOVER, &pmtu, sizeof pmtu); + setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufsz, sizeof bufsz); + setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &bufsz, sizeof bufsz); + ql->local = (struct sockaddr_in){ .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_ANY) }; + if (bind(fd, (struct sockaddr *)&ql->local, sizeof ql->local) < 0) { /* NOLINT(readability-trailing-comma): glibc's transparent-union sockaddr argument trips the check */ + int err = errno; + close(fd); + return -err; + } + return fd; +} + +int ioxd__quic_open(proactor_t *p, struct listener *l) +{ + pthread_once(&crypto_once, crypto_init); + struct quic_listener *ql = calloc(1, sizeof *ql); + if (!ql) { + perror("quic"); + return -1; + } + ql->p = p; + ql->l = l; + ql->fd = socket_open(ql, l->port); + if (ql->fd < 0) { + fprintf(stderr, "[w%d] quic :%u: %s\n", p->id, l->port, ioxd__io_errstr(-ql->fd)); + free(ql); + return -1; + } + ql->table_mask = 1023; + ql->table = calloc(ql->table_mask + 1, sizeof *ql->table); + if (!ql->table || RAND_bytes(ql->secret, sizeof ql->secret) != 1) { + perror("quic"); + close(ql->fd); + free(ql->table); + free(ql); + return -1; + } + ql->recv.target.on_cqe = recv_on_cqe; + ql->recv.ql = ql; + ql->timer.target.on_cqe = timer_on_cqe; + ql->timer.ql = ql; + l->ql = ql; + l->fd = ql->fd; + recv_arm(ql); + return 0; +} + +void ioxd__quic_drain(struct listener *l) +{ + struct quic_listener *ql = l->ql; + if (!ql) + return; + if (ql->recv_armed) { + struct io_uring_sqe *sqe = ioxd__proactor_sqe(ql->p); + sqe->opcode = IORING_OP_ASYNC_CANCEL; + sqe->fd = -1; + sqe->addr = UD(&ql->recv.target, TAG_CALL); + sqe->user_data = TAG_IGNORE; + } + struct quic_conn *next; + for (struct quic_conn *qc = ql->conns; qc; qc = next) { + next = qc->next; + bool there = true; + if (qc->state == QUIC_OPEN) { + ngtcp2_ccerr_set_application_error(&qc->err, 0, nullptr, 0); + there = conn_close(qc); + } + if (there) + conn_free(qc); + } + if (ql->timer.armed && !ql->timer.cancelling) + timer_cancel(ql); + wake_streams(ql); +} + +void ioxd__quic_close(struct listener *l) +{ + struct quic_listener *ql = l->ql; + if (!ql) + return; + struct quic_conn *next; + for (struct quic_conn *qc = ql->conns; qc; qc = next) { + next = qc->next; + conn_free(qc); + } + while (ql->free_sends) { + struct quic_send *s = ql->free_sends; + ql->free_sends = s->next; + free(s); + } + close(ql->fd); + free(ql->table); + free(ql->heap); + free(ql); + l->ql = nullptr; +} + +#else + +int ioxd_bind_quic(int port, ioxd_certs *certs, const char *const *alpn) +{ + (void)certs; + (void)alpn; + fprintf(stderr, "ioxd_bind_quic: port %d: this build has no QUIC (make QUIC=1 with libngtcp2 and OpenSSL 3.5)\n", port); + return -1; +} + +#endif diff --git a/lib/quic/quic.h b/lib/quic/quic.h new file mode 100644 index 0000000..d01f155 --- /dev/null +++ b/lib/quic/quic.h @@ -0,0 +1,368 @@ +/* + * quic/quic.h - a QUIC port on its worker: the UDP socket, its multishot recvmsg, the + * connections keyed by connection id, their timers as one kernel timeout, and the sends that + * leave as GSO trains. ngtcp2 runs the protocol, OpenSSL its TLS 1.3; the streams are + * quic/stream.c. Private; not installed. + */ +#pragma once + +#include "io/internal.h" +#include "io/pipe.h" +#include "ioxd/quic.h" + +#if IOXD_QUIC + +#include +#include +#include +#include +#include +#include + +#define QUIC_CIDLEN 8 /* the ids this server mints; short headers are parsed by it */ +#define QUIC_SEND_BUF (63 * 1024) /* one GSO train, under the UDP payload ceiling */ +#define QUIC_HIGH_WATER (256UL * 1024) /* bytes a stream's writer may have unacknowledged before it parks */ +#define QUIC_CHUNK_MAX 16384U /* bytes of stream data one retained chunk holds */ + +struct quic_listener; +struct quic_conn; +struct quic_stream; + +/* A run of stream bytes: received and not yet read, or written and not yet acknowledged. */ +struct chunk { + struct chunk *next; + size_t len; + uint64_t offset; /* tx: where in the stream it starts */ + bool fin; /* rx: the stream ended with these bytes */ + uint8_t data[]; +}; + +/* One stream of a connection, served by a coroutine of its own with a pipe over it. */ +struct quic_stream { + struct quic_conn *qc; + int64_t id; + struct chunk *rx_head, *rx_tail; /* delivered, not yet read */ + bool rx_fin, rx_reset; /* the peer finished, or reset, its sending side */ + struct chunk *tx_head, *tx_tail; /* retained until acknowledged; ngtcp2 reads them in place */ + struct chunk *tx_cur; /* the first not yet wholly handed to ngtcp2 */ + uint64_t tx_queued, tx_sent, tx_acked; /* stream offsets */ + bool tx_fin, tx_fin_sent, tx_stopped, tx_blocked; + bool pending; /* in the connection's pump list */ + struct quic_stream *pump_next; + coro_t *parked; /* its coroutine, parked in a read or at the high-water mark */ + bool coro_done, closed; /* the handler returned; ngtcp2 is done with the stream */ + bool waking; /* in the listener's wake list */ + struct quic_stream *wake_next; + struct quic_stream *next; /* the connection's list */ +}; + +enum quic_state { QUIC_OPEN, QUIC_CLOSING, QUIC_DRAINING, QUIC_DEAD }; + +/* A connection: ngtcp2's, its TLS session, its streams, and where it stands in the listener's + * tables. It outlives its ngtcp2 conn while a stream's coroutine still runs. */ +struct quic_conn { + struct quic_listener *ql; + ngtcp2_conn *conn; + SSL *ssl; + ngtcp2_crypto_ossl_ctx *ossl; + ngtcp2_crypto_conn_ref ref; + struct table *certs; /* the store's table this handshake started on, referenced */ + struct sockaddr_storage remote; + socklen_t remote_len; + ngtcp2_ccerr err; + enum quic_state state; + uint8_t *close_pkt; /* the CONNECTION_CLOSE, repeated while closing */ + size_t close_len; + unsigned close_sent_since; /* packets taken since it was last repeated */ + ngtcp2_tstamp expiry; /* what the heap orders by */ + unsigned heap_idx; + ngtcp2_cid cids[16]; /* the ids in the table for this connection */ + unsigned n_cids; + struct quic_stream *streams; + struct quic_stream *pump, *pump_tail; /* streams with bytes to write, served in turn */ + unsigned live_coros; /* stream coroutines still running */ + struct quic_conn *next, *prev; /* the listener's list */ +}; + +/* A staged operation the loop calls back: the first member of each. */ +struct quic_recv { + ioxd_cqe_target target; + struct quic_listener *ql; + struct msghdr msg; + struct sockaddr_storage name; +}; +struct quic_send { + ioxd_cqe_target target; + struct quic_listener *ql; + struct msghdr msg; + struct iovec iov; + struct sockaddr_storage to; + char control[CMSG_SPACE(sizeof(uint16_t))]; + struct quic_send *next; + uint8_t buf[QUIC_SEND_BUF]; +}; +struct quic_timer { + ioxd_cqe_target target; + struct quic_listener *ql; + struct __kernel_timespec ts; + bool armed, cancelling; + ngtcp2_tstamp deadline; +}; + +struct cid_slot { + uint8_t cid[NGTCP2_MAX_CIDLEN]; + uint8_t len; /* 0: empty */ + bool tomb; + struct quic_conn *qc; +}; + +struct quic_listener { + proactor_t *p; + struct listener *l; + int fd; + struct sockaddr_in local; + struct quic_recv recv; + bool recv_armed, recv_starved; + struct quic_send *free_sends; + unsigned n_sends, n_free; + bool no_gso; + struct quic_timer timer; + struct quic_conn **heap; + unsigned n_heap, cap_heap; + struct cid_slot *table; + unsigned table_mask, table_used, table_tombs; + struct quic_conn *conns; + unsigned n_conns; + uint8_t secret[32]; /* stateless reset tokens */ + unsigned resets_left; /* this second */ + time_t resets_at; + struct quic_stream *wake_head, *wake_tail; + uint64_t accepted; +}; + +/* quic.c: the port's life on the worker, and what a stream needs of its connection. */ +int ioxd__quic_open (proactor_t *p, struct listener *l); /* socket, recv and tables; -1 with the reason logged */ +void ioxd__quic_drain (struct listener *l); /* shutdown: close every connection, stop receiving */ +void ioxd__quic_close (struct listener *l); /* after the ring: free it all */ +void ioxd__quic_rearm (struct listener *l); /* buffers came back: a recv parked on -ENOBUFS */ +void ioxd__quic_service(proactor_t *p); /* once per loop turn: resume the streams woken */ +void ioxd__quic_flush (struct quic_conn *qc); /* send what ngtcp2 has to send; a stream's send calls it */ +void ioxd__quic_wake (struct quic_stream *s); /* resume its parked coroutine, from the loop */ +void ioxd__quic_unwake(struct quic_stream *s); /* a stream freed while queued to wake */ +ngtcp2_tstamp ioxd__quic_now(void); + +/* stream.c: what the connection's callbacks hand over, and the pump the writes come from. */ +void ioxd__stream_open (struct quic_conn *qc, int64_t id); +void ioxd__stream_recv (struct quic_stream *s, const uint8_t *data, size_t len, bool fin); +void ioxd__stream_acked (struct quic_stream *s, uint64_t offset, uint64_t len); +void ioxd__stream_closed(struct quic_stream *s); +void ioxd__stream_reset (struct quic_stream *s); +void ioxd__stream_stop (struct quic_stream *s, uint64_t app_error); +void ioxd__stream_unblock(struct quic_stream *s); +void ioxd__stream_abandon(struct quic_conn *qc); /* the connection is gone: every stream ends */ +ngtcp2_ssize ioxd__stream_write_pkt(ngtcp2_conn *conn, ngtcp2_path *path, ngtcp2_pkt_info *pi, + uint8_t *dest, size_t destlen, ngtcp2_tstamp ts, void *user_data); +void ioxd__stream_resume(struct quic_stream *s); /* the loop: run what was woken */ + +#else + +static inline int ioxd__quic_open (proactor_t *p, struct listener *l) { (void)p; (void)l; return -1; } +static inline void ioxd__quic_drain (struct listener *l) { (void)l; } +static inline void ioxd__quic_close (struct listener *l) { (void)l; } +static inline void ioxd__quic_rearm (struct listener *l) { (void)l; } +static inline void ioxd__quic_service(proactor_t *p) { (void)p; } + +#endif + +/* ── quic.c: the notes ─────────────────────────────────────────────────────────────────── */ + +/* + * quic/quic.c - a QUIC port on one worker. The socket is the worker's own (SO_REUSEPORT), so + * the kernel spreads peers across workers as it does for TCP; one multishot recvmsg delivers + * every datagram into a provided buffer, with the peer's address in front. A datagram is + * routed by the connection id in its header: known ids go to their connection, an Initial with + * an unknown one starts a connection, the rest is answered with a stateless reset or dropped. + * A connection is ngtcp2's; a cycle - a datagram read, a timer fired - ends with everything + * ngtcp2 wants sent leaving as one GSO train per burst. Timers are one kernel timeout per + * worker, armed at the earliest expiry in a heap of connections. Stream coroutines are never + * resumed from inside ngtcp2: a callback queues the stream, and the loop resumes it after the + * cycle, when nothing is on the stack. + * + * The ids this server mints carry the worker in their first byte (cid[0] mod workers), so a + * kernel filter could steer a peer that changed address back here; without one a moved peer + * lands on whichever worker the 4-tuple hashes to and is dropped there, as a stale id. + */ + +/* at file scope: + * - what the transport parameters offer a peer: a window per stream, one for the connection, + * how many streams it may open, how long the connection may idle, and how long a handshake + * may take before the connection is dropped [#define IDLE_TIMEOUT (30 * + * NGTCP2_SECONDS)] + * - a CONNECTION_CLOSE is repeated once per this many packets that still arrive for the + * closing connection [#define CLOSE_REPEAT 8] + * - stateless resets a second, so an unknown id cannot make this server flood + * [#define RESETS_PER_SECOND 64] + * - send buffers kept warm per port [#define IDLE_SENDS 64] + */ + +/* ioxd__quic_now: + * ngtcp2's clock: nanoseconds, monotonic. + */ + +/* table_slot: + * Open addressing with linear probing over the ids: the slot holding cid, or with insert the + * first free one on its probe path (a tombstone first, so deleted ids do not pile up). + */ + +/* table_grow: + * Twice the slots, every live id rehashed, the tombstones dropped. + */ + +/* cid_register: + * An id this connection answers to, in the table and on the connection's own list so a free + * takes them all out; ngtcp2 issues at most the peer's active_connection_id_limit of them. + */ + +/* send_get: + * A send buffer from the port's pool, or a fresh one. + */ + +/* send_on_cqe: + * The send is done, the buffer free. A refused GSO train - a kernel without UDP_SEGMENT, a + * device that will not - turns GSO off for the port: from then on one packet per send; the + * train that failed is left to loss recovery. + */ + +/* send_submit: + * One sendmsg: the datagram(s) in the buffer to the peer, with a UDP_SEGMENT control message + * naming the segment size when the buffer holds a train. + */ + +/* heap_update: + * The connection's place in the heap after its expiry changed: pushed if new, else sifted. + */ + +/* timer_arm: + * The port's one kernel timeout, at the earliest expiry; relative, since the ring's timeouts + * are. Already due is one nanosecond away. + */ + +/* timer_update: + * The heap's earliest expiry against what is armed: nothing armed and something to wait for + * arms it; something earlier than the armed deadline cancels the armed one, and its cancel + * completion re-arms at the new earliest. A later deadline is left: the timer fires early + * and finds nothing due, which is cheaper than a cancel. + */ + +/* conn_reschedule: + * After a cycle: the connection's next expiry into the heap, the timer after it. + */ + +/* conn_leave: + * Into the closing or draining period: three PTOs, then the connection goes; its streams end + * now. + */ + +/* conn_close: + * A CONNECTION_CLOSE with the error recorded, sent and kept to repeat, and the closing period + * begins. Nothing to send - the handshake never got far enough - frees the connection. + */ + +/* conn_error: + * What ngtcp2's error means for the connection: the peer closed it (draining), it is to be + * dropped without a word, it idled out, or it failed and the peer is told. + */ + +/* ioxd__quic_flush: + * The write cycle: as many packets as ngtcp2 will write into one buffer - a GSO train of + * equal packets, the last may be shorter - each buffer one sendmsg, until ngtcp2 has nothing + * more or its pacing says later. The streams' bytes come through ioxd__stream_write_pkt. + * - as much as the pacer allows, never less than one packet, never more than the buffer + * [size_t buflen = quantum > one ? quantum : one;] + */ + +/* conn_read: + * A datagram for the connection: fed to ngtcp2 on the path it arrived on - a peer that moved + * is ngtcp2's to validate - then the write cycle, and the timer after it. + */ + +/* cb_new_cid: + * ngtcp2 wants another id to give the peer: random, the worker in its first byte, its + * stateless reset token from the port's secret, and into the table. + */ + +/* cb_stream_close: + * ngtcp2 is done with a stream; a stream the peer opened gives it one more to open. + */ + +/* conn_free: + * Out of the heap, the table and the list; ngtcp2's conn and the TLS session freed, the + * store's table released. The streams whose coroutines still run keep the struct itself + * alive: the last of them frees it. + */ + +/* accept_conn: + * An Initial for an id nobody holds: ngtcp2 checks it is one, and a connection is made for + * it - ngtcp2's, then the TLS session from the store's QUIC context, bound to the store's + * table for SNI and to the port's protocols for ALPN. The peer's chosen id and the one this + * server minted both route to it: the peer keeps using its own until it has read a reply. + * Then the Initial is fed like any datagram. + */ + +/* send_version_negotiation: + * A version this server does not speak: the versions it does, in a Version Negotiation + * packet, with a reserved one first so a client is kept honest about ignoring unknown ones. + */ + +/* send_stateless_reset: + * A short-header packet for an id nobody holds: a stateless reset with the token the id would + * have carried, so a peer that still holds the connection learns it is gone. Shorter than the + * packet it answers, at most so many a second. + */ + +/* dispatch: + * A datagram: its version and id decoded, then to the connection holding the id, or to accept + * for an Initial, or answered with a reset. A closing connection repeats its CONNECTION_CLOSE + * every CLOSE_REPEAT packets; a draining one says nothing. + */ + +/* recv_arm: + * The port's multishot recvmsg into the worker's provided buffers: the kernel writes the + * peer's address in front of each datagram. + */ + +/* recv_on_cqe: + * One datagram: the io_uring_recvmsg_out header, the name, then the payload, in the buffer + * the kernel picked; dispatched and the buffer returned. -ENOBUFS parks the recv until + * buffers come back (ioxd__quic_rearm); the multishot ending re-arms it. + */ + +/* timer_on_cqe: + * The timeout fired, or was cancelled to move: every connection whose expiry passed gets its + * expiry handled - loss recovery, idle, handshake timeouts, the end of a closing period - and + * a write cycle; then the timer is armed at the new earliest. + */ + +/* ioxd__quic_wake: + * A stream whose coroutine should run: queued for the loop. Never resumed here - a callback + * is ngtcp2's stack, and a handler must not run on it. + */ + +/* wake_streams: + * The loop, after a cycle: every queued stream's coroutine resumed. + */ + +/* socket_open: + * The port's UDP socket: SO_REUSEPORT so every worker has one, no fragmentation - QUIC + * forbids it - and room in the socket buffers for a burst. + */ + +/* ioxd__quic_open: + * The worker's share of the port: socket, tables, secret, and the recv armed. + */ + +/* ioxd__quic_drain: + * Shutdown: the recv cancelled, every connection told (application error 0) and freed at + * once - the closing period is not waited out - so the worker's count of live connections + * reaches zero. + */ diff --git a/lib/quic/stream.c b/lib/quic/stream.c new file mode 100644 index 0000000..83ac689 --- /dev/null +++ b/lib/quic/stream.c @@ -0,0 +1,433 @@ +#include "quic/quic.h" + +#if IOXD_QUIC + +#include "io/coro.h" + +#include +#include +#include +#include + +static struct chunk *chunk_new(const uint8_t *data, size_t len) +{ + struct chunk *c = malloc(sizeof *c + len); + if (!c) + return nullptr; + c->next = nullptr; + c->len = len; + c->offset = 0; + c->fin = false; + memcpy(c->data, data, len); + return c; +} + +static void chunks_free(struct chunk *c) +{ + while (c) { + struct chunk *next = c->next; + free(c); + c = next; + } +} + +static bool open_end(const struct quic_stream *s) +{ + return s->qc->state == QUIC_OPEN && !s->closed; +} + +static void park(struct quic_stream *s) +{ + s->parked = ioxd__coro_current(); + ioxd__coro_yield(); + s->parked = nullptr; +} + +/* ── the pump: streams with something to write, served in turn ────────────────────────── */ + +static void pump_add(struct quic_stream *s) +{ + struct quic_conn *qc = s->qc; + if (s->pending || s->tx_blocked || s->tx_stopped || s->closed) + return; + s->pending = true; + s->pump_next = nullptr; + if (qc->pump_tail) + qc->pump_tail->pump_next = s; + else + qc->pump = s; + qc->pump_tail = s; +} + +static void pump_remove(struct quic_stream *s) +{ + struct quic_conn *qc = s->qc; + if (!s->pending) + return; + struct quic_stream *prev = nullptr; + for (struct quic_stream *w = qc->pump; w; prev = w, w = w->pump_next) { + if (w != s) + continue; + if (prev) + prev->pump_next = w->pump_next; + else + qc->pump = w->pump_next; + if (qc->pump_tail == w) + qc->pump_tail = prev; + break; + } + s->pending = false; + s->pump_next = nullptr; +} + +static void pump_rotate(struct quic_stream *s) +{ + struct quic_conn *qc = s->qc; + if (qc->pump != s || qc->pump_tail == s) + return; + qc->pump = s->pump_next; + s->pump_next = nullptr; + qc->pump_tail->pump_next = s; + qc->pump_tail = s; +} + +static bool wants_write(const struct quic_stream *s) +{ + return s->tx_sent < s->tx_queued || (s->tx_fin && !s->tx_fin_sent); +} + +static size_t tx_vecs(struct quic_stream *s, ngtcp2_vec *vecs, size_t max) +{ + size_t n = 0; + for (struct chunk *c = s->tx_cur; c && n < max; c = c->next) { + uint64_t skip = s->tx_sent > c->offset ? s->tx_sent - c->offset : 0; + if (skip >= c->len) + continue; + vecs[n++] = (ngtcp2_vec){ c->data + skip, c->len - (size_t)skip }; + } + return n; +} + +static void tx_advance(struct quic_stream *s, size_t n) +{ + s->tx_sent += n; + while (s->tx_cur && s->tx_cur->offset + s->tx_cur->len <= s->tx_sent) + s->tx_cur = s->tx_cur->next; +} + +ngtcp2_ssize ioxd__stream_write_pkt(ngtcp2_conn *conn, ngtcp2_path *path, ngtcp2_pkt_info *pi, uint8_t *dest, + size_t destlen, ngtcp2_tstamp ts, void *user_data) +{ + struct quic_conn *qc = user_data; + for (;;) { + struct quic_stream *s = qc->pump; + while (s && !wants_write(s)) { + pump_remove(s); + s = qc->pump; + } + ngtcp2_ssize ndatalen = 0; + if (!s) + return ngtcp2_conn_writev_stream(conn, path, pi, dest, destlen, &ndatalen, NGTCP2_WRITE_STREAM_FLAG_NONE, -1, + nullptr, 0, ts); + ngtcp2_vec vecs[8]; + size_t nvec = tx_vecs(s, vecs, sizeof vecs / sizeof vecs[0]); + size_t total = 0; + for (size_t i = 0; i < nvec; i++) + total += vecs[i].len; + bool all = s->tx_sent + total == s->tx_queued; + uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; + if (s->tx_fin && all) + flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + ngtcp2_ssize n = ngtcp2_conn_writev_stream(conn, path, pi, dest, destlen, &ndatalen, flags, s->id, vecs, nvec, ts); + trace("[w%d] quic stream %lld write: %zu vecs %zu bytes fin=%d -> n=%zd ndatalen=%zd\n", qc->ql->p->id, + (long long)s->id, nvec, total, !!(flags & NGTCP2_WRITE_STREAM_FLAG_FIN), n, ndatalen); + if (ndatalen > 0) + tx_advance(s, (size_t)ndatalen); + if (n == NGTCP2_ERR_STREAM_DATA_BLOCKED) { + s->tx_blocked = true; + pump_remove(s); + continue; + } + if (n == NGTCP2_ERR_STREAM_SHUT_WR || n == NGTCP2_ERR_STREAM_NOT_FOUND) { + s->tx_stopped = true; + pump_remove(s); + ioxd__quic_wake(s); + continue; + } + if (n == 0) + return 0; + if (n < 0 && n != NGTCP2_ERR_WRITE_MORE) + return n; + if ((flags & NGTCP2_WRITE_STREAM_FLAG_FIN) && (size_t)(ndatalen > 0 ? ndatalen : 0) == total) + s->tx_fin_sent = true; + if (!wants_write(s)) + pump_remove(s); + else + pump_rotate(s); + if (n == NGTCP2_ERR_WRITE_MORE) + continue; + return n; + } +} + +/* ── the link a stream's pipe is on ────────────────────────────────────────────────────── */ + +static int link_recv_item(void *link, struct rx_item *out) +{ + struct quic_stream *s = link; + for (;;) { + struct chunk *c = s->rx_head; + if (c) { + s->rx_head = c->next; + if (!s->rx_head) + s->rx_tail = nullptr; + out->ptr = c->data; + out->len = (uint32_t)c->len; + out->buf_id = 0; + return 1; + } + if (s->rx_fin) + return 0; + if (s->rx_reset || !open_end(s)) + return -ECONNRESET; + park(s); + } +} + +static bool link_has_item(void *link) +{ + return ((struct quic_stream *)link)->rx_head != nullptr; +} + +static void link_release(void *link, const struct rx_item *item) +{ + struct quic_stream *s = link; + struct chunk *c = (struct chunk *)((uint8_t *)(uintptr_t)item->ptr - offsetof(struct chunk, data)); + size_t len = c->len; + free(c); + if (open_end(s)) { + ngtcp2_conn_extend_max_stream_offset(s->qc->conn, s->id, len); + ngtcp2_conn_extend_max_offset(s->qc->conn, len); + ioxd__quic_flush(s->qc); + } +} + +static int link_send(void *link, const void *data, size_t n) +{ + struct quic_stream *s = link; + if (s->tx_stopped || s->tx_fin || !open_end(s)) + return -EPIPE; + const uint8_t *p = data; + for (size_t left = n; left;) { + size_t k = left < QUIC_CHUNK_MAX ? left : QUIC_CHUNK_MAX; + struct chunk *c = chunk_new(p, k); + if (!c) + return -ENOMEM; + c->offset = s->tx_queued; + s->tx_queued += k; + if (s->tx_tail) + s->tx_tail->next = c; + else + s->tx_head = c; + s->tx_tail = c; + if (!s->tx_cur) + s->tx_cur = c; + p += k; + left -= k; + } + pump_add(s); + ioxd__quic_flush(s->qc); + while (open_end(s) && !s->tx_stopped && s->tx_queued - s->tx_acked > QUIC_HIGH_WATER) + park(s); + if (s->tx_stopped || !open_end(s)) + return -EPIPE; + return (int)n; +} + +static const ioxd_pipe_link stream_link = { + .recv_item = link_recv_item, + .has_item = link_has_item, + .release = link_release, + .send = link_send, +}; + +/* ── the stream's life ─────────────────────────────────────────────────────────────────── */ + +static void stream_free(struct quic_stream *s) +{ + struct quic_conn *qc = s->qc; + pump_remove(s); + ioxd__quic_unwake(s); + struct quic_stream *prev = nullptr; + for (struct quic_stream *w = qc->streams; w; prev = w, w = w->next) { + if (w != s) + continue; + if (prev) + prev->next = w->next; + else + qc->streams = w->next; + break; + } + chunks_free(s->rx_head); + chunks_free(s->tx_head); + free(s); +} + +static void stream_finish(struct quic_stream *s) +{ + struct quic_conn *qc = s->qc; + trace("[w%d] quic stream %lld handler done: closed=%d state=%d\n", qc->ql->p->id, (long long)s->id, s->closed, qc->state); + if (open_end(s)) { + if (!s->tx_stopped && !s->tx_fin) { + s->tx_fin = true; + pump_add(s); + } + if (!s->rx_fin && !s->rx_reset) + ngtcp2_conn_shutdown_stream_read(qc->conn, 0, s->id, 0); + ioxd__quic_flush(qc); + } + chunks_free(s->rx_head); + s->rx_head = s->rx_tail = nullptr; + s->coro_done = true; + qc->live_coros--; + if (s->closed || qc->state == QUIC_DEAD) + stream_free(s); + if (qc->state == QUIC_DEAD && qc->live_coros == 0) + free(qc); +} + +static void stream_main(void *arg) +{ + struct quic_stream *s = arg; + char gather[IOXD_PIPE_GATHER]; + char slab[IOXD_PIPE_LEAD + IOXD_PIPE_CAP + IOXD_PIPE_SLACK]; + ioxd_pipe pipe; + ioxd__pipe_init(&pipe, s, &stream_link, nullptr, gather, sizeof gather, slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, + IOXD_PIPE_SLACK); + s->qc->ql->p->stream_handler(&pipe); + ioxd__pipe_close(&pipe); + stream_finish(s); +} + +void ioxd__stream_open(struct quic_conn *qc, int64_t id) +{ + trace("[w%d] quic stream %lld open\n", qc->ql->p->id, (long long)id); + struct quic_stream *s = calloc(1, sizeof *s); + if (!s) { + ngtcp2_conn_shutdown_stream(qc->conn, 0, id, 0); + return; + } + s->qc = qc; + s->id = id; + if (!ngtcp2_is_bidi_stream(id)) + s->tx_stopped = true; + s->next = qc->streams; + qc->streams = s; + ngtcp2_conn_set_stream_user_data(qc->conn, id, s); + qc->live_coros++; + ioxd__proactor_spawn(qc->ql->p, stream_main, s); +} + +void ioxd__stream_recv(struct quic_stream *s, const uint8_t *data, size_t len, bool fin) +{ + trace("[w%d] quic stream %lld recv %zu bytes fin=%d parked=%d\n", s->qc->ql->p->id, (long long)s->id, len, fin, s->parked != nullptr); + if (len) { + struct chunk *c = chunk_new(data, len); + if (!c) { + s->rx_reset = true; + ioxd__quic_wake(s); + return; + } + if (s->rx_tail) + s->rx_tail->next = c; + else + s->rx_head = c; + s->rx_tail = c; + } + if (fin) + s->rx_fin = true; + ioxd__quic_wake(s); +} + +void ioxd__stream_acked(struct quic_stream *s, uint64_t offset, uint64_t len) +{ + uint64_t end = offset + len; + if (end > s->tx_acked) + s->tx_acked = end; + while (s->tx_head && s->tx_head->offset + s->tx_head->len <= s->tx_acked) { + struct chunk *c = s->tx_head; + s->tx_head = c->next; + if (!s->tx_head) + s->tx_tail = nullptr; + if (s->tx_cur == c) + s->tx_cur = c->next; + free(c); + } + if (s->parked && s->tx_queued - s->tx_acked <= QUIC_HIGH_WATER) + ioxd__quic_wake(s); +} + +void ioxd__stream_closed(struct quic_stream *s) +{ + s->closed = true; + pump_remove(s); + chunks_free(s->tx_head); + s->tx_head = s->tx_tail = s->tx_cur = nullptr; + if (s->coro_done) { + stream_free(s); + return; + } + ioxd__quic_wake(s); +} + +void ioxd__stream_reset(struct quic_stream *s) +{ + s->rx_reset = true; + ioxd__quic_wake(s); +} + +void ioxd__stream_stop(struct quic_stream *s, uint64_t app_error) +{ + s->tx_stopped = true; + pump_remove(s); + if (open_end(s)) + ngtcp2_conn_shutdown_stream_write(s->qc->conn, 0, s->id, app_error); + ioxd__quic_wake(s); +} + +void ioxd__stream_unblock(struct quic_stream *s) +{ + s->tx_blocked = false; + if (wants_write(s)) + pump_add(s); +} + +void ioxd__stream_abandon(struct quic_conn *qc) +{ + struct quic_stream *next; + for (struct quic_stream *s = qc->streams; s; s = next) { + next = s->next; + pump_remove(s); + if (s->coro_done) { + stream_free(s); + continue; + } + if (qc->state == QUIC_DEAD) { + chunks_free(s->tx_head); + s->tx_head = s->tx_tail = s->tx_cur = nullptr; + s->closed = true; + } + ioxd__quic_wake(s); + } +} + +void ioxd__stream_resume(struct quic_stream *s) +{ + coro_t *c = s->parked; + if (!c) + return; + s->parked = nullptr; + ioxd__coro_resume(c); +} + +#endif diff --git a/lib/quic/stream.h b/lib/quic/stream.h new file mode 100644 index 0000000..6a98848 --- /dev/null +++ b/lib/quic/stream.h @@ -0,0 +1,135 @@ +/* + * quic/stream.h - the notes of stream.c, whose declarations are quic/quic.h + */ +#pragma once + +/* ── stream.c: the notes ───────────────────────────────────────────────────────────────── */ + +/* + * quic/stream.c - a QUIC stream as a pipe: its own coroutine runs the pipe handler over it. + * What ngtcp2 delivers for the stream is copied into chunks the reader pops and gives back - + * the datagram's buffer goes back to the kernel at once, and giving a chunk back is what + * opens the peer's window by that much. What the handler writes is copied into chunks that + * stay until the peer acknowledges them, since ngtcp2 keeps pointers into them for a + * retransmission; a writer with more than QUIC_HIGH_WATER unacknowledged parks until acks + * bring it down. A stream with bytes to send is in its connection's pump, which the write + * cycle (quic.c) serves in turn, one stream's bytes after another's into the same packet. + * + * Nothing here resumes a coroutine: a callback marks the stream and asks the listener to + * wake it (ioxd__quic_wake), and the loop resumes it once ngtcp2 has unwound. + */ + +/* chunk_new: + * A copy of the bytes, with the header in front. + */ + +/* open_end: + * The connection is serving and ngtcp2 still has the stream: a read may wait, a write may go. + */ + +/* park: + * Suspend the stream's coroutine until the listener resumes it. + */ + +/* pump_add: + * Queue the stream for the write cycle, unless it has nothing it may write now. + */ + +/* pump_remove: + * Take it out, wherever it is in the list. + */ + +/* pump_rotate: + * The head goes to the tail: a stream that wrote gives the next one its turn. + */ + +/* wants_write: + * Bytes not yet handed to ngtcp2, or a FIN not yet sent. + */ + +/* tx_vecs: + * The retained bytes from the send offset on, as vectors for ngtcp2, at most max of them. + */ + +/* tx_advance: + * ngtcp2 took n bytes: move the send offset and the chunk cursor past them. + */ + +/* ioxd__stream_write_pkt: + * One packet for ngtcp2_conn_write_aggregate_pkt2 (quic.c): the pump's streams offer their + * bytes in turn with WRITE_STREAM_FLAG_MORE, so several share a packet, until ngtcp2 says the + * packet is complete; with nothing to offer, ngtcp2 writes what it has of its own (ACKs, its + * frames). A FIN goes with the last bytes, or alone once they are all handed over. + * - the window is shut on this stream: it re-enters the pump when ngtcp2 opens it + * (ioxd__stream_unblock) [if (n == NGTCP2_ERR_STREAM_DATA_BLOCKED) {] + * - the stream cannot be written any more: the writer learns so [if (n == + * NGTCP2_ERR_STREAM_SHUT_WR || n == NGTCP2_ERR_STREAM_NOT_FOUND) {] + * - nothing could be written now - congestion, pacing - so nothing was [if (n == 0)] + * - the FIN went out only when every byte offered went with it [if ((flags & + * NGTCP2_WRITE_STREAM_FLAG_FIN) && (size_t)(ndatalen > 0 ? ndatalen : 0) == total)] + */ + +/* link_recv_item: + * The reader's next chunk; parks until one arrives. 0 once the peer's FIN was delivered, + * -ECONNRESET when the peer reset the stream, the stream went away or the connection did. + */ + +/* link_release: + * A chunk read to the end: freed, and the peer's window opened by as much, with a write cycle + * so the MAX_STREAM_DATA can go out. + */ + +/* link_send: + * The bytes copied into retained chunks, the pump told, a write cycle run; then the writer + * parks while more than the high-water mark is unacknowledged. -EPIPE once the stream cannot + * take more: the peer stopped it, it ended, or the connection did. + */ + +/* stream_free: + * Out of every list, its chunks gone. Only once ngtcp2 is done with the stream or the + * connection is gone: until then ngtcp2 may still read the retained chunks. + */ + +/* stream_finish: + * The handler returned: the FIN goes after the last byte, a peer still sending is told to + * stop, whatever was not read is dropped. The stream stays for ngtcp2 while it still holds + * it; a connection already gone is freed by its last coroutine. + */ + +/* stream_main: + * The stream's coroutine: a pipe over the stream, the handler, the finish. + */ + +/* ioxd__stream_open: + * The peer opened a stream: a stream object, hung on ngtcp2's stream as its user data, and a + * coroutine spawned for it. A one-way stream is read-only, so its send side is stopped from + * the start. + */ + +/* ioxd__stream_recv: + * Bytes from ngtcp2, inside its callback: copied into a chunk, the reader woken after the + * cycle. + */ + +/* ioxd__stream_acked: + * The peer acknowledged up to an offset: the chunks wholly below it are freed, and a writer + * parked at the high-water mark is woken if it is now under it. + */ + +/* ioxd__stream_closed: + * ngtcp2 is done with the stream: its retained bytes can go, and so can the stream once the + * handler has returned. + */ + +/* ioxd__stream_stop: + * STOP_SENDING from the peer: nothing more is written, and ngtcp2 resets the sending side. + */ + +/* ioxd__stream_abandon: + * The connection left: every stream is woken to end; those whose handler already returned + * are freed now. With the connection gone, ngtcp2 holds nothing, so the retained bytes go too. + */ + +/* ioxd__stream_resume: + * The loop resumes the parked coroutine, if any, once nothing of ngtcp2's is on the stack. + */ diff --git a/lib/tls/certs.c b/lib/tls/certs.c index 69e0166..583505c 100644 --- a/lib/tls/certs.c +++ b/lib/tls/certs.c @@ -8,6 +8,10 @@ #if IOXD_TLS +#if IOXD_QUIC +#include +#endif + #include #include #include @@ -20,6 +24,7 @@ struct host { char *name; SSL_CTX *ctx; + SSL_CTX *quic; /* the same certificate, configured for QUIC; nullptr without QUIC */ }; struct table { @@ -27,6 +32,7 @@ struct table { struct host *hosts; int n; SSL_CTX *fallback; + SSL_CTX *fallback_quic; }; struct ioxd_certs { @@ -36,11 +42,12 @@ struct ioxd_certs { pthread_mutex_t reload; }; -static int table_ex; +static int table_ex, alpn_ex; static pthread_once_t table_ex_once = PTHREAD_ONCE_INIT; static void make_table_ex(void) { table_ex = SSL_get_ex_new_index(0, nullptr, nullptr, nullptr, nullptr); + alpn_ex = SSL_get_ex_new_index(0, nullptr, nullptr, nullptr, nullptr); } void ioxd__certs_bind(SSL *ssl, struct table *t) @@ -72,13 +79,13 @@ static bool host_eq(const char *a, size_t alen, const char *b) return true; } -static SSL_CTX *lookup(const struct table *t, const char *name, size_t len) +static const struct host *lookup(const struct table *t, const char *name, size_t len) { if (len > 1 && name[len - 1] == '.') len--; for (int i = 0; i < t->n; i++) if (host_eq(name, len, t->hosts[i].name)) - return t->hosts[i].ctx; + return &t->hosts[i]; const char *dot = memchr(name, '.', len); if (dot) { char wild[256]; @@ -88,33 +95,104 @@ static SSL_CTX *lookup(const struct table *t, const char *name, size_t len) memcpy(wild + 1, dot, rest); for (int i = 0; i < t->n; i++) if (host_eq(wild, rest + 1, t->hosts[i].name)) - return t->hosts[i].ctx; + return &t->hosts[i]; } } return nullptr; } -static int on_client_hello(SSL *ssl, int *alert, void *arg) /* NOLINT(readability-non-const-parameter): OpenSSL's signature */ +static const struct host *hello_host(SSL *ssl) { - (void)alert; - (void)arg; const struct table *t = SSL_get_ex_data(ssl, table_ex); const unsigned char *ext; size_t ext_len; if (!t) - return SSL_CLIENT_HELLO_SUCCESS; + return nullptr; if (!SSL_client_hello_get0_ext(ssl, TLSEXT_TYPE_server_name, &ext, &ext_len) || ext_len < 5) - return SSL_CLIENT_HELLO_SUCCESS; + return nullptr; size_t list_len = (size_t)ext[0] << 8 | ext[1]; size_t name_len = (size_t)ext[3] << 8 | ext[4]; if (ext[2] != 0 || name_len + 3 != list_len || list_len + 2 != ext_len) - return SSL_CLIENT_HELLO_SUCCESS; - SSL_CTX *ctx = lookup(t, (const char *)ext + 5, name_len); - if (ctx) - SSL_set_SSL_CTX(ssl, ctx); + return nullptr; + return lookup(t, (const char *)ext + 5, name_len); +} + +static int on_client_hello(SSL *ssl, int *alert, void *arg) /* NOLINT(readability-non-const-parameter): OpenSSL's signature */ +{ + (void)alert; + (void)arg; + const struct host *h = hello_host(ssl); + if (h) + SSL_set_SSL_CTX(ssl, h->ctx); return SSL_CLIENT_HELLO_SUCCESS; } +#if IOXD_QUIC +static int on_client_hello_quic(SSL *ssl, int *alert, void *arg) /* NOLINT(readability-non-const-parameter): OpenSSL's signature */ +{ + (void)alert; + (void)arg; + const struct host *h = hello_host(ssl); + if (h && h->quic) + SSL_set_SSL_CTX(ssl, h->quic); + return SSL_CLIENT_HELLO_SUCCESS; +} + +static int select_alpn(SSL *ssl, const unsigned char **out, unsigned char *outlen, const unsigned char *in, + unsigned int inlen, void *arg) +{ + (void)arg; + const uint8_t *ours = SSL_get_ex_data(ssl, alpn_ex); + size_t ours_len = ours ? ours[0] | (size_t)ours[1] << 8 : 0; + ours = ours ? ours + 2 : nullptr; + for (size_t off = 0; off < ours_len; off += 1 + ours[off]) { + size_t len = ours[off]; + for (unsigned int at = 0; at < inlen; at += 1 + in[at]) { + if (in[at] == len && at + 1 + len <= inlen && memcmp(in + at + 1, ours + off + 1, len) == 0) { + *out = in + at + 1; + *outlen = (unsigned char)len; + return SSL_TLSEXT_ERR_OK; + } + } + } + return SSL_TLSEXT_ERR_ALERT_FATAL; +} + +void ioxd__certs_bind_alpn(SSL *ssl, const uint8_t *alpn, size_t len) +{ + (void)len; + pthread_once(&table_ex_once, make_table_ex); + SSL_set_ex_data(ssl, alpn_ex, (void *)(uintptr_t)alpn); +} + +static SSL_CTX *context_for_quic(const char *cert, const char *key) +{ + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx) + return nullptr; + bool ok = SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION) + && SSL_CTX_set_num_tickets(ctx, 0) == 1 + && SSL_CTX_use_certificate_chain_file(ctx, cert) == 1 + && SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) == 1 + && SSL_CTX_check_private_key(ctx) == 1; + if (!ok) { + SSL_CTX_free(ctx); + return nullptr; + } + SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION | SSL_OP_NO_TICKET | SSL_OP_NO_ANTI_REPLAY | SSL_OP_CIPHER_SERVER_PREFERENCE); + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_OFF); + SSL_CTX_set_mode(ctx, SSL_MODE_RELEASE_BUFFERS); + SSL_CTX_set_client_hello_cb(ctx, on_client_hello_quic, nullptr); + SSL_CTX_set_alpn_select_cb(ctx, select_alpn, nullptr); + return ctx; +} + +SSL_CTX *ioxd__certs_fallback_quic(const struct table *t) +{ + return t->fallback_quic; +} +#endif + static SSL_CTX *context_for(const char *cert, const char *key) { SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); @@ -157,6 +235,7 @@ static void table_free(struct table *t) { for (int i = 0; i < t->n; i++) { SSL_CTX_free(t->hosts[i].ctx); + SSL_CTX_free(t->hosts[i].quic); free(t->hosts[i].name); } free(t->hosts); @@ -202,8 +281,16 @@ static struct table *load(const char *dir, const struct table *old) if (ks.st_mode & ((unsigned)S_IRGRP | (unsigned)S_IROTH)) fprintf(stderr, "ioxd_certs: %s: mode %03o, readable past its owner\n", key, (unsigned)(ks.st_mode & 0777)); - SSL_CTX *ctx = context_for(cert, key); - const char *why = ctx ? not_current(ctx) : ssl_error(); + SSL_CTX *ctx = context_for(cert, key); + SSL_CTX *quic = nullptr; + const char *why = ctx ? not_current(ctx) : ssl_error(); +#if IOXD_QUIC + if (ctx && !why) { + quic = context_for_quic(cert, key); + if (!quic) + why = ssl_error(); + } +#endif if (ctx && why) { SSL_CTX_free(ctx); ctx = nullptr; @@ -213,8 +300,11 @@ static struct table *load(const char *dir, const struct table *old) if (old) { for (int i = 0; i < old->n; i++) if (strcmp(old->hosts[i].name, e->d_name) == 0) { - ctx = old->hosts[i].ctx; + ctx = old->hosts[i].ctx; + quic = old->hosts[i].quic; SSL_CTX_up_ref(ctx); + if (quic) + SSL_CTX_up_ref(quic); } } if (!ctx) @@ -227,9 +317,11 @@ static struct table *load(const char *dir, const struct table *old) abort(); } t->hosts = grown; - t->hosts[t->n++] = (struct host){ name, ctx }; - if (strcmp(e->d_name, "default") == 0) - t->fallback = ctx; + t->hosts[t->n++] = (struct host){ name, ctx, quic }; + if (strcmp(e->d_name, "default") == 0) { + t->fallback = ctx; + t->fallback_quic = quic; + } } closedir(d); if (t->n == 0) { @@ -240,8 +332,10 @@ static struct table *load(const char *dir, const struct table *old) if (!t->fallback) { const char *prev = old ? fallback_name(old) : nullptr; for (int i = 0; prev && !t->fallback && i < t->n; i++) - if (strcmp(t->hosts[i].name, prev) == 0) - t->fallback = t->hosts[i].ctx; + if (strcmp(t->hosts[i].name, prev) == 0) { + t->fallback = t->hosts[i].ctx; + t->fallback_quic = t->hosts[i].quic; + } if (!t->fallback) { fprintf(stderr, "ioxd_certs: %s: no `default` host (default/cert.pem + key.pem) to answer" " when SNI matches nothing\n", dir); diff --git a/lib/tls/certs.h b/lib/tls/certs.h index 5ce79d2..978e4da 100644 --- a/lib/tls/certs.h +++ b/lib/tls/certs.h @@ -16,6 +16,10 @@ struct table *ioxd__certs_acquire (ioxd_certs *certs); /* the ta void ioxd__certs_release (ioxd_certs *certs, struct table *t); SSL_CTX *ioxd__certs_fallback(const struct table *t); /* the context a handshake starts on */ void ioxd__certs_bind (SSL *ssl, struct table *t); /* the table its ClientHello picks a host from */ +#if IOXD_QUIC +SSL_CTX *ioxd__certs_fallback_quic(const struct table *t); /* the same, for a QUIC handshake: its contexts carry the QUIC TLS callbacks */ +void ioxd__certs_bind_alpn(SSL *ssl, const uint8_t *alpn, size_t len); /* the protocols its ALPN callback picks from, wire form */ +#endif #endif /* ── certs.c: the notes ──────────────────────────────────────────────────────────────────── */ diff --git a/manual/build.py b/manual/build.py index b33d80b..95f75de 100644 --- a/manual/build.py +++ b/manual/build.py @@ -31,6 +31,7 @@ ("ioxd/timer.h", "ioxd_timer", "a delay that parks the connection, not the worker"), ("ioxd/socket.h", "ioxd_socket", "outbound connections, as pipes"), ("ioxd/tls.h", "ioxd_tls", "a certificate store, for a TLS port"), + ("ioxd/quic.h", "ioxd_quic", "a QUIC port: its streams as pipes"), ] @@ -442,9 +443,9 @@ def render_page(header, page, subject, top_paras, entries, index, version_str, e cc main.c $(pkg-config --cflags --libs ioxd) -o server

DESCRIPTION

-

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core. Each worker owns an io_uring -ring, a ring of receive buffers the kernel delivers into, and its own sockets on every bound port -(SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own +

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core, and QUIC, every stream of +it a pipe. Each worker owns an io_uring ring, a ring of receive buffers the kernel delivers into, and +its own sockets on every bound port (SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own coroutine, so a handler reads the body and writes the reply in straight-line code: a call that has to wait for the wire suspends the coroutine, and the worker's loop resumes it on the completion.

@@ -495,7 +496,9 @@ def render_page(header, page, subject, top_paras, entries, index, version_str, e

Building

Linux 6.x on x86-64, gcc 14 or newer (the library is C23; the headers are usable from C11), OpenSSL 3 for -the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out). +the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out), +and for QUIC libngtcp2 with its OpenSSL backend over OpenSSL 3.5 or newer (in when pkg-config finds them; +make QUIC=1 or -DIOXD_QUIC=ON insists, QUIC=0 / OFF leaves it out). make produces libioxd.a and libioxd.so; make install the headers and a pkg-config file; CMake exports ioxd::ioxd. Kernel TLS needs a kernel with SOCKET_URING_OP_SETSOCKOPT (6.7 or newer) when the registered file table is on, which is the default.

@@ -513,6 +516,7 @@ def render_page(header, page, subject, top_paras, entries, index, version_str, e
<ioxd/timer.h>
a delay that parks the connection, not the worker
<ioxd/socket.h>
outbound connections, as pipes
<ioxd/tls.h>
a certificate store, for a TLS port
+
<ioxd/quic.h>
a QUIC port: its streams as pipes

SEE ALSO

@@ -521,6 +525,29 @@ def render_page(header, page, subject, top_paras, entries, index, version_str, e """ EXAMPLES = { + "ioxd_quic": [ + ("A line echo, on TCP and on QUIC streams, with one handler:", + """static void echo(ioxd_pipe *pipe) +{ + for (;;) { + ioxd_slice live = { NULL, 0 }; + if (ioxd_pipe_read(pipe, &live) <= 0) /* the stream's end, or the connection's */ + return; /* returning ends the stream: its FIN goes out */ + ioxd_pipe_write(pipe, live.p, live.len); + ioxd_pipe_drop(pipe, live.len); + if (ioxd_pipe_flush(pipe) < 0) + return; + } +} + +int main(void) +{ + ioxd_certs *certs = ioxd_certs_load("certs"); /* QUIC is TLS 1.3: a store is required */ + ioxd_bind(8080, NULL); + ioxd_bind_quic(8443, certs, (const char *const[]){ "echo", NULL }); + return ioxd_run_pipes(0, echo); +}"""), + ], "ioxd_config": [ ("Twice the receive buffers, before the run; every other field keeps its default:", """ioxd_config config = { .recv_buffers = 8192 }; diff --git a/manual/functions.html b/manual/functions.html index 69da150..0fb081f 100644 --- a/manual/functions.html +++ b/manual/functions.html @@ -12,6 +12,7 @@
FUNCTIONS(3)libioxd Programmer's ManualFUNCTIONS(3)

NAME

functions - every public name of libioxd, alphabetically, with the page that describes it

DESCRIPTION

+ diff --git a/manual/index.html b/manual/index.html index ba29744..2211ca5 100644 --- a/manual/index.html +++ b/manual/index.html @@ -28,6 +28,7 @@

SECTION 3: HEADERS

+
ioxd_advanceioxd_http(3)
ioxd_bindioxd_run(3)
ioxd_bind_quicioxd_quic(3)
ioxd_body_allioxd_http(3)
ioxd_body_read_next_chunkioxd_http(3)
ioxd_body_read_untilioxd_http(3)
ioxd_timer(3)<ioxd/timer.h>a delay that parks the connection, not the worker
ioxd_socket(3)<ioxd/socket.h>outbound connections, as pipes
ioxd_tls(3)<ioxd/tls.h>a certificate store, for a TLS port
ioxd_quic(3)<ioxd/quic.h>a QUIC port: its streams as pipes
functions(3)every public name, alphabetically

SEE ALSO

The repository at github.com/MDA2AV/libioxd. This manual is built diff --git a/manual/ioxd.7.html b/manual/ioxd.7.html index 7b91d64..d11173d 100644 --- a/manual/ioxd.7.html +++ b/manual/ioxd.7.html @@ -20,9 +20,9 @@

SYNOPSIS

cc main.c $(pkg-config --cflags --libs ioxd) -o server

DESCRIPTION

-

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core. Each worker owns an io_uring -ring, a ring of receive buffers the kernel delivers into, and its own sockets on every bound port -(SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own +

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core, and QUIC, every stream of +it a pipe. Each worker owns an io_uring ring, a ring of receive buffers the kernel delivers into, and +its own sockets on every bound port (SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own coroutine, so a handler reads the body and writes the reply in straight-line code: a call that has to wait for the wire suspends the coroutine, and the worker's loop resumes it on the completion.

@@ -73,7 +73,9 @@

Limits

Building

Linux 6.x on x86-64, gcc 14 or newer (the library is C23; the headers are usable from C11), OpenSSL 3 for -the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out). +the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out), +and for QUIC libngtcp2 with its OpenSSL backend over OpenSSL 3.5 or newer (in when pkg-config finds them; +make QUIC=1 or -DIOXD_QUIC=ON insists, QUIC=0 / OFF leaves it out). make produces libioxd.a and libioxd.so; make install the headers and a pkg-config file; CMake exports ioxd::ioxd. Kernel TLS needs a kernel with SOCKET_URING_OP_SETSOCKOPT (6.7 or newer) when the registered file table is on, which is the default.

@@ -91,6 +93,7 @@

FILES

<ioxd/timer.h>
a delay that parks the connection, not the worker
<ioxd/socket.h>
outbound connections, as pipes
<ioxd/tls.h>
a certificate store, for a TLS port
+
<ioxd/quic.h>
a QUIC port: its streams as pipes

SEE ALSO

diff --git a/manual/ioxd_config.html b/manual/ioxd_config.html index c8076ef..3ae0b51 100644 --- a/manual/ioxd_config.html +++ b/manual/ioxd_config.html @@ -45,7 +45,7 @@

EXAMPLES

ioxd_bind(8080, NULL); return ioxd_run(0);

SEE ALSO

-

ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_CONFIG(3)
diff --git a/manual/ioxd_examples.html b/manual/ioxd_examples.html index 1e3beb9..3f093ba 100644 --- a/manual/ioxd_examples.html +++ b/manual/ioxd_examples.html @@ -16,6 +16,7 @@

DESCRIPTION

json.cJSON replies: a struct described once and serialized with one call, a list of thousands streamed as one array, an object written call by call, and an error object with its status. middleware.cmiddleware written by hand: one that stamps a header on every reply, one that times the request around the rest of the chain, one that gates a route and short-circuits, and how a middleware hands what it found to the handler. pipes.ca protocol of your own on the pipe the HTTP engine itself reads and writes through: raw TCP, one coroutine per connection, the same suspend-and-resume. A frame here is a 4-byte big-endian length and a payload; the reply is the payload reversed, framed the same way. +quic_echo.ca line echo on QUIC: every stream a client opens is served by the same pipe handler that serves a TCP connection, so one function answers both. QUIC needs certificates - it is TLS 1.3, always - and an application protocol to agree on; "echo" is this program's. static_files.cfiles from a directory, served by hand: the path's last segment names the file, its extension the content type, fstat the length, an ETag the version, and the bytes go through the reply slab piece by piece with a declared length. HEAD and 304 cost no read. stream_request.ca request body streamed, so a body of any size never sits in memory whole: read through a fixed buffer until it ends, whatever its framing, or taken chunk by chunk exactly as the sender framed it. stream_response.creplies that stream: a body bigger than the slab goes out chunked as the slab fills, a flush sends what is there on purpose so a client sees lines as they are made, a declared length lets a large body of known size go out with Content-Length instead, and reserve/advance write straight into the slab. @@ -354,6 +355,48 @@

pipes.c

ioxd_bind(8100, NULL); return ioxd_run_pipes(0, frames); } +

quic_echo.c

+
make examples && ./ioxd-example-quic_echo certs      # the store of ioxd_certs_load: certs/default/{cert,key}.pem
+printf 'hello\n' | nc 127.0.0.1 8080                 # the same echo over TCP
+# over QUIC: any client that can open a stream with ALPN "echo", e.g. aioquic in python
+

A stream is a pipe: read what the peer sent, write what goes back, return when done - the return sends the stream's end. The handler runs on a coroutine of its own per stream, so a slow stream never holds another. A one-way stream reads but cannot be written: ioxd_pipe_write returns -1 on it.

#include <ioxd.h>
+
+#include <string.h>
+
+/* Lines in, echoes out, until the peer ends the stream (or the connection). */
+static void echo(ioxd_pipe *pipe)
+{
+    for (;;) {
+        ioxd_slice live = { NULL, 0 };
+        if (ioxd_pipe_read(pipe, &live) <= 0)            /* the end, or the peer is gone */
+            return;
+        const char *nl = memchr(live.p, '\n', live.len);
+        if (!nl) {                                        /* half a line: wait for the rest */
+            ioxd_pipe_examine(pipe, live.len);
+            continue;
+        }
+        size_t len = (size_t)(nl - live.p) + 1;
+        char  *out = ioxd_pipe_reserve(pipe, 6 + len);   /* "echo: " + the line, straight into the slab */
+        if (!out)
+            return;
+        memcpy(out, "echo: ", 6);
+        memcpy(out + 6, live.p, len);
+        ioxd_pipe_advance(pipe, 6 + len);
+        ioxd_pipe_drop(pipe, len);
+        if (ioxd_pipe_flush(pipe) < 0)
+            return;
+    }
+}
+
+int main(int argc, char **argv)
+{
+    ioxd_certs *certs = ioxd_certs_load(argc > 1 ? argv[1] : "certs");
+    if (!certs)
+        return 1;
+    ioxd_bind(8080, NULL);                                /* the echo over TCP, plain */
+    ioxd_bind_quic(8443, certs, (const char *const[]){ "echo", NULL });   /* and over QUIC streams */
+    return ioxd_run_pipes(0, echo);
+}

static_files.c

mkdir -p www && echo '<h1>hello</h1>' > www/index.html && echo 'h1 { color: teal }' > www/site.css
 make examples && ./ioxd-example-static_files www
@@ -606,7 +649,7 @@ 

stream_response.c

ioxd_bind(8080, NULL); return ioxd_run(0); }
-

SEE ALSO

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd(7)

+

SEE ALSO

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd(7)

libioxd 0.1.02026-09-10IOXD_EXAMPLES(7)
diff --git a/manual/ioxd_http.html b/manual/ioxd_http.html index 31c55ee..5f97f70 100644 --- a/manual/ioxd_http.html +++ b/manual/ioxd_http.html @@ -240,7 +240,7 @@

EXAMPLES

/* the head may already be out (a streamed reply): ioxd_header then returns false */ }

SEE ALSO

-

ioxd_config(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_HTTP(3)
diff --git a/manual/ioxd_json.html b/manual/ioxd_json.html index 93ddcd4..abb7b30 100644 --- a/manual/ioxd_json.html +++ b/manual/ioxd_json.html @@ -164,7 +164,7 @@

EXAMPLES

ioxd_json j = ioxd_json_reply(ctx); user_to_json(&j, &u); /* {"id":42,"name":"Zoe","tags":[...],"orders":[{...}]} */

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_JSON(3)
diff --git a/manual/ioxd_pipe.html b/manual/ioxd_pipe.html index 7d1041d..ed81965 100644 --- a/manual/ioxd_pipe.html +++ b/manual/ioxd_pipe.html @@ -94,7 +94,7 @@

EXAMPLES

return ioxd_run_pipes(0, echo); }

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_PIPE(3)
diff --git a/manual/ioxd_quic.html b/manual/ioxd_quic.html new file mode 100644 index 0000000..a8c0e07 --- /dev/null +++ b/manual/ioxd_quic.html @@ -0,0 +1,53 @@ + + + + + +ioxd_quic(3) - libioxd manual + + + + +
+
IOXD_QUIC(3)libioxd Programmer's ManualIOXD_QUIC(3)
+

NAME

+

ioxd/quic.h - QUIC: a UDP port whose connections carry streams, each stream handed to the pipe handler as a pipe of its own. TLS 1.3 comes from the same certificate store a TLS port uses, run by ngtcp2 with OpenSSL; the transport lives in the library, since the kernel offers no QUIC of its own.

+

SYNOPSIS

+
#include <ioxd.h>
+
+int ioxd_bind_quic(int port, ioxd_certs *certs, const char *const *alpn);
+

DESCRIPTION

+
+
+int ioxd_bind_quic(int port, ioxd_certs *certs, const char *const *alpn);
+
+

Bind a UDP port for QUIC, with a certificate store (ioxd/tls.h) and the application protocols the port answers, most preferred first, ended by NULL - QUIC requires one, so a client offering none of them is refused at the handshake. Every stream a peer opens is served by the handler of ioxd_run_pipes (ioxd/run.h) as a pipe: what the peer sent on the stream is what the pipe reads, what the handler writes goes back on it, and the handler returning ends the stream. A stream the peer opened one-way reads but cannot be written. ioxd_run, the HTTP server, does not serve a QUIC port yet - HTTP/3 is a layer that is not there - and refuses to start with one bound. -1 if refused: a bad port, no store, no protocol, the table full, or a build without QUIC (make QUIC=1 needs libngtcp2 with its OpenSSL backend, and OpenSSL 3.5 or newer).

+
+

EXAMPLES

+

A line echo, on TCP and on QUIC streams, with one handler:

+
static void echo(ioxd_pipe *pipe)
+{
+    for (;;) {
+        ioxd_slice live = { NULL, 0 };
+        if (ioxd_pipe_read(pipe, &live) <= 0)       /* the stream's end, or the connection's */
+            return;                                 /* returning ends the stream: its FIN goes out */
+        ioxd_pipe_write(pipe, live.p, live.len);
+        ioxd_pipe_drop(pipe, live.len);
+        if (ioxd_pipe_flush(pipe) < 0)
+            return;
+    }
+}
+
+int main(void)
+{
+    ioxd_certs *certs = ioxd_certs_load("certs");   /* QUIC is TLS 1.3: a store is required */
+    ioxd_bind(8080, NULL);
+    ioxd_bind_quic(8443, certs, (const char *const[]){ "echo", NULL });
+    return ioxd_run_pipes(0, echo);
+}
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+
libioxd 0.1.02026-09-10IOXD_QUIC(3)
+
+ + diff --git a/manual/ioxd_router.html b/manual/ioxd_router.html index 331ac68..146629f 100644 --- a/manual/ioxd_router.html +++ b/manual/ioxd_router.html @@ -165,7 +165,7 @@

EXAMPLES

} IOXD_DEFAULT(not_found);

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_ROUTER(3)
diff --git a/manual/ioxd_run.html b/manual/ioxd_run.html index 646cb57..0ff4788 100644 --- a/manual/ioxd_run.html +++ b/manual/ioxd_run.html @@ -56,7 +56,7 @@

DESCRIPTION

The same, without HTTP: every connection on every bound port is handed to fn as a pipe.

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_RUN(3)
diff --git a/manual/ioxd_slice.html b/manual/ioxd_slice.html index 44f1203..b14ff91 100644 --- a/manual/ioxd_slice.html +++ b/manual/ioxd_slice.html @@ -101,7 +101,7 @@

EXAMPLES

if (ioxd_slice_eq(form[i].key, "name")) ioxd_printf(ctx, "hello %.*s\n", (int)form[i].value.len, form[i].value.p);

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_SLICE(3)
diff --git a/manual/ioxd_socket.html b/manual/ioxd_socket.html index bc44269..5f01de2 100644 --- a/manual/ioxd_socket.html +++ b/manual/ioxd_socket.html @@ -52,7 +52,7 @@

EXAMPLES

ioxd_disconnect(up); }

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_SOCKET(3)
diff --git a/manual/ioxd_timer.html b/manual/ioxd_timer.html index 632a704..248dd8c 100644 --- a/manual/ioxd_timer.html +++ b/manual/ioxd_timer.html @@ -37,7 +37,7 @@

EXAMPLES

} }

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_socket(3), ioxd_tls(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_socket(3), ioxd_tls(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_TIMER(3)
diff --git a/manual/ioxd_tls.html b/manual/ioxd_tls.html index 17c099a..04238e0 100644 --- a/manual/ioxd_tls.html +++ b/manual/ioxd_tls.html @@ -47,7 +47,7 @@

EXAMPLES

/* ... later, after new files were written: */ ioxd_certs_reload(certs); /* a host that fails keeps its old certificate */

SEE ALSO

-

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_examples(7), ioxd(7)

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_run(3), ioxd_timer(3), ioxd_socket(3), ioxd_quic(3), ioxd_examples(7), ioxd(7)

libioxd 0.1.02026-09-10IOXD_TLS(3)
diff --git a/playground/examples/quic_echo.c b/playground/examples/quic_echo.c new file mode 100644 index 0000000..513ae8b --- /dev/null +++ b/playground/examples/quic_echo.c @@ -0,0 +1,52 @@ +/* + * quic_echo.c - a line echo on QUIC: every stream a client opens is served by the same pipe + * handler that serves a TCP connection, so one function answers both. QUIC needs certificates - + * it is TLS 1.3, always - and an application protocol to agree on; "echo" is this program's. + * + * make examples && ./ioxd-example-quic_echo certs # the store of ioxd_certs_load: certs/default/{cert,key}.pem + * printf 'hello\n' | nc 127.0.0.1 8080 # the same echo over TCP + * # over QUIC: any client that can open a stream with ALPN "echo", e.g. aioquic in python + * + * A stream is a pipe: read what the peer sent, write what goes back, return when done - the + * return sends the stream's end. The handler runs on a coroutine of its own per stream, so a + * slow stream never holds another. A one-way stream reads but cannot be written: ioxd_pipe_write + * returns -1 on it. + */ +#include + +#include + +/* Lines in, echoes out, until the peer ends the stream (or the connection). */ +static void echo(ioxd_pipe *pipe) +{ + for (;;) { + ioxd_slice live = { NULL, 0 }; + if (ioxd_pipe_read(pipe, &live) <= 0) /* the end, or the peer is gone */ + return; + const char *nl = memchr(live.p, '\n', live.len); + if (!nl) { /* half a line: wait for the rest */ + ioxd_pipe_examine(pipe, live.len); + continue; + } + size_t len = (size_t)(nl - live.p) + 1; + char *out = ioxd_pipe_reserve(pipe, 6 + len); /* "echo: " + the line, straight into the slab */ + if (!out) + return; + memcpy(out, "echo: ", 6); + memcpy(out + 6, live.p, len); + ioxd_pipe_advance(pipe, 6 + len); + ioxd_pipe_drop(pipe, len); + if (ioxd_pipe_flush(pipe) < 0) + return; + } +} + +int main(int argc, char **argv) +{ + ioxd_certs *certs = ioxd_certs_load(argc > 1 ? argv[1] : "certs"); + if (!certs) + return 1; + ioxd_bind(8080, NULL); /* the echo over TCP, plain */ + ioxd_bind_quic(8443, certs, (const char *const[]){ "echo", NULL }); /* and over QUIC streams */ + return ioxd_run_pipes(0, echo); +} diff --git a/tests/pipe-server.c b/tests/pipe-server.c index 467f2c7..25e515e 100644 --- a/tests/pipe-server.c +++ b/tests/pipe-server.c @@ -5,7 +5,9 @@ * the API: "copy N" reads the next N bytes into a buffer of the handler's own and sends them * back, "hold N" waits for them where the kernel left them - however many receives that takes - * keeps them (which consumes them, so nothing is read twice), writes them back out and gives the - * reader its room again. `make check` runs tests/pipes.py against it. + * reader its room again. `make check` runs tests/pipes.py against it. With IOXD_CERTS naming a + * certificate store, the same echo answers QUIC streams on the next port up, protocol "echo": + * tests/quic.py talks to that one. */ #include @@ -114,7 +116,13 @@ static void echo(ioxd_pipe *pipe) int main(int argc, char **argv) { - int port = argc > 1 ? (int)strtol(argv[1], NULL, 10) : 8100; + int port = argc > 1 ? (int)strtol(argv[1], NULL, 10) : 8100; + const char *certs = getenv("IOXD_CERTS"); /* NOLINT(concurrency-mt-unsafe): no threads yet */ ioxd_bind(port, NULL); + if (certs && *certs) { + ioxd_certs *store = ioxd_certs_load(certs); + if (!store || ioxd_bind_quic(port + 1, store, (const char *const[]){ "echo", NULL }) != 0) + return 1; + } return ioxd_run_pipes(2, echo); } diff --git a/tests/quic.py b/tests/quic.py new file mode 100644 index 0000000..553163e --- /dev/null +++ b/tests/quic.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""QUIC through tests/pipe-server.c: the same line echo, every stream a pipe of its own. A +handshake with the mounted certificate and the "echo" protocol, a line echoed on a stream, many +streams on one connection, streams on many connections, a body larger than every window with the +echo read back while it is still being sent, the server finishing a stream when the handler +returns, a client that resets a stream, a protocol the server does not answer, and an idle close. + + python3 tests/quic.py [certs-dir] (aioquic in the python; skips itself without it)""" +import asyncio, os, ssl, sys, time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8101 + +try: + from aioquic.asyncio import connect + from aioquic.asyncio.protocol import QuicConnectionProtocol + from aioquic.quic.configuration import QuicConfiguration + from aioquic.quic.events import ConnectionTerminated, StreamDataReceived, StreamReset +except ImportError: + print("SKIP quic: aioquic is not installed in this python") + sys.exit(0) + +results = [] + + +def check(name, cond): + print(f"{'ok ' if cond else 'FAIL'} {name}") + results.append(cond) + return cond + + +class Echo(QuicConnectionProtocol): + """Streams as queues: (data, end) pairs per stream, and the connection's end.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.streams = {} + self.terminated = None + self.resets = {} + + def queue(self, sid): + return self.streams.setdefault(sid, asyncio.Queue()) + + def quic_event_received(self, event): + if isinstance(event, StreamDataReceived): + self.queue(event.stream_id).put_nowait((event.data, event.end_stream)) + elif isinstance(event, StreamReset): + self.resets[event.stream_id] = event.error_code + self.queue(event.stream_id).put_nowait((b"", True)) + elif isinstance(event, ConnectionTerminated): + self.terminated = event + for q in self.streams.values(): + q.put_nowait((b"", True)) + + def send(self, sid, data, end=False): + self._quic.send_stream_data(sid, data, end) + self.transmit() + + def open(self): + return self._quic.get_next_available_stream_id() + + async def read(self, sid, want=None, timeout=10): + """Bytes until end_stream, or until `want` bytes are in.""" + out = b"" + deadline = time.monotonic() + timeout + while True: + left = deadline - time.monotonic() + if left <= 0: + return out, False + try: + data, end = await asyncio.wait_for(self.queue(sid).get(), left) + except asyncio.TimeoutError: + return out, False + out += data + if end: + return out, True + if want is not None and len(out) >= want: + return out, False + + +def config(alpn="echo"): + c = QuicConfiguration(is_client=True, alpn_protocols=[alpn], idle_timeout=20.0) + c.verify_mode = ssl.CERT_NONE + return c + + +async def echo_line(proto, line): + sid = proto.open() + proto.send(sid, line, end=True) + data, end = await proto.read(sid) + return sid, data, end + + +async def main(): + # 1. a handshake, and one line on one stream: the handler echoes it and ends the stream when the + # client's FIN ends its input + async with connect("127.0.0.1", PORT, configuration=config(), create_protocol=Echo) as proto: + await proto.wait_connected() + check("handshake with ALPN echo", proto._quic.tls.alpn_negotiated == "echo") + sid, data, end = await echo_line(proto, b"hello quic\n") + check("a line on a stream comes back echoed, the server's FIN after it", data == b"echo: hello quic\n" and end) + + # 2. many streams on one connection, all in flight at once, each its own coroutine + sids = [] + for i in range(50): + sid = proto.open() + proto.send(sid, b"line %d\n" % i, end=True) + sids.append(sid) + got = [await proto.read(sid) for sid in sids] + check("50 streams at once, each echoed on its own", all(got[i] == (b"echo: line %d\n" % i, True) for i in range(50))) + + # 3. a stream held open: two lines with a pause between, echoed as they come, then quit + sid = proto.open() + proto.send(sid, b"first\n") + d1, _ = await proto.read(sid, want=len(b"echo: first\n")) + proto.send(sid, b"second\n") + d2, _ = await proto.read(sid, want=len(b"echo: second\n")) + proto.send(sid, b"quit\n") + d3, end = await proto.read(sid) + check("a stream held open echoes line by line; quit ends it from the server's side", + d1 == b"echo: first\n" and d2 == b"echo: second\n" and d3 == b"" and end) + + # 4. more than every window: 2 MB of lines, the echo read as it arrives so nothing has to + # be buffered whole on either side (the stream window is 256 KB, the connection's 1 MB) + line = b"x" * 1000 + b"\n" + n = 2000 + sid = proto.open() + total = 0 + async def pump(): + for i in range(n): + proto.send(sid, line, end=(i == n - 1)) + if i % 50 == 49: + await asyncio.sleep(0) + sender = asyncio.ensure_future(pump()) + data, end = await proto.read(sid, timeout=60) + await sender + check(f"2 MB over one stream, echoed while it is still arriving ({len(data)} bytes back)", + len(data) == n * (6 + len(line)) and end and data.startswith(b"echo: " + line)) + + # 5. the client resets a stream mid-way: the handler's read fails, the stream ends + sid = proto.open() + proto.send(sid, b"half a line without its end") + proto._quic.reset_stream(sid, 7) + proto.transmit() + await asyncio.sleep(0.2) + sid2, data, end = await echo_line(proto, b"still alive\n") + check("a stream reset by the client ends it, the connection serves on", data == b"echo: still alive\n" and end) + + # 6. a unidirectional stream: read-only for the handler, its writes fail, the stream ends + uni = proto._quic.get_next_available_stream_id(is_unidirectional=True) + proto.send(uni, b"one way\n", end=True) + await asyncio.sleep(0.2) + sid3, data, end = await echo_line(proto, b"after uni\n") + check("a one-way stream is taken and cannot be answered; the next stream is fine", + data == b"echo: after uni\n" and end and proto.terminated is None) + + # 7. many connections, each a handshake of its own + async def one(i): + async with connect("127.0.0.1", PORT, configuration=config(), create_protocol=Echo) as p: + await p.wait_connected() + _, data, end = await echo_line(p, b"conn %d\n" % i) + return data == b"echo: conn %d\n" % i and end + outcomes = await asyncio.gather(*(one(i) for i in range(40))) + check("40 connections at once", all(outcomes)) + + # 8. a protocol the server does not answer: the handshake fails + try: + async with connect("127.0.0.1", PORT, configuration=config("nope"), create_protocol=Echo) as p: + await asyncio.wait_for(p.wait_connected(), 5) + refused = False + except Exception: + refused = True + check("ALPN the server does not serve is refused at the handshake", refused) + + # 9. the server closes the connection when its idle timeout passes (30 s server side is long + # for a test; the client's own idle timeout of 2 s closes first and the server must take the + # CONNECTION_CLOSE without complaint), and a new connection works afterwards + c = config() + c.idle_timeout = 2.0 + async with connect("127.0.0.1", PORT, configuration=c, create_protocol=Echo) as p: + await p.wait_connected() + await asyncio.sleep(3.0) + idle_closed = p.terminated is not None + async with connect("127.0.0.1", PORT, configuration=config(), create_protocol=Echo) as p: + await p.wait_connected() + _, data, end = await echo_line(p, b"after idle\n") + check("an idle connection is closed, and the port serves the next", idle_closed and data == b"echo: after idle\n" and end) + + +asyncio.run(main()) +print("all passed" if all(results) else "FAILURES") +sys.exit(0 if all(results) else 1) diff --git a/tests/run-suites.sh b/tests/run-suites.sh index 9ad2ecd..ce3c643 100755 --- a/tests/run-suites.sh +++ b/tests/run-suites.sh @@ -1,7 +1,8 @@ #!/bin/sh # run-suites.sh - the check sequence, in one place: the unit test, then the HTTP fixture with the -# smoke, conformance, stress and early-TLS suites against it, then the pipe fixture with the pipe suite. Both -# `make check` and CMake's `check` target run this, so the sequence lives here and nowhere else. +# smoke, conformance, stress and early-TLS suites against it, then the pipe fixture with the pipe +# suite and, in a build with QUIC, the QUIC suite on its streams. Both `make check` and CMake's +# `check` target run this, so the sequence lives here and nowhere else. # # sh tests/run-suites.sh --unit tests/ioxd-unit --server tests/ioxd-test-server \ # --pipe-server tests/ioxd-pipe-server [--port 8099] [--pipe-port 8102] @@ -12,9 +13,10 @@ # --server PATH the HTTP fixture (tests/server.c) # --pipe-server PATH the pipe fixture (tests/pipe-server.c) # --python PY the python the suites run under [python3] -# --tls-python PY the python for tls_early.py (needs tlslite-ng) [--python] +# --tls-python PY the python for tls_early.py (needs tlslite-ng) and quic.py (aioquic) [--python] # --work DIR scratch: the fixture's log and the certificates it serves [obj/check] -# --suite NAME run only this one (repeatable): unit smoke conformance stress tls pipes +# --suite NAME run only this one (repeatable): unit smoke conformance stress tls pipes quic +# --quic 0|1 whether the build has QUIC: the pipe fixture then serves it too [0] # # The suites named in one run share the fixture they talk to, and are meant to: a fixture bound to # a port the suite before it left full of TIME_WAIT connections has some of its new connections @@ -38,6 +40,7 @@ python=python3 tls_python= work= suites= +quic=0 while [ $# -gt 0 ]; do case $1 in @@ -50,13 +53,14 @@ while [ $# -gt 0 ]; do --tls-python) tls_python=$2; shift 2 ;; --work) work=$2; shift 2 ;; --suite) suites="$suites $2"; shift 2 ;; + --quic) quic=$2; shift 2 ;; -h|--help) sed -n '2,26p' "$0"; exit 0 ;; *) echo "run-suites: unknown option $1" >&2; exit 2 ;; esac done [ -n "$tls_python" ] || tls_python=$python [ -n "$work" ] || work=$root/obj/check -[ -n "$suites" ] || suites="unit smoke conformance stress tls pipes" +[ -n "$suites" ] || suites="unit smoke conformance stress tls pipes quic" tls_port=$((port + 2)) wanted() { @@ -148,15 +152,27 @@ if wanted smoke || wanted stress || wanted tls; then [ $rc -eq 0 ] || { echo "--- $log ---"; cat "$log"; echo "--- end of $log ---"; } fi -# --- the pipe fixture --- -if wanted pipes; then - [ -n "$pipe_server" ] || { echo "run-suites: --pipe-server is required for the pipe suite" >&2; exit 2; } +# --- the pipe fixture: the line echo on TCP, and on QUIC streams when the build has QUIC --- +if wanted pipes || wanted quic; then + [ -n "$pipe_server" ] || { echo "run-suites: --pipe-server is required for the pipe and quic suites" >&2; exit 2; } log=$work/pipe.log inner=0 - "$pipe_server" "$pipe_port" >"$log" 2>&1 & + if [ "$quic" = 1 ]; then + sh "$tests/mkcerts.sh" "$tests/certs" >/dev/null || rc=1 + IOXD_CERTS="$tests/certs" "$pipe_server" "$pipe_port" >"$log" 2>&1 & + else + "$pipe_server" "$pipe_port" >"$log" 2>&1 & + fi pid=$! if wait_for_fixture "$pipe_port" "$log"; then - "$python" "$tests/pipes.py" "$pipe_port" || inner=1 + wanted pipes && { "$python" "$tests/pipes.py" "$pipe_port" || inner=1; } + if wanted quic; then + if [ "$quic" = 1 ]; then + "$tls_python" "$tests/quic.py" $((pipe_port + 1)) || inner=1 + else + echo "skip quic: this build has no QUIC (make QUIC=1 with libngtcp2 and OpenSSL 3.5)" + fi + fi else echo "FAIL the pipe fixture never listened on $pipe_port" inner=1