Introduction
QUIC and HTTP/3 are no longer “experimental toys” — they are now powering the majority of modern web traffic (think YouTube, Gmail, Facebook, Cloudflare, etc.). QUIC replaces TCP + TLS with a single secure transport over UDP, bringing 0-RTT handshakes, stream multiplexing without head-of-line blocking, and built-in encryption. HTTP/3 rides on top of QUIC, inheriting these benefits for the web layer.
If you’re a network engineer, developer, or curious technologist, setting up your own QUIC/HTTP-3 lab is the best way to truly understand how it works. Instead of relying on browser flags or prebuilt demos, this article walks you through creating a from-scratch HTTP/3 environment on Ubuntu with Python (aioquic).
By the end, you’ll have:
- A QUIC + HTTP/3 server in Python serving real files and routes.
- A custom Python client to test GET, POST, and uploads.
- TLS secrets for Wireshark decryption and optional QLOG traces for deep QUIC analysis.
- Step-by-step commands, explanations, and troubleshooting pointers.
what you’ll get
- A working QUIC + HTTP/3 stack in ~200 lines of Python.
- Server: static files from /www, POST echo, /health, optional /upload streaming (easy to extend).
- Client: single-request H3 client (GET/POST).
- Observability: TLS secrets for Wireshark, optional QLOG traces for qvis.
- Ops: systemd service, UFW, perf & hardening notes.

