Skip to content

Commit 05e5e89

Browse files
committed
Merge TASK-196: Serve public HTTP3 through native UDP owners
2 parents e83857a + bc1a280 commit 05e5e89

76 files changed

Lines changed: 5824 additions & 185 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile.am‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,9 @@ EXTRA_DIST += scripts/check-v3-public-http2.py scripts/test-v3-public-http2.py \
566566
test/installed/v3_http2_consumer.cpp test/installed/v3_http2_node.mjs test/installed/v3_http2_raw.mjs \
567567
examples/v3_http2.cpp specs/architecture/v3/TASK-195-design.md \
568568
specs/architecture/v3/TASK-195-implementation-evidence.md specs/architecture/v3/TASK-195-receipt.json
569+
570+
EXTRA_DIST += scripts/test-v3-http3-full-gates.py scripts/check-v3-public-http3.py scripts/test-v3-public-http3.py \
571+
scripts/lib/v3_public_http3_inventory.py test/installed/v3_http3_consumer.cpp \
572+
test/installed/v3_http3_aioquic.py test/integ/http3-client-go/public.go
573+
EXTRA_DIST += specs/architecture/v3/TASK-196-design.md \
574+
specs/architecture/v3/TASK-196-implementation-evidence.md specs/architecture/v3/TASK-196-receipt.json

‎docs/v3-api.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,19 @@ contract](https://github.com/etr/libhttpserver/blob/v3/docs/native-tls-build.md)
8585
TLS-off retains the same declarations and typed unavailable operations.
8686
The [TASK-179 audit](https://github.com/etr/libhttpserver/blob/v3/docs/task-179-behavior-parity.md) distinguishes semantic,
8787
frame, provider and private TCP/TLS/QUIC observations.
88+
89+
## Explicit UDP HTTP/3 listeners
90+
91+
A listener entry owns one transport. Append `server::listener_transport::udp` to the existing aggregate to bind QUIC; TCP remains the default:
92+
93+
```cpp
94+
options.protocols().enable(http::protocol::http_3);
95+
options.tls().provider = server::tls_provider::system_default;
96+
options.tls().profile = server::tls_profile::certificates;
97+
options.add_listener({"127.0.0.1", 0, true, credentials,
98+
server::listener_transport::udp});
99+
```
100+
101+
Publish a certificate host with `h3` ALPN through the ordinary public credential store and register the ordinary public routes. `get_bound_port(index)` reports that UDP entry's actual port. To serve TCP and UDP on the same fixed port, configure two entries with different transports. All endpoints bind before any becomes active; partial failure rolls back every port. H3 without explicit UDP and UDP without H3/TLS fail before bind. TLS-off builds retain these declarations and return typed `not_supported`. External PSK remains HTTP/1-only.
102+
103+
The native path supports authenticated QUICv1/TLS1.3 routes, incremental bodies, ordered trailers and independent concurrent request streams. Native RFC9220 and comprehensive HTTP/3 parity/rotation/staged drain are subsequent v3 tasks. `request_stop()` is immediate; `stop()` and destruction retain callback services until pending completions and protocol close expiry, including when an external host loop has stopped dispatching.

‎scripts/check-v3-public-http3.py‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
"""Installed public-only H3 transport: independent clients and native loops."""
3+
import socket
4+
import argparse, asyncio, hashlib, importlib.metadata, importlib.util, json, os, shlex, shutil, ssl, sys, tempfile
5+
from pathlib import Path
6+
ROOT=Path(__file__).resolve().parents[1]
7+
sys.path.insert(0,str(ROOT/'scripts/lib'))
8+
from v3_package_commands import Commands, runtime_environment
9+
from v3_package_integration import compile_probe
10+
from v3_package_matrix import check_linkage
11+
from v3_package_process import Consumer
12+
from v3_public_http3_inventory import CLIENTS, ONESHOTS, validate_client, validate_matrix
13+
from v3_public_http2_h2 import Client as H2Client
14+
15+
def require(value,message):
16+
if not value: raise ValueError(message)
17+
def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest()
18+
def load(path,name):
19+
spec=importlib.util.spec_from_file_location(name,path)
20+
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
21+
return module
22+
23+
def scenarios(executable,loop,args,directory,env,fixtures,commands,client):
24+
rows=[]
25+
if args.tls=='no':
26+
output=commands([executable,fixtures,loop],directory,env)
27+
require(output.strip()=='TLS_OFF_H3_REJECTED','same-source TLS-off typed H3 rejection')
28+
return [],[dict(case='tls_off_h3_rejected',status='passed')]
29+
for name in CLIENTS:
30+
process_receipt=directory/(name+'-basic-process.json')
31+
with Consumer([str(executable),str(fixtures),loop],directory,env,process_receipt) as child:
32+
port=child.port()
33+
with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as malformed:
34+
for packet in (b'bad Initial',b'\xc0'+b'\x00'*63,b'\xc0'+b'\xff'*1199):
35+
malformed.sendto(packet,('127.0.0.1',port))
36+
if name=='aioquic':
37+
result=dict(client=name,version=importlib.metadata.version(name),alpn='h3',verified=True,
38+
quic_version=1,tls_version='TLSv1.3',early_data=False, retry_count=1,
39+
scenarios=asyncio.run(client.basic(port,fixtures)))
40+
else:
41+
output=commands([args.quic_go,'-matrix','public','-port',str(port),'-ca',fixtures/'root.pem'],directory,env,timeout=20)
42+
result=json.loads(output.strip().splitlines()[-1])
43+
validate_client(result); child.stop()
44+
process=json.loads(process_receipt.read_text())
45+
require('H3_ACCEPT_TOTAL 1 0' in process['stderr'],'malformed Initial reached connection admission')
46+
require(process['exit']==0 and 'H3_STOP ' in process['stderr'],'public owner shutdown absent')
47+
if loop=='external': require('INACTIVE_DRIVER_REJECTED' in process['stderr'],'inactive external readiness driver')
48+
rows.append(dict(**result,process_receipt=str(process_receipt)))
49+
checks=[dict(case=name+'_bad_initial_listener_recovery',status='passed') for name in CLIENTS]
50+
for mode,expected in ONESHOTS.items():
51+
output=commands([executable,fixtures,loop,mode],directory,env)
52+
require(output.strip()==expected,'prebind/rollback predicate '+mode)
53+
checks.append(dict(case=mode,status='passed'))
54+
for family,mode in [('policy','policy'),('timeouts','timeouts'),('failed-handshake','basic'),('stalled','stalled'),('destruction','destroy')]:
55+
process_receipt=directory/(family+'-process.json')
56+
with Consumer([str(executable),str(fixtures),loop,mode],directory,env,process_receipt) as child:
57+
port=child.port()
58+
if family=='policy': observations=asyncio.run(client.rejection(port,fixtures))
59+
elif family=='timeouts': observations=asyncio.run(client.timeout_recovery(port,fixtures))
60+
elif family=='failed-handshake': observations=asyncio.run(client.failed_handshake(port,fixtures))
61+
elif family=='stalled': observations=asyncio.run(client.stalled_handshake(port,fixtures))
62+
else: observations=asyncio.run(client.active_stop(port,fixtures,child.stop))
63+
if family!='destruction':child.stop()
64+
process=json.loads(process_receipt.read_text())
65+
if family=='stalled':require('H3_ACCEPT_TOTAL 2 0' in process['stderr'] and 'H3_TLS_COMPLETED 1' in process['stderr'],'stalled handshake retired before recovery')
66+
if family=='policy':require('POLICY_REFUSAL ' in process['stderr'],'admission hook before TLS')
67+
if family=='destruction':require('H3_DESTROYED ' in process['stderr'],'active destruction completion')
68+
checks.extend(observations)
69+
process_receipt=directory/'quic-go-destruction-process.json'
70+
with Consumer([str(executable),str(fixtures),loop,'destroy'],directory,env,process_receipt) as server:
71+
port=server.port()
72+
with Consumer([str(args.quic_go),'-matrix','public-stop','-port',str(port),'-ca',str(fixtures/'root.pem')],directory,env,directory/'quic-go-active-process.json') as independent:
73+
require(independent.line()=='H3_ACTIVE','independent active stream absent')
74+
server.stop()
75+
result=json.loads(independent.line());require(result.get('status')=='passed' and result.get('client')=='quic-go','independent destruction witness')
76+
require(independent.process.wait(timeout=5)==0,'independent destruction process failed')
77+
checks.append(dict(case='quic_go_active_stream_server_destruction',status='passed'))
78+
for name in CLIENTS:
79+
with Consumer([str(executable),str(fixtures),loop,'mixed'],directory,env,directory/(name+'-mixed-process.json')) as child:
80+
port=child.port();mixed_tcp(port,fixtures)
81+
if name=='aioquic':asyncio.run(client.basic(port,fixtures))
82+
else:
83+
result=json.loads(commands([args.quic_go,'-matrix','public','-port',str(port),'-ca',fixtures/'root.pem'],directory,env,timeout=20).strip().splitlines()[-1])
84+
validate_client(result)
85+
child.stop()
86+
checks.append(dict(case=name+'_mixed_tcp_udp_same_port',status='passed'))
87+
return rows,checks
88+
89+
def mixed_tcp(port,fixtures):
90+
context=ssl.create_default_context(cafile=str(fixtures/'root.pem'))
91+
context.set_alpn_protocols(['http/1.1'])
92+
with context.wrap_socket(socket.create_connection(('127.0.0.1',port),timeout=3),server_hostname='a.example') as peer:
93+
require(peer.selected_alpn_protocol()=='http/1.1','mixed TLS TCP H1 ALPN')
94+
peer.sendall(b'GET /hello HTTP/1.1\r\nHost: a.example\r\nConnection: close\r\n\r\n')
95+
data=bytearray()
96+
while chunk:=peer.recv(4096):
97+
data.extend(chunk);require(len(data)<=65536,'mixed H1 response bound')
98+
require(b'hello native h3\n' in data and b'native-fin' in data,'mixed H1 route/trailer')
99+
client=H2Client(port,fixtures)
100+
try:
101+
result=client.get('/hello')
102+
require(result['body']==b'hello native h3\n','mixed TLS TCP H2 route')
103+
finally:client.close()
104+
105+
def main():
106+
parser=argparse.ArgumentParser(description=__doc__)
107+
for name in ('prefix','provider-prefix','build','output'): parser.add_argument('--'+name,type=Path,required=True)
108+
parser.add_argument('--source',type=Path,default=ROOT)
109+
parser.add_argument('--tls',choices=('yes','no'),required=True)
110+
parser.add_argument('--compiler',default='/usr/bin/clang++-18')
111+
parser.add_argument('--quic-go',type=Path,required=True)
112+
parser.add_argument('--sanitize',default='')
113+
args=parser.parse_args(); args.output=args.output.resolve(); args.output.mkdir(parents=True,exist_ok=False)
114+
prefix,source,build,provider=[p.resolve() for p in (args.prefix,args.source,args.build,args.provider_prefix)]
115+
commands=Commands(args.output); env=runtime_environment(os.environ,prefix,provider,[])
116+
env.update(PKG_CONFIG_PATH='',PKG_CONFIG_LIBDIR=str(prefix/'lib/pkgconfig'))
117+
receipt=dict(version=1,predicate='installed-public-http3',tls=args.tls,prefix=str(prefix),source=str(source),build=str(build),
118+
cells=[],command=sys.argv,environment={k:env.get(k) for k in ('ASAN_OPTIONS','UBSAN_OPTIONS','TSAN_OPTIONS','LD_LIBRARY_PATH')})
119+
try:
120+
audit=load(source/'scripts/audit-v3-installed-package.py','audit')
121+
client=load(source/'test/installed/v3_http3_aioquic.py','public_h3_client') if args.tls=='yes' else None
122+
receipt['revision']=commands(['git','rev-parse','HEAD'],source,env).strip()
123+
receipt['branch']=commands(['git','branch','--show-current'],source,env).strip()
124+
receipt['compiler']=commands(shlex.split(args.compiler)+['--version'],source,env)
125+
receipt['python']=dict(executable=sys.executable,version=sys.version,ssl=ssl.OPENSSL_VERSION,sha256=digest(Path(sys.executable)))
126+
if args.tls=='yes':
127+
require(importlib.metadata.version('aioquic')==CLIENTS['aioquic'],'aioquic pin')
128+
receipt['clients']=dict(quic_go=dict(executable=str(args.quic_go),sha256=digest(args.quic_go),identity=json.loads(commands([args.quic_go,'--identity'],source,env))),aioquic=dict(version='1.3.0'))
129+
import aioquic
130+
receipt['clients']['aioquic']['source_hashes']={str(p):digest(p) for p in Path(aioquic.__file__).parent.rglob('*') if p.is_file() and p.suffix in ('.py','.so')}
131+
receipt['provider']={str(p):digest(p) for pattern in ('libssl.so*','libcrypto.so*','libssl.a','libcrypto.a') for p in (provider/'lib').glob(pattern) if p.is_file()}
132+
inputs=sorted(p for p in (source/'src').rglob('*') if p.suffix in ('.cpp','.hpp'))
133+
inputs += [source/p for p in ('src/Makefile.am','test/Makefile.am','configure.ac','test/installed/v3_http3_consumer.cpp','test/installed/v3_http3_aioquic.py','test/integ/http3-client-go/public.go','scripts/check-v3-public-http3.py','scripts/lib/v3_public_http3_inventory.py')]
134+
inputs += sorted((source/'test/integ/http3-client-go').glob('*.go')) + [source/'test/integ/http3-client-go/go.mod',source/'test/integ/http3-client-go/go.sum']
135+
receipt['source_hashes']={str(p.relative_to(source)):digest(p) for p in inputs}
136+
receipt['source_identity']=hashlib.sha256(json.dumps(receipt['source_hashes'],sort_keys=True).encode()).hexdigest()
137+
receipt['library_hashes']={str(p):digest(p) for p in (prefix/'lib').glob('libhttpserver.*') if p.is_file()}
138+
require(Path(commands(['pkg-config','--variable=pcfiledir','libhttpserver'],args.output,env).strip()).resolve()==prefix/'lib/pkgconfig','installed pkg-config identity')
139+
cflags=shlex.split(commands(['pkg-config','--cflags','libhttpserver'],args.output,env))
140+
require(not audit.consumer_flag_violations(cflags,(source,build)),'compile flag contamination')
141+
directory=Path(tempfile.mkdtemp(prefix='task196-installed-')); receipt['consumer_directory']=str(directory)
142+
for name in ('test/installed/v3_http3_consumer.cpp','examples/v3_host_poll.hpp'): shutil.copyfile(source/name,directory/Path(name).name)
143+
fixtures=directory/'fixtures';shutil.copytree(source/'test/data/tls_credentials',fixtures)
144+
receipt['fixture_hashes']={p.name:digest(p) for p in fixtures.iterdir() if p.is_file()}
145+
for linkage in ('shared','static'):
146+
libraries=shlex.split(commands(['pkg-config','--libs']+(['--static'] if linkage=='static' else [])+['libhttpserver'],directory,env))
147+
if linkage=='static':libraries=[str(prefix/'lib/libhttpserver.a') if f=='-lhttpserver' else f for f in libraries]
148+
libraries += ['-pthread','-Wl,-rpath,'+str(prefix/'lib'),'-Wl,-rpath,'+str(provider/'lib')]+shlex.split(args.sanitize)
149+
executable=directory/linkage
150+
provenance=compile_probe(args.compiler,[directory/'v3_http3_consumer.cpp'],cflags+shlex.split(args.sanitize),libraries,executable,commands,env,source,build,prefix,False)
151+
graph=audit.dependency_graph([str(executable)]);check_linkage(graph,linkage,prefix)
152+
require(not audit.graph_violations(graph,'strict',args.tls,'aggregate',audit.load_policy(),provider),'dependency graph violations')
153+
for loop in ('managed','external'):
154+
out=args.output/(linkage+'-'+loop);out.mkdir()
155+
cell=dict(linkage=linkage,loop=loop,compile=provenance,dependencies=graph);receipt['cells'].append(cell)
156+
cell['clients'],cell['configuration']=scenarios(executable,loop,args,out,env,fixtures,commands,client)
157+
cell['status']='passed';print('PASS installed H3',args.tls,linkage,loop,flush=True)
158+
receipt['status']='passed'
159+
if args.tls=='yes':validate_matrix(receipt)
160+
except BaseException as error:
161+
receipt.update(status='failed',error=str(error));raise
162+
finally:(args.output/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n')
163+
164+
if __name__=='__main__':main()

‎scripts/lib/v3_package_matrix.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def configuration_results(output, inventory, loop="managed", *, tls):
201201
results = {}
202202
for name, outcome, binding in rows:
203203
expected = ('ok', 'bound') if name == 'plaintext' else ('not_supported', 'unbound') if name in (
204-
'http3', 'mixed_rejection') or (name in ('tls_listener', 'http2') and tls == 'no') else ('invalid_argument', 'unbound')
204+
'tls_listener', 'http2') and tls == 'no' else ('invalid_argument', 'unbound')
205205
if name in results or (outcome, binding) != expected:
206206
raise ValueError('configuration outcome diverged: ' + name)
207207
results[name] = {'outcome': outcome, 'binding': binding}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Exact basic HTTP/3 product predicate, independent of fixture success."""
2+
import hashlib
3+
from pathlib import Path
4+
from v3_package_matrix import check_dependencies, check_linkage, check_scenarios
5+
6+
CLIENTS = {'aioquic':'1.3.0','quic-go':'v0.55.0'}
7+
BASIC = dict(authenticated_get_trailers=dict(body='hello native h3\n'), unknown_route={},
8+
incremental_upload_request_trailers=dict(body='incremental installed native h3 upload'),
9+
tls_peer_metadata={}, parallel_running_handler={}, parallel_suspended_upload={},
10+
reset_stream_local_once=dict(cancellation_entries=1))
11+
ONESHOTS = {'listener-storage-limit':'LIMIT_H3_UNBOUND','no-udp':'PREBIND_H3_REJECTED no-udp','no-tls':'PREBIND_H3_REJECTED no-tls',
12+
'no-h3':'PREBIND_H3_REJECTED no-h3','invalid-transport':'PREBIND_H3_REJECTED invalid-transport',
13+
'wrong-store':'PREBIND_H3_REJECTED wrong-store','psk':'PSK_H3_REJECTED',
14+
'rollback':'ROLLBACK_H3_UNBOUND rollback','rollback-reverse':'ROLLBACK_H3_UNBOUND rollback-reverse'}
15+
16+
CONFIGURATION = list(ONESHOTS) + [case for c in CLIENTS for case in (c+'_bad_initial_listener_recovery',c+'_mixed_tcp_udp_same_port')] + ['stalled_handshake_capacity_recovery','peer_policy_before_tls','header_timeout_and_recovery','failed_handshake_and_recovery','active_stream_server_destruction','quic_go_active_stream_server_destruction']
17+
18+
def validate_client(receipt):
19+
if receipt.get('client') not in CLIENTS or receipt.get('version') != CLIENTS[receipt['client']]:
20+
raise ValueError('independent pinned client identity absent')
21+
for key, value in dict(alpn='h3',verified=True,quic_version=1,tls_version='TLSv1.3',early_data=False,retry_count=1).items():
22+
if type(receipt.get(key)) is not type(value) or receipt[key] != value:
23+
raise ValueError('authenticated protocol witness differs: '+key)
24+
rows=receipt.get('scenarios',[])
25+
check_scenarios(rows,BASIC)
26+
for row in rows:
27+
for key,value in BASIC[row['case']].items():
28+
if row.get(key) != value: raise ValueError('wrong public route observation: '+key)
29+
30+
def validate_matrix(receipt):
31+
if (receipt.get('predicate'),receipt.get('status'),receipt.get('tls')) != ('installed-public-http3','passed','yes'):
32+
raise ValueError('public H3 requires successful installed TLS-on matrix')
33+
cells=receipt.get('cells',[])
34+
if len(cells)!=4 or {(c.get('linkage'),c.get('loop')) for c in cells}!={(a,b) for a in ('shared','static') for b in ('managed','external')}:
35+
raise ValueError('missing/duplicate installed H3 cells')
36+
prefix,source,build=[Path(receipt[k]) for k in ('prefix','source','build')]
37+
for name,digest in receipt['library_hashes'].items():
38+
if hashlib.sha256(Path(name).read_bytes()).hexdigest()!=digest: raise ValueError('installed library changed')
39+
for name,digest in receipt['source_hashes'].items():
40+
if hashlib.sha256((source/name).read_bytes()).hexdigest()!=digest: raise ValueError('covered source changed: '+name)
41+
for cell in cells:
42+
if cell.get('status')!='passed' or cell['compile'].get('private_test_headers') is not False:
43+
raise ValueError('failed/private product witness')
44+
check_dependencies(cell['compile']['dependencies'],prefix,source,build)
45+
check_linkage(cell['dependencies'],cell['linkage'],prefix)
46+
check_scenarios(cell.get('configuration',[]),CONFIGURATION)
47+
clients=cell.get('clients',[])
48+
if len(clients)!=2 or {r.get('client') for r in clients}!=set(CLIENTS): raise ValueError('missing/duplicate independent client')
49+
for client in clients: validate_client(client)

0 commit comments

Comments
 (0)