Prerequisites & lab layout
Why this matters: QUIC is UDP + TLS + HTTP/3 layered together; having a predictable environment avoids the usual “it compiles on my machine” hiccups.
Pointers
- Use a Python virtual environment so your QUIC libs don’t collide with system packages.
- Keep a simple directory layout so certs, logs, and content are obvious.
Commands
sudo apt update
sudo apt install -y python3-venv python3-pip build-essential openssl ufw
python3 -m venv ~/h3lab
is used to create a Python virtual environment in the directory ~/h3lab.
🔎 What it does
- python3 -m venv → runs the built-in venv module that ships with Python.
- ~/h3lab → is the path to the folder where the virtual environment will be created.
When you run it:
- A new directory ~/h3lab/ is created (if it doesn’t exist).
- Inside it, you’ll see subfolders like bin/, lib/, and pyvenv.cfg.
- These contain a private copy of the Python interpreter and its own site-packages directory (where pip installs packages).
Why it’s useful (in our QUIC/HTTP-3 lab)
- Isolation:
- Packages installed here (e.g., aioquic) won’t interfere with your system Python or other projects.
- You avoid version conflicts (important if you experiment with multiple Python libs).
- Reproducibility:
- Anyone following the same steps gets the same clean environment.
- If you break something, you can delete ~/h3lab and recreate it easily.
- Control:
- You decide which Python version + packages are inside this lab.
- Great for experiments like QUIC/HTTP-3, where dependencies evolve quickly.
🚀 Next step after creating it
You need to activate the environment:
source ~/h3lab/bin/activate
After activation, your shell prompt changes (e.g., (h3lab) appears in front).
Now when you run:
pip install --upgrade pip
pip install aioquic
…it installs into ~/h3lab/lib/… only, not globally.
To validate it:
pip show aioquic
(h3lab) root@sanchit:~# pip show aioquic
Name: aioquic
Version: 1.2.0
Summary: An implementation of QUIC and HTTP/3
Home-page: https://github.com/aiortc/aioquic
Author:
Author-email: Jeremy Lainé <jeremy.laine@m4x.org>
License: BSD-3-Clause
Location: /root/h3lab/lib/python3.12/site-packages
Requires: certifi, cryptography, pylsqpack, pyopenssl, service-identity
Required-by:
To deactivate, just run:
deactivate
👉 So in short:
python3 -m venv ~/h3lab = make me a private Python playground called h3lab for my QUIC/HTTP-3 lab.
mkdir -p ~/h3lab/{certs,www,logs}
🔎 What it does
So in one command, you’re making three directories inside ~/h3lab.
mkdir → the Linux command to create directories.
-p (parents) → Tells mkdir to create parent directories as needed. If ~/h3lab doesn’t exist, it will create it first, then the subfolders. Prevents errors if the parent path isn’t already there.
~/h3lab/{certs,www,logs} → uses brace expansion: It expands into three separate paths:
~/h3lab/certs
~/h3lab/www
~/h3lab/logs
Why it’s useful in our QUIC/HTTP-3 Lab
We’re organizing the project into clean folders:
- certs/ → to hold TLS certificate (cert.pem) and private key (key.pem).
- www/ → to hold web content (HTML files, test pages, uploads).
- logs/ → to store logs like TLS secrets (secrets.log) and QLOG traces (quic.qlog.json).
This structure mirrors how a real web server environment would be laid out (certificates, web root, logs).
Example before & after
👉 Before running the command:
ls ~/h3lab
ls: cannot access '/home/you/h3lab': No such file or directory
👉 After running:
ls -R ~/h3lab
(h3lab) root@sanchit:~# ls ~/h3lab
bin certs include lib lib64 logs pyvenv.cfg www
🔑 In short:
This command creates a neat folder hierarchy for the lab in one shot, so you don’t have to make each directory manually.
Step 1 — Make a local TLS certificate (with SAN)
Why this matters: HTTP/3 always runs over TLS. Even for localhost, browsers/clients validate the hostname via the certificate’s Subject Alternative Name (SAN).
Commands
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout ~/h3lab/certs/key.pem \
-out ~/h3lab/certs/cert.pem \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"
Quick test page
cat > ~/h3lab/www/index.html <<'EOF'
<!doctype html><meta charset="utf-8">
<title>HTTP/3 on QUIC – it works!</title>
<h1>🎉 Hello from HTTP/3 over QUIC.</h1>
<h1>Developed by SanchitGurukul</h1>
<p>Served by a tiny Python server you control.</p>
EOF
Pointers
- For a real domain later, issue a proper cert (e.g., via ACME) and use that instead of self-signed.
- You can add more SANs (e.g., your LAN IP) if you’ll access it from other machines.
Step 2 — The HTTP/3 server (no demo; clear structure)
Why this matters: Understanding events is key. QUIC emits transport events; H3Connection translates them into HTTP/3 events like HeadersReceived and DataReceived. You respond by sending headers/data and calling transmit().
Save as: ~/h3lab/h3_server.py
#!/usr/bin/env python3
"""
Tiny but solid HTTP/3 server using aioquic.
Routes:
GET / -> serves index.html from --www
GET / -> serves static files from --www
HEAD ... -> metadata only
POST /echo -> echoes request body
GET /health -> 200 OK (readiness probe)
POST /upload -> (optional) streams body to /www/uploads/.bin
Security:
- blocks path traversal ("..")
- sets basic content-type
Observability:
- TLS secrets log for Wireshark
- optional QLOG traces for qvis
"""
import argparse, asyncio, logging, mimetypes, uuid
from pathlib import Path
from aioquic.asyncio import serve
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection, H3_ALPN
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted, QuicEvent
from aioquic.quic.logger import QuicLogger
LOG = logging.getLogger("h3server")
def _b(s: str) -> bytes:
return s.encode("utf-8")
class Http3Server(QuicConnectionProtocol):
def __init__(self, *args, www: Path, **kwargs):
super().__init__(*args, **kwargs)
self._http: H3Connection | None = None
self._www = www.resolve()
self._buffers: dict[int, bytearray] = {}
(self._www / "uploads").mkdir(parents=True, exist_ok=True)
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, HandshakeCompleted) and self._http is None:
self._http = H3Connection(self._quic)
LOG.info("Handshake completed; HTTP/3 ready")
if self._http is None:
return
for http_event in self._http.handle_event(event):
if isinstance(http_event, HeadersReceived):
self._on_headers(http_event)
elif isinstance(http_event, DataReceived):
self._on_data(http_event)
def _on_headers(self, ev: HeadersReceived) -> None:
headers = {k.lower(): v for k, v in ev.headers}
method = headers.get(b":method", b"GET").decode()
raw_path = headers.get(b":path", b"/").decode()
stream_id = ev.stream_id
# Simple routing
if method in ("GET", "HEAD") and raw_path == "/health":
return self._reply(stream_id, 200, b"ok\n")
if method == "POST" and raw_path == "/echo":
self._buffers[stream_id] = bytearray()
return
if method == "POST" and raw_path == "/upload":
# stream to disk; body handled in _on_data
fn = f"{uuid.uuid4().hex}.bin"
path = self._www / "uploads" / fn
self._buffers[stream_id] = bytearray()
# store the filename using a side key
self._buffers[stream_id + 1] = bytearray(path.as_posix().encode())
return
if method in ("GET", "HEAD"):
path = "/index.html" if raw_path == "/" else raw_path
full = (self._www / path.lstrip("/")).resolve()
# prevent ../../ escapes
if not (full == self._www or self._www in full.parents):
return self._reply(stream_id, 403, b"Forbidden\n")
if full.is_file():
data = full.read_bytes()
ctype, _ = mimetypes.guess_type(full.name)
resp_headers = [
(b":status", b"200"),
(b"server", b"py-h3"),
(b"content-length", _b(str(len(data)))),
]
if ctype:
resp_headers.append((b"content-type", _b(ctype)))
self._http.send_headers(stream_id, resp_headers)
if method == "GET":
self._http.send_data(stream_id, data, end_stream=True)
else: # HEAD
self._http.send_data(stream_id, b"", end_stream=True)
self.transmit()
else:
self._reply(stream_id, 404, b"Not Found\n")
else:
self._reply(stream_id, 405, b"Method Not Allowed\n")
def _on_data(self, ev: DataReceived) -> None:
buf = self._buffers.get(ev.stream_id)
if buf is None:
return
buf.extend(ev.data)
if ev.stream_ended:
# echo route
if ev.stream_id in self._buffers and (ev.stream_id + 1) not in self._buffers:
body = bytes(self._buffers.pop(ev.stream_id))
headers = [
(b":status", b"200"),
(b"server", b"py-h3"),
(b"content-length", _b(str(len(body)))),
(b"content-type", b"application/octet-stream"),
]
self._http.send_headers(ev.stream_id, headers)
self._http.send_data(ev.stream_id, body, end_stream=True)
return self.transmit()
# upload route (uses ev.stream_id+1 key to store filename)
meta = self._buffers.pop(ev.stream_id + 1, None)
if meta:
out_path = Path(meta.decode())
out_path.write_bytes(bytes(self._buffers.pop(ev.stream_id)))
return self._reply(ev.stream_id, 200, _b(f"saved:{out_path.name}\n"))
def _reply(self, stream_id: int, status: int, body: bytes) -> None:
self._http.send_headers(stream_id, [
(b":status", _b(str(status))),
(b"server", b"py-h3"),
(b"content-length", _b(str(len(body)))),
(b"content-type", b"text/plain; charset=utf-8"),
])
self._http.send_data(stream_id, body, end_stream=True)
self.transmit()
async def main():
ap = argparse.ArgumentParser(description="Tiny HTTP/3 server (QUIC)")
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=4433)
ap.add_argument("--cert", default=str(Path.home() / "h3lab/certs/cert.pem"))
ap.add_argument("--key", default=str(Path.home() / "h3lab/certs/key.pem"))
ap.add_argument("--www", default=str(Path.home() / "h3lab/www"))
ap.add_argument("--secrets-log", default=str(Path.home() / "h3lab/logs/secrets.log"),
help="Write TLS secrets (Wireshark)")
ap.add_argument("--qlog", default="", help="Directory to write QLOG traces")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
level=logging.DEBUG if args.verbose else logging.INFO,
)
cfg = QuicConfiguration(
is_client=False,
alpn_protocols=H3_ALPN,
secrets_log_file=open(args.secrets_log, "a", buffering=1),
)
cfg.load_cert_chain(args.cert, args.key)
if args.qlog:
cfg.quic_logger = QuicLogger()
www_path = Path(args.www).resolve()
www_path.mkdir(parents=True, exist_ok=True)
# IMPORTANT: serve() is a coroutine; await it and manage lifecycle manually
server = await serve(
args.host, args.port,
configuration=cfg,
create_protocol=lambda *a, **kw: Http3Server(*a, www=www_path, **kw),
)
LOG.info("HTTP/3 server listening on udp://%s:%d (www=%s)", args.host, args.port, www_path)
LOG.info("TLS secrets log: %s", args.secrets_log)
if args.qlog:
LOG.info("QLOG enabled; will write JSON at shutdown to %s/*.qlog.json", args.qlog)
try:
await asyncio.Future() # run forever
finally:
server.close()
await server.wait_closed()
if cfg.quic_logger and args.qlog:
out = Path(args.qlog).resolve()
out.mkdir(parents=True, exist_ok=True)
(out / "quic.qlog.json").write_text(
__import__("json").dumps(cfg.quic_logger.to_dict(), indent=2)
)
if __name__ == "__main__":
asyncio.run(main())
Pointers
- QuicConfiguration(alpn_protocols=H3_ALPN) ensures HTTP/3 is negotiated.
- We log TLS secrets to decrypt QUIC in Wireshark.
- Directory traversal is blocked by checking full.parents.
- /upload is intentionally simple: stream to memory then write to disk; for huge files, switch to chunked streaming to file.
Step 3 — The HTTP/3 client (simple and explicit)
Why this matters: A tiny client makes it easy to test different paths, headers, or bodies without relying on system curl builds.
Save as: ~/h3lab/h3_client.py
#!/usr/bin/env python3
"""
Tiny HTTP/3 client for QUIC/aioquic.
Features:
- GET/POST/HEAD with custom headers (-H)
- Read POST body from --post "DATA" or --post-stdin (pipe/file)
- Optional insecure mode (self-signed labs) or custom CA (--cacert)
- Prints response headers to stderr and body to stdout (or --save file)
- Simple timeout, verbose logs
Examples:
python h3_client.py https://localhost:4433/ --insecure -v
python h3_client.py https://localhost:4433/echo --post "hello" --insecure
echo -n "tiny file" | python h3_client.py https://localhost:4433/upload --post-stdin --insecure
python h3_client.py https://example.com/path -H "Accept: application/json"
"""
import argparse
import asyncio
import logging
import sys
from typing import List, Tuple
from urllib.parse import urlparse
from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection, H3_ALPN
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted, QuicEvent
LOG = logging.getLogger("h3client")
def parse_header(h: str) -> Tuple[bytes, bytes]:
if ":" not in h:
raise argparse.ArgumentTypeError(f"Invalid header (missing ':'): {h}")
k, v = h.split(":", 1)
return k.strip().encode("utf-8"), v.lstrip().encode("utf-8")
class Http3Client(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http: H3Connection | None = None
self._events: List = []
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, HandshakeCompleted) and self._http is None:
self._http = H3Connection(self._quic)
if self._http is None:
return
for ev in self._http.handle_event(event):
self._events.append(ev)
async def request(
self,
authority: str,
path: str,
method: str = "GET",
headers: List[Tuple[bytes, bytes]] | None = None,
body: bytes = b"",
save_path: str | None = None,
):
assert self._http is not None
stream_id = self._quic.get_next_available_stream_id(is_unidirectional=False)
req_headers: List[Tuple[bytes, bytes]] = [
(b":method", method.encode()),
(b":scheme", b"https"),
(b":authority", authority.encode()),
(b":path", path.encode()),
(b"user-agent", b"py-h3client"),
]
if headers:
req_headers.extend(headers)
# End stream if HEAD and no body; otherwise, send body after headers
end_stream = (method.upper() == "HEAD") and not body
self._http.send_headers(stream_id, req_headers, end_stream=end_stream)
if body and method.upper() != "HEAD":
self._http.send_data(stream_id, body, end_stream=True)
self.transmit()
# Collect response
got_fin = False
out_f = None
try:
if save_path:
out_f = open(save_path, "wb")
while not got_fin:
await asyncio.sleep(0.01)
# iterate over a snapshot to allow removal
for ev in list(self._events):
if isinstance(ev, HeadersReceived) and ev.stream_id == stream_id:
# print headers to stderr
for k, v in ev.headers:
sys.stderr.buffer.write(k + b": " + v + b"\n")
sys.stderr.buffer.write(b"\n")
sys.stderr.flush()
self._events.remove(ev)
elif isinstance(ev, DataReceived) and ev.stream_id == stream_id:
if out_f:
out_f.write(ev.data)
else:
sys.stdout.buffer.write(ev.data)
sys.stdout.flush()
got_fin = ev.stream_ended
self._events.remove(ev)
finally:
if out_f:
out_f.flush()
out_f.close()
async def main():
ap = argparse.ArgumentParser(description="Tiny HTTP/3 client")
ap.add_argument("url", help="https://host[:port]/path")
ap.add_argument("-X", "--method", default=None, help="HTTP method (default: GET or POST if body)")
ap.add_argument("--post", metavar="DATA", help="send POST with this DATA")
ap.add_argument("--post-stdin", action="store_true", help="read POST body from stdin (pipe/file)")
ap.add_argument("-H", "--header", action="append", type=parse_header, default=[],
help='Add header, e.g., -H "Accept: application/json"')
ap.add_argument("--save", metavar="FILE", help="save response body to FILE instead of stdout")
ap.add_argument("--timeout", type=float, default=10.0, help="overall operation timeout (seconds)")
ap.add_argument("-v", "--verbose", action="store_true")
ap.add_argument("--insecure", action="store_true", help="disable certificate verification")
ap.add_argument("--cacert", help="custom CA bundle to verify server (overrides system store)")
args = ap.parse_args()
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
level=logging.DEBUG if args.verbose else logging.INFO,
)
u = urlparse(args.url)
if u.scheme != "https":
raise SystemExit("Only https:// URLs are supported for HTTP/3")
host = u.hostname
if host is None:
raise SystemExit("Invalid URL: missing host")
port = u.port or 4433 # default lab port
path = u.path or "/"
# Determine method/body
body_bytes = b""
if args.post is not None:
body_bytes = args.post.encode()
elif args.post_stdin:
body_bytes = sys.stdin.buffer.read()
method = args.method.upper() if args.method else ("POST" if (args.post or args.post_stdin) else "GET")
# Build QUIC config
cfg = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
cfg.server_name = host # SNI
if args.insecure:
import ssl
cfg.verify_mode = ssl.CERT_NONE
elif args.cacert:
# Use custom CA bundle
import ssl
ctx = ssl.create_default_context(cafile=args.cacert)
# aioquic expects verify via cfg.verify_mode + system store; to use custom bundle,
# we switch to CERT_REQUIRED and let OpenSSL use the provided cafile path via env.
cfg.verify_mode = ssl.CERT_REQUIRED
# Workaround: set env var SSL_CERT_FILE for this process if needed.
import os
os.environ.setdefault("SSL_CERT_FILE", args.cacert)
# Extra request headers from -H
extra_headers: List[Tuple[bytes, bytes]] = list(args.header)
async def runner():
async with connect(host, port, configuration=cfg, create_protocol=Http3Client) as proto:
client: Http3Client = proto # type: ignore
authority = f"{host}:{port}" if u.port else host
await client.request(
authority=authority,
path=path or "/",
method=method,
headers=extra_headers,
body=body_bytes,
save_path=args.save,
)
try:
await asyncio.wait_for(runner(), timeout=args.timeout)
except asyncio.TimeoutError:
LOG.error("Timed out after %.1f seconds", args.timeout)
raise SystemExit(1)
except Exception as e:
# Surface common TLS/verification issues clearly
LOG.error("Connection failed: %s", e)
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
Pointers
- authority must match the server’s SNI/cert host for verification (use –insecure for local self-signed).
- This client prints headers on stderr and body on stdout—handy for piping.
Step 4 — Run, test, and see it work
Server
sudo ufw allow 4433/udp
python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
Client
# GET homepage
python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
Client Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
2025-08-26 02:38:31,352 INFO quic: [e09a43d0a46f5eaf] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:38:31,363 INFO quic: [e09a43d0a46f5eaf] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 213
content-type: text/html
<!doctype html><meta charset="utf-8">
<title>HTTP/3 on QUIC – it works!</title>
<h1>🎉 Hello from HTTP/3 over QUIC.</h1>
<h1>Developed by SanchitGurukul</h1>
<p>Served by a tiny Python server you control.</p>
2025-08-26 02:38:31,424 INFO quic: [e09a43d0a46f5eaf] Connection close sent (code 0x0, reason )
Server Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:38:19,236 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:38:19,237 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:38:19,237 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
2025-08-26 02:38:31,332 DEBUG quic: [e09a43d0a46f5eaf] Network path ('127.0.0.1', 59063) discovered
2025-08-26 02:38:31,339 DEBUG quic: [e09a43d0a46f5eaf] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:38:31,339 INFO quic: [e09a43d0a46f5eaf] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:38:31,345 DEBUG quic: [e09a43d0a46f5eaf] TLS State.SERVER_EXPECT_CLIENT_HELLO -> State.SERVER_EXPECT_FINISHED
2025-08-26 02:38:31,359 DEBUG quic: [e09a43d0a46f5eaf] Discarding epoch Epoch.INITIAL
2025-08-26 02:38:31,359 DEBUG quic: [e09a43d0a46f5eaf] Network path ('127.0.0.1', 59063) validated by handshake
2025-08-26 02:38:31,372 DEBUG quic: [e09a43d0a46f5eaf] TLS State.SERVER_EXPECT_FINISHED -> State.SERVER_POST_HANDSHAKE
2025-08-26 02:38:31,372 DEBUG quic: [e09a43d0a46f5eaf] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:38:31,372 INFO quic: [e09a43d0a46f5eaf] ALPN negotiated protocol h3
2025-08-26 02:38:31,372 DEBUG quic: [e09a43d0a46f5eaf] Stream 2 created by peer
2025-08-26 02:38:31,373 DEBUG quic: [e09a43d0a46f5eaf] Stream 6 created by peer
2025-08-26 02:38:31,373 DEBUG quic: [e09a43d0a46f5eaf] Stream 10 created by peer
2025-08-26 02:38:31,373 INFO h3server: Handshake completed; HTTP/3 ready
2025-08-26 02:38:31,374 DEBUG quic: [e09a43d0a46f5eaf] Stream 0 created by peer
2025-08-26 02:38:31,425 INFO quic: [e09a43d0a46f5eaf] Connection close received (code 0x0, reason )
2025-08-26 02:38:31,425 DEBUG quic: [e09a43d0a46f5eaf] QuicConnectionState.CONNECTED -> QuicConnectionState.DRAINING
2025-08-26 02:38:31,684 DEBUG quic: [e09a43d0a46f5eaf] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:38:31,684 DEBUG quic: [e09a43d0a46f5eaf] QuicConnectionState.DRAINING -> QuicConnectionState.TERMINATED
# Health check
python ~/h3lab/h3_client.py https://localhost:4433/health --insecure
Client Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
2025-08-26 02:40:33,513 INFO quic: [8930c1e0c0a7450d] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:40:33,518 INFO quic: [8930c1e0c0a7450d] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 213
content-type: text/html
<!doctype html><meta charset="utf-8">
<title>HTTP/3 on QUIC – it works!</title>
<h1>🎉 Hello from HTTP/3 over QUIC.</h1>
<h1>Developed by SanchitGurukul</h1>
<p>Served by a tiny Python server you control.</p>
2025-08-26 02:40:33,541 INFO quic: [8930c1e0c0a7450d] Connection close sent (code 0x0, reason )
Server Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:40:29,966 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:40:29,967 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:40:29,967 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
2025-08-26 02:40:33,484 DEBUG quic: [8930c1e0c0a7450d] Network path ('127.0.0.1', 48435) discovered
2025-08-26 02:40:33,491 DEBUG quic: [8930c1e0c0a7450d] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:40:33,495 INFO quic: [8930c1e0c0a7450d] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:40:33,502 DEBUG quic: [8930c1e0c0a7450d] TLS State.SERVER_EXPECT_CLIENT_HELLO -> State.SERVER_EXPECT_FINISHED
2025-08-26 02:40:33,516 DEBUG quic: [8930c1e0c0a7450d] Discarding epoch Epoch.INITIAL
2025-08-26 02:40:33,517 DEBUG quic: [8930c1e0c0a7450d] Network path ('127.0.0.1', 48435) validated by handshake
2025-08-26 02:40:33,520 DEBUG quic: [8930c1e0c0a7450d] TLS State.SERVER_EXPECT_FINISHED -> State.SERVER_POST_HANDSHAKE
2025-08-26 02:40:33,520 DEBUG quic: [8930c1e0c0a7450d] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:40:33,520 INFO quic: [8930c1e0c0a7450d] ALPN negotiated protocol h3
2025-08-26 02:40:33,520 DEBUG quic: [8930c1e0c0a7450d] Stream 2 created by peer
2025-08-26 02:40:33,521 DEBUG quic: [8930c1e0c0a7450d] Stream 6 created by peer
2025-08-26 02:40:33,521 DEBUG quic: [8930c1e0c0a7450d] Stream 10 created by peer
2025-08-26 02:40:33,521 INFO h3server: Handshake completed; HTTP/3 ready
2025-08-26 02:40:33,522 DEBUG quic: [8930c1e0c0a7450d] Stream 0 created by peer
2025-08-26 02:40:33,542 INFO quic: [8930c1e0c0a7450d] Connection close received (code 0x0, reason )
2025-08-26 02:40:33,542 DEBUG quic: [8930c1e0c0a7450d] QuicConnectionState.CONNECTED -> QuicConnectionState.DRAINING
2025-08-26 02:40:33,672 DEBUG quic: [8930c1e0c0a7450d] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:40:33,673 DEBUG quic: [8930c1e0c0a7450d] QuicConnectionState.DRAINING -> QuicConnectionState.TERMINATED
# Echo
python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello over QUIC" --insecure
Client Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello over QUIC" --insecure
2025-08-26 02:41:22,763 INFO quic: [73444cd8e1970437] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:41:22,769 INFO quic: [73444cd8e1970437] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 15
content-type: application/octet-stream
hello over QUIC2025-08-26 02:41:22,784 INFO quic: [73444cd8e1970437] Connection close sent (code 0x0, reason )
Server Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:41:07,465 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:41:07,465 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:41:07,465 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
2025-08-26 02:41:22,736 DEBUG quic: [73444cd8e1970437] Network path ('127.0.0.1', 42194) discovered
2025-08-26 02:41:22,748 DEBUG quic: [73444cd8e1970437] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:41:22,749 INFO quic: [73444cd8e1970437] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:41:22,758 DEBUG quic: [73444cd8e1970437] TLS State.SERVER_EXPECT_CLIENT_HELLO -> State.SERVER_EXPECT_FINISHED
2025-08-26 02:41:22,766 DEBUG quic: [73444cd8e1970437] Discarding epoch Epoch.INITIAL
2025-08-26 02:41:22,767 DEBUG quic: [73444cd8e1970437] Network path ('127.0.0.1', 42194) validated by handshake
2025-08-26 02:41:22,772 DEBUG quic: [73444cd8e1970437] TLS State.SERVER_EXPECT_FINISHED -> State.SERVER_POST_HANDSHAKE
2025-08-26 02:41:22,774 DEBUG quic: [73444cd8e1970437] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:41:22,774 INFO quic: [73444cd8e1970437] ALPN negotiated protocol h3
2025-08-26 02:41:22,774 DEBUG quic: [73444cd8e1970437] Stream 2 created by peer
2025-08-26 02:41:22,774 DEBUG quic: [73444cd8e1970437] Stream 6 created by peer
2025-08-26 02:41:22,775 DEBUG quic: [73444cd8e1970437] Stream 10 created by peer
2025-08-26 02:41:22,776 INFO h3server: Handshake completed; HTTP/3 ready
2025-08-26 02:41:22,779 DEBUG quic: [73444cd8e1970437] Stream 0 created by peer
2025-08-26 02:41:22,783 DEBUG quic: [73444cd8e1970437] Stream 0 discarded
2025-08-26 02:41:22,784 INFO quic: [73444cd8e1970437] Connection close received (code 0x0, reason )
2025-08-26 02:41:22,784 DEBUG quic: [73444cd8e1970437] QuicConnectionState.CONNECTED -> QuicConnectionState.DRAINING
2025-08-26 02:41:22,898 DEBUG quic: [73444cd8e1970437] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:41:22,899 DEBUG quic: [73444cd8e1970437] QuicConnectionState.DRAINING -> QuicConnectionState.TERMINATED
# Check with Self sign certificate
python ~/h3lab/h3_client.py https://localhost:4433/upload --post "tiny file" --insecure -v
Client Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/upload --post "tiny file" --insecure -v
2025-08-26 02:43:54,318 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_HANDSHAKE_START -> State.CLIENT_EXPECT_SERVER_HELLO
2025-08-26 02:43:54,338 DEBUG quic: [048d455dfba74c44] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:43:54,340 INFO quic: [048d455dfba74c44] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:43:54,340 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_EXPECT_SERVER_HELLO -> State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS
2025-08-26 02:43:54,341 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS -> State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE
2025-08-26 02:43:54,341 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE -> State.CLIENT_EXPECT_CERTIFICATE_VERIFY
2025-08-26 02:43:54,342 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.INITIAL
2025-08-26 02:43:54,345 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_EXPECT_CERTIFICATE_VERIFY -> State.CLIENT_EXPECT_FINISHED
2025-08-26 02:43:54,345 DEBUG quic: [048d455dfba74c44] TLS State.CLIENT_EXPECT_FINISHED -> State.CLIENT_POST_HANDSHAKE
2025-08-26 02:43:54,346 INFO quic: [048d455dfba74c44] ALPN negotiated protocol h3
2025-08-26 02:43:54,349 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:43:54,351 DEBUG quic: [048d455dfba74c44] Stream 3 created by peer
2025-08-26 02:43:54,351 DEBUG quic: [048d455dfba74c44] Stream 7 created by peer
2025-08-26 02:43:54,351 DEBUG quic: [048d455dfba74c44] Stream 11 created by peer
2025-08-26 02:43:54,353 DEBUG quic: [048d455dfba74c44] Stream 0 discarded
:status: 200
server: py-h3
content-length: 43
content-type: text/plain; charset=utf-8
saved:7b54565cbc394a8fba22ec24abc4289e.bin
2025-08-26 02:43:54,359 INFO quic: [048d455dfba74c44] Connection close sent (code 0x0, reason )
2025-08-26 02:43:54,359 DEBUG quic: [048d455dfba74c44] QuicConnectionState.CONNECTED -> QuicConnectionState.CLOSING
2025-08-26 02:43:54,530 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:43:54,530 DEBUG quic: [048d455dfba74c44] QuicConnectionState.CLOSING -> QuicConnectionState.TERMINATED
Server Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:43:44,261 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:43:44,262 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:43:44,262 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
2025-08-26 02:43:54,320 DEBUG quic: [048d455dfba74c44] Network path ('127.0.0.1', 51783) discovered
2025-08-26 02:43:54,329 DEBUG quic: [048d455dfba74c44] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:43:54,330 INFO quic: [048d455dfba74c44] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:43:54,336 DEBUG quic: [048d455dfba74c44] TLS State.SERVER_EXPECT_CLIENT_HELLO -> State.SERVER_EXPECT_FINISHED
2025-08-26 02:43:54,343 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.INITIAL
2025-08-26 02:43:54,343 DEBUG quic: [048d455dfba74c44] Network path ('127.0.0.1', 51783) validated by handshake
2025-08-26 02:43:54,347 DEBUG quic: [048d455dfba74c44] TLS State.SERVER_EXPECT_FINISHED -> State.SERVER_POST_HANDSHAKE
2025-08-26 02:43:54,348 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:43:54,348 INFO quic: [048d455dfba74c44] ALPN negotiated protocol h3
2025-08-26 02:43:54,348 DEBUG quic: [048d455dfba74c44] Stream 2 created by peer
2025-08-26 02:43:54,348 DEBUG quic: [048d455dfba74c44] Stream 6 created by peer
2025-08-26 02:43:54,348 DEBUG quic: [048d455dfba74c44] Stream 10 created by peer
2025-08-26 02:43:54,348 INFO h3server: Handshake completed; HTTP/3 ready
2025-08-26 02:43:54,349 DEBUG quic: [048d455dfba74c44] Stream 0 created by peer
2025-08-26 02:43:54,355 DEBUG quic: [048d455dfba74c44] Stream 0 discarded
2025-08-26 02:43:54,370 INFO quic: [048d455dfba74c44] Connection close received (code 0x0, reason )
2025-08-26 02:43:54,371 DEBUG quic: [048d455dfba74c44] QuicConnectionState.CONNECTED -> QuicConnectionState.DRAINING
2025-08-26 02:43:54,479 DEBUG quic: [048d455dfba74c44] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:43:54,479 DEBUG quic: [048d455dfba74c44] QuicConnectionState.DRAINING -> QuicConnectionState.TERMINATED
# Upload something
echo -n "tiny file" | python ~/h3lab/h3_client.py https://localhost:4433/upload --post-stdin --insecure -v
Client Output:
(h3lab) root@sanchit:~/h3lab# echo -n "tiny file" | python ~/h3lab/h3_client.py https://localhost:4433/upload --post-stdin --insecure -v
2025-08-26 02:46:26,617 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_HANDSHAKE_START -> State.CLIENT_EXPECT_SERVER_HELLO
2025-08-26 02:46:26,640 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:46:26,643 INFO quic: [7a6d510bb4bb393c] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:46:26,644 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_EXPECT_SERVER_HELLO -> State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS
2025-08-26 02:46:26,644 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS -> State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE
2025-08-26 02:46:26,645 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE -> State.CLIENT_EXPECT_CERTIFICATE_VERIFY
2025-08-26 02:46:26,646 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.INITIAL
2025-08-26 02:46:26,648 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_EXPECT_CERTIFICATE_VERIFY -> State.CLIENT_EXPECT_FINISHED
2025-08-26 02:46:26,649 DEBUG quic: [7a6d510bb4bb393c] TLS State.CLIENT_EXPECT_FINISHED -> State.CLIENT_POST_HANDSHAKE
2025-08-26 02:46:26,650 INFO quic: [7a6d510bb4bb393c] ALPN negotiated protocol h3
2025-08-26 02:46:26,657 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:46:26,658 DEBUG quic: [7a6d510bb4bb393c] Stream 3 created by peer
2025-08-26 02:46:26,658 DEBUG quic: [7a6d510bb4bb393c] Stream 7 created by peer
2025-08-26 02:46:26,659 DEBUG quic: [7a6d510bb4bb393c] Stream 11 created by peer
2025-08-26 02:46:26,660 DEBUG quic: [7a6d510bb4bb393c] Stream 0 discarded
:status: 200
server: py-h3
content-length: 43
content-type: text/plain; charset=utf-8
saved:8c81aeadaf554f8ea715c75ba4e1dd9a.bin
2025-08-26 02:46:26,666 INFO quic: [7a6d510bb4bb393c] Connection close sent (code 0x0, reason )
2025-08-26 02:46:26,667 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.CONNECTED -> QuicConnectionState.CLOSING
2025-08-26 02:46:26,852 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:46:26,852 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.CLOSING -> QuicConnectionState.TERMINATED
Server Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:46:21,946 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:46:21,946 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:46:21,946 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
2025-08-26 02:46:26,622 DEBUG quic: [7a6d510bb4bb393c] Network path ('127.0.0.1', 57420) discovered
2025-08-26 02:46:26,629 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.FIRSTFLIGHT -> QuicConnectionState.CONNECTED
2025-08-26 02:46:26,630 INFO quic: [7a6d510bb4bb393c] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 02:46:26,636 DEBUG quic: [7a6d510bb4bb393c] TLS State.SERVER_EXPECT_CLIENT_HELLO -> State.SERVER_EXPECT_FINISHED
2025-08-26 02:46:26,648 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.INITIAL
2025-08-26 02:46:26,649 DEBUG quic: [7a6d510bb4bb393c] Network path ('127.0.0.1', 57420) validated by handshake
2025-08-26 02:46:26,654 DEBUG quic: [7a6d510bb4bb393c] TLS State.SERVER_EXPECT_FINISHED -> State.SERVER_POST_HANDSHAKE
2025-08-26 02:46:26,654 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.HANDSHAKE
2025-08-26 02:46:26,655 INFO quic: [7a6d510bb4bb393c] ALPN negotiated protocol h3
2025-08-26 02:46:26,655 DEBUG quic: [7a6d510bb4bb393c] Stream 2 created by peer
2025-08-26 02:46:26,655 DEBUG quic: [7a6d510bb4bb393c] Stream 6 created by peer
2025-08-26 02:46:26,655 DEBUG quic: [7a6d510bb4bb393c] Stream 10 created by peer
2025-08-26 02:46:26,656 INFO h3server: Handshake completed; HTTP/3 ready
2025-08-26 02:46:26,657 DEBUG quic: [7a6d510bb4bb393c] Stream 0 created by peer
2025-08-26 02:46:26,662 DEBUG quic: [7a6d510bb4bb393c] Stream 0 discarded
2025-08-26 02:46:26,668 INFO quic: [7a6d510bb4bb393c] Connection close received (code 0x0, reason )
2025-08-26 02:46:26,668 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.CONNECTED -> QuicConnectionState.DRAINING
2025-08-26 02:46:26,785 DEBUG quic: [7a6d510bb4bb393c] Discarding epoch Epoch.ONE_RTT
2025-08-26 02:46:26,786 DEBUG quic: [7a6d510bb4bb393c] QuicConnectionState.DRAINING -> QuicConnectionState.TERMINATED
ls -l ~/h3lab/www/uploads
(h3lab) root@sanchit:~/h3lab# ls -l ~/h3lab/www/uploads
total 20
-rw-r--r-- 1 root root 9 Aug 26 02:45 54248dc2d06f44f5b3bfe8d2bd0fe441.bin
-rw-r--r-- 1 root root 9 Aug 26 02:35 5fbfcd05dd094c1390e6fa17cc138eee.bin
-rw-r--r-- 1 root root 9 Aug 26 02:43 7b54565cbc394a8fba22ec24abc4289e.bin
-rw-r--r-- 1 root root 9 Aug 26 02:46 8c81aeadaf554f8ea715c75ba4e1dd9a.bin
-rw-r--r-- 1 root root 9 Aug 26 02:36 dba4c2774a5145838ec4629e5c51b7f2.bin
Pointers
- QUIC = UDP. If things time out, double-check you opened UDP in UFW / cloud SG.
- Browsers: modern Chrome/Firefox support H3—load https://localhost:4433/ (allow the self-signed cert).
Step 5 — Observe & debug like a pro
Wireshark decryption (TLS secrets)
- Wireshark → Edit → Preferences → Protocols → TLS → set (Pre-Master) Key Log filename to ~/h3lab/logs/secrets.log.
- Start capture → run client → you’ll see QUIC decrypted packets, plus H3 headers/frames.
QLOG traces (qvis)
- We enabled –qlog ~/h3lab/logs. Open quic.qlog.json in qvis to visualize handshake, streams, loss, PTO, congestion windows, etc.
Pointers
- If you don’t see decrypted payloads, ensure the secrets file is being written (permissions, path).
- QLOG is per run in this example; you can also rotate per connection.
Step 6 — Make it a service (systemd)
Why this matters: Realistic labs start & survive reboots the same way they would in prod.
sudo tee /etc/systemd/system/h3server@.service >/dev/null <<'UNIT'
[Unit]
Description=Tiny QUIC/HTTP3 Server (Python/aioquic) for %i
After=network.target
[Service]
User=%i
WorkingDirectory=/root/h3lab
Environment="PATH=/root/h3lab/bin:/usr/bin"
ExecStart=/root/h3lab/bin/python /root/h3lab/h3_server.py \
--host 0.0.0.0 --port 4433 \
--www /root/h3lab/www \
--secrets-log /root/h3lab/logs/secrets.log \
--qlog /root/h3lab/logs
Restart=on-failure
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
# replace 'sanchit' with your username if needed
sudo systemctl enable h3server@sanchit --now
Output:
(h3lab) root@sanchit:~/h3lab# sudo systemctl enable h3server@root --now
Created symlink /etc/systemd/system/multi-user.target.wants/h3server@root.service → /etc/systemd/system/h3server@.service.
systemctl status h3server@sanchit
Output:
systemctl status h3server@root -l
● h3server@root.service - Tiny QUIC/HTTP3 Server (Python/aioquic) for root
Loaded: loaded (/etc/systemd/system/h3server@.service; enabled; preset: enabled)
Active: active (running) since Tue 2025-08-26 03:19:32 UTC; 69ms ago
Main PID: 7419 (python)
Tasks: 1 (limit: 4605)
Memory: 3.8M (peak: 3.8M)
CPU: 41ms
CGroup: /system.slice/system-h3server.slice/h3server@root.service
└─7419 /root/h3lab/bin/python /root/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --www /root/h3lab/www --secrets-log /root>
Aug 26 03:19:32 sanchit systemd[1]: Started h3server@root.service - Tiny QUIC/HTTP3 Server (Python/aioquic) for root.
Pointers
- Use the templated unit (%i) so each user can run their own instance.
- Logs go to journalctl -u h3server@sanchit -f.
Step 7 — Hardening & performance tips
- Dual-stack: also bind IPv6 (--host ::) and open UDP in firewall for v6.
- UDP buffers (busy networks):
echo 'net.core.rmem_max=2500000' | sudo tee /etc/sysctl.d/90-quic.conf
echo 'net.core.wmem_max=2500000' | sudo tee -a /etc/sysctl.d/90-quic.conf
sudo sysctl --system
Outputs:
(h3lab) root@sanchit:~/h3lab# echo 'net.core.rmem_max=2500000' | sudo tee /etc/sysctl.d/90-quic.conf
net.core.rmem_max=2500000
(h3lab) root@sanchit:~/h3lab# echo 'net.core.wmem_max=2500000' | sudo tee -a /etc/sysctl.d/90-quic.conf
net.core.wmem_max=2500000
(h3lab) root@sanchit:~/h3lab# sudo sysctl --system
* Applying /usr/lib/sysctl.d/10-apparmor.conf ...
* Applying /etc/sysctl.d/10-bufferbloat.conf ...
* Applying /etc/sysctl.d/10-console-messages.conf ...
* Applying /etc/sysctl.d/10-ipv6-privacy.conf ...
* Applying /etc/sysctl.d/10-kernel-hardening.conf ...
* Applying /etc/sysctl.d/10-magic-sysrq.conf ...
* Applying /etc/sysctl.d/10-map-count.conf ...
* Applying /etc/sysctl.d/10-network-security.conf ...
* Applying /etc/sysctl.d/10-ptrace.conf ...
* Applying /etc/sysctl.d/10-zeropage.conf ...
* Applying /usr/lib/sysctl.d/50-pid-max.conf ...
* Applying /etc/sysctl.d/90-quic.conf ...
* Applying /usr/lib/sysctl.d/99-protect-links.conf ...
* Applying /etc/sysctl.d/99-sysctl.conf ...
* Applying /etc/sysctl.conf ...
kernel.apparmor_restrict_unprivileged_userns = 1
net.core.default_qdisc = fq_codel
kernel.printk = 4 4 1 7
net.ipv6.conf.all.use_tempaddr = 2
net.ipv6.conf.default.use_tempaddr = 2
kernel.kptr_restrict = 1
kernel.sysrq = 176
vm.max_map_count = 1048576
net.ipv4.conf.default.rp_filter = 2
net.ipv4.conf.all.rp_filter = 2
kernel.yama.ptrace_scope = 1
vm.mmap_min_addr = 65536
kernel.pid_max = 4194304
net.core.rmem_max = 2500000
net.core.wmem_max = 2500000
fs.protected_fifos = 1
fs.protected_hardlinks = 1
fs.protected_regular = 2
fs.protected_symlinks = 1
- MIME hygiene: add custom types if serving .wasm, .json, etc.
- CORS / headers: if this fronts a web app, add route-specific headers (e.g., access-control-allow-origin).
- Large uploads: swap the simple /upload buffer with chunked writes to a temp file to avoid memory spikes.
- Real certs: for a domain like sanchitgurukul.xyz, use a proper certificate and remove --insecure from the client.
Step 8 — Common errors & fixes
- “Nothing shows up”: QUIC uses UDP; open 4433/udp (not TCP).
- Handshake but no H3: confirm both sides use H3_ALPN.
- Cert verify failed: local tests → use --insecure on the client or match SNI to SAN.
- Import issues: always run from a venv and pip install aioquic; don’t execute from inside the aioquic source tree.
Step 9 — Where to take this next
- Add JSON APIs: extend routing to return/accept JSON; wire up request logging.
- 0-RTT: cache session tickets so second connection sends data in 0-RTT.
- QUIC datagrams: for telemetry or game-style updates without stream head-of-line blocking.
- ASGI app: front FastAPI or Starlette via HTTP/3 (great for modern APIs).
- Metrics: emit Prometheus counters for requests, bytes, rtt, loss (from QLOG or app).
Quick reference (commands you’ll use a lot)
# Start server (verbose + qlog)
python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
Output:
(h3lab) root@sanchit:~# python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
2025-08-26 02:46:21,946 INFO h3server: HTTP/3 server listening on udp://0.0.0.0:4433 (www=/root/h3lab/www)
2025-08-26 02:46:21,946 INFO h3server: TLS secrets log: /root/h3lab/logs/secrets.log
2025-08-26 02:46:21,946 INFO h3server: QLOG enabled; will write JSON at shutdown to /root/h3lab/logs/*.qlog.json
# Test GET / HEAD
python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
2025-08-26 03:27:53,413 INFO quic: [dd172bf58d61de9d] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 03:27:53,429 INFO quic: [dd172bf58d61de9d] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 213
content-type: text/html
<!doctype html><meta charset="utf-8">
<title>HTTP/3 on QUIC – it works!</title>
<h1>🎉 Hello from HTTP/3 over QUIC.</h1>
<h1>Developed by SanchitGurukul</h1>
<p>Served by a tiny Python server you control.</p>
2025-08-26 03:27:53,475 INFO quic: [dd172bf58d61de9d] Connection close sent (code 0x0, reason )
python ~/h3lab/h3_client.py https://localhost:4433/health --insecure
Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/health --insecure
2025-08-26 03:28:10,360 INFO quic: [11f20a1439c404fb] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 03:28:10,364 INFO quic: [11f20a1439c404fb] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 3
content-type: text/plain; charset=utf-8
ok
2025-08-26 03:28:10,390 INFO quic: [11f20a1439c404fb] Connection close sent (code 0x0, reason )
# Test POST echo
python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello" --insecure
Output:
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello" --insecure
2025-08-26 03:28:26,582 INFO quic: [9817948006c00399] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 03:28:26,586 INFO quic: [9817948006c00399] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 5
content-type: application/octet-stream
hello2025-08-26 03:28:26,612 INFO quic: [9817948006c00399] Connection close sent (code 0x0, reason )
# Try a file you add under ~/h3lab/www
cp /etc/hosts ~/h3lab/www/hosts.txt
python ~/h3lab/h3_client.py https://localhost:4433/hosts.txt --insecure
Output:
(h3lab) root@sanchit:~/h3lab# cp /etc/hosts ~/h3lab/www/hosts.txt
(h3lab) root@sanchit:~/h3lab# python ~/h3lab/h3_client.py https://localhost:4433/hosts.txt --insecure
2025-08-26 03:28:57,782 INFO quic: [fe905264f88a188c] Negotiated protocol version 0x00000001 (VERSION_1)
2025-08-26 03:28:57,785 INFO quic: [fe905264f88a188c] ALPN negotiated protocol h3
:status: 200
server: py-h3
content-length: 222
content-type: text/plain
127.0.0.1 localhost
127.0.1.1 sanchit
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
2025-08-26 03:28:57,797 INFO quic: [fe905264f88a188c] Connection close sent (code 0x0, reason )
Summary
In this article, we built a hands-on HTTP/3 lab on Ubuntu 24.04 using Python and aioquic. Step by step, you:
- Installed prerequisites and created a clean virtual environment.
- Generated TLS certificates (with SANs) — a must for HTTP/3.
- Built a Python QUIC/HTTP-3 server from scratch (with GET, HEAD, POST, health check, and uploads).
- Wrote a Python client to send and read HTTP/3 requests without relying on browser quirks or curl builds.
- Tested the lab with real requests, observed QUIC traffic in Wireshark, and visualized connections with QLOG/qvis.
- Deployed the server as a systemd service for persistence.
- Applied hardening tips for UDP buffers, firewall, MIME types, and production certs.
- Learned common troubleshooting tricks (UDP vs TCP ports, ALPN mismatch, cert errors).
- Saw how to extend the lab (0-RTT, datagrams, JSON APIs, FastAPI/ASGI integration).
The takeaway?
- QUIC is real and widely deployed today.
- HTTP/3 is easy to prototype locally — no need for massive server stacks.
- With just Python and aioquic, you can explore modern transport protocols in depth.
This lab is a perfect foundation if you want to:
- Study QUIC in Wireshark,
- Prototype HTTP/3 APIs,
- Test edge cases (loss, reordering, 0-RTT),
- Or migrate your own services to QUIC/HTTP-3.
🚀 Next step: integrate this lab with your real domain (e.g., sanchitgurukul.xyz) using a trusted certificate, and you’ll be production-ready!
Useful Links
https://datatracker.ietf.org/doc/rfc9000
https://www.wireshark.org/docs/relnotes
https://sanchitgurukul.com/basic-networking
https://sanchitgurukul.com/network-security
