Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/waa.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,12 @@ HTTP/2 实现为 [Mag1cFall/fhttp](https://github.com/Mag1cFall/fhttp) v0.6.8-fi
- QUIC 首航与 neqo 0.28 相同:DCID 长度与首个包号按 neqo 取随机值,ClientHello 在 SNI 中点切开分到前两个 Initial 包,数据报补零到 1252 字节(IPv6 为 1232),不做 PMTU 探测
- ClientHello 为 13 个随机排列的扩展,其后依次为 QUIC 传输参数、ECH GREASE 与恢复会话时的 `pre_shared_key`;X25519MLKEM768 与 X25519 共用一把 X25519 密钥,ECH GREASE 长度按 NSS 由内层 ClientHello 的长度补齐得出
- 传输参数与 Firefox 默认值相同:空闲超时 30 秒、连接接收窗口 25165824、本端双向流窗口 12582912、对端双向流与单向流窗口 1048576、各 100 条流、`max_ack_delay` 20、连接 ID 上限 8、`min_ack_delay` 1000 微秒与 `max_datagram_frame_size` 65535
- 与 neqo 一样,有请求进行中时,自最近一次收到数据包或其后首次发出数据起经过空闲超时的一半仍没有收发,就发送一个保活 PING;没有请求时连接按空闲超时关闭
- 控制流 SETTINGS 依次为 QPACK 动态表容量 65536、阻塞流 20、WebTransport、两个 H3 DATAGRAM 设置与扩展 CONNECT,其后跟一个保留类型的 GREASE 帧;QPACK 按 neqo 把静态表中没有的头名插入动态表,字符串一律 Huffman 编码,并回送 Section Acknowledgment 与 Insert Count Increment
- 每类连接保存会话票据与服务端下发的 `NEW_TOKEN`。之后的连接带令牌与 `pre_shared_key` 恢复会话;票据允许 0-RTT 时 ClientHello 带 `early_data`,控制流与 QPACK 流在 0-RTT 中发出并沿用上次的服务端 QPACK 设置;GET、HEAD、OPTIONS 等安全方法的请求在 0-RTT 中发出,其余请求等握手完成;服务端拒绝 0-RTT 时重新打开控制流与 QPACK 流,并重发 0-RTT 中的请求
- 恢复会话的首航与 Firefox 相同:第三个 Initial 数据报在 Initial 包之后带首个 0-RTT 包(控制流与 QPACK 流),再补零到 1252 字节

QUIC 与 TLS 实现为 [Mag1cFall/uquic](https://github.com/Mag1cFall/uquic) v0.0.6-firefox.4 与 [Mag1cFall/utls](https://github.com/Mag1cFall/utls) v1.8.2-firefox.3,分别基于 refraction-networking 的 uquic 与 utls;utls 分叉在自定义 ClientHello 恢复会话时保留 `early_data`,握手中途关闭连接时结束握手;uquic 分叉在 1-RTT 超时探测没有可发数据时发 PING,并把恢复会话的首个 0-RTT 包并入首航最后一个 Initial 数据报。HTTP/3 与 QPACK 由服务自行实现。
QUIC 与 TLS 实现为 [Mag1cFall/uquic](https://github.com/Mag1cFall/uquic) v0.0.6-firefox.6 与 [Mag1cFall/utls](https://github.com/Mag1cFall/utls) v1.8.2-firefox.3,分别基于 refraction-networking 的 uquic 与 utls;utls 分叉在自定义 ClientHello 恢复会话时保留 `early_data`,握手中途关闭连接时结束握手;uquic 分叉在 1-RTT 超时探测没有可发数据时发 PING,把恢复会话的首个 0-RTT 包并入首航最后一个 Initial 数据报,并提供按 neqo 只在请求流进行中保活的选项。HTTP/3 与 QPACK 由服务自行实现。

`Origin` 与目标主机不同的请求先发 CORS 预检:`OPTIONS` 带 `Accept: */*`、`Access-Control-Request-Method`、按字母序以逗号连接的小写 `Access-Control-Request-Headers`(页面脚本设置的头)、原请求的 `User-Agent`、`Accept-Language`、`Accept-Encoding`、`Referer`、`Origin` 与 `Sec-Fetch-*`,以及 `Priority: u=4`,不带 Cookie。预检按方法、URL 与请求头集合缓存,有效期取响应的 `Access-Control-Max-Age`(缺省 5 秒,上限 24 小时);非 2xx 预检使原请求失败。

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.27

require (
github.com/Mag1cFall/fhttp v0.6.8-firefox.1
github.com/Mag1cFall/uquic v0.0.6-firefox.4
github.com/Mag1cFall/uquic v0.0.6-firefox.6
github.com/Mag1cFall/utls v1.8.2-firefox.3
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.15.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
github.com/Mag1cFall/fhttp v0.6.8-firefox.1 h1:7AMOM2KCVwfOD0mr/CayGHlrekbcJ1QlSfxK38GHuQw=
github.com/Mag1cFall/fhttp v0.6.8-firefox.1/go.mod h1:1KQKGvAc98d+20flM1QVrKuupd7hdOw2tPPZDNLmWi4=
github.com/Mag1cFall/uquic v0.0.6-firefox.4 h1:UEFL2ewKtwHEOtI8AErc5USWHCb4laHjiIewqawxEM8=
github.com/Mag1cFall/uquic v0.0.6-firefox.4/go.mod h1:JHH4tzlX5KM0a4TJSglkbL2kW1hdVBenlA0YFKD8o0E=
github.com/Mag1cFall/uquic v0.0.6-firefox.6 h1:AvAWHvAMST2ffRJb+LKTWv8+Gz0u6JfzGxcXuXxOPFI=
github.com/Mag1cFall/uquic v0.0.6-firefox.6/go.mod h1:JHH4tzlX5KM0a4TJSglkbL2kW1hdVBenlA0YFKD8o0E=
github.com/Mag1cFall/utls v1.8.2-firefox.3 h1:5DGK0Ps9Kew+pd1E0d96YeirOdk4AOHJmx+NeNJwRxM=
github.com/Mag1cFall/utls v1.8.2-firefox.3/go.mod h1:HFKOiYks7qa1VvvhZBWu7Ic9tTYm1xNXHKiRcpw3Mtc=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
Expand Down
33 changes: 20 additions & 13 deletions internal/aistudio/transport_quic.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,28 @@ func dialFirefoxQUIC(ctx context.Context, host string, resumption *quicResumptio
MinVersion: utls.VersionTLS13,
ClientSessionCache: resumption.sessions,
OmitEmptyPsk: true,
}, &quic.Config{
}, firefoxQUICConfig(datagram, resumption.tokens))
if err != nil {
_ = transport.Close()
_ = socket.Close()
return nil, nil, err
}
go func() {
<-connection.Context().Done()
_ = transport.Close()
_ = socket.Close()
}()
return connection, settings, nil
}

// firefoxQUICConfig 返回 Firefox 152 的 QUIC 连接配置:空闲超时 30 秒,与 neqo 一样只在请求流尚未完成时每半个空闲超时发送保活 PING
func firefoxQUICConfig(datagram int, tokens quic.TokenStore) *quic.Config {
return &quic.Config{
Versions: []quic.Version{quic.Version1},
HandshakeIdleTimeout: firefoxQUICIdleTimeout,
MaxIdleTimeout: firefoxQUICIdleTimeout,
KeepAlivePeriod: firefoxQUICIdleTimeout / 2,
KeepAliveStreamsOnly: true,
InitialStreamReceiveWindow: firefoxQUICMaxStreamData,
MaxStreamReceiveWindow: firefoxQUICMaxStreamData,
InitialConnectionReceiveWindow: firefoxQUICMaxData,
Expand All @@ -138,19 +156,8 @@ func dialFirefoxQUIC(ctx context.Context, host string, resumption *quicResumptio
MaxIncomingUniStreams: neqoMaxStreams,
InitialPacketSize: uint16(datagram),
DisablePathMTUDiscovery: true,
TokenStore: resumption.tokens,
})
if err != nil {
_ = transport.Close()
_ = socket.Close()
return nil, nil, err
TokenStore: tokens,
}
go func() {
<-connection.Context().Done()
_ = transport.Close()
_ = socket.Close()
}()
return connection, settings, nil
}

// firefoxQUICSpec 返回 Firefox 152(neqo 0.28)一次直连的 QUIC 首航:随机 DCID 长度与首个包号、SNI 切分的 CRYPTO 帧、Initial 数据报补零到 datagram 字节,psk 与 early 决定 ClientHello 是否带 pre_shared_key 与 early_data
Expand Down
126 changes: 126 additions & 0 deletions internal/aistudio/transport_quic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package aistudio

import (
"bufio"
"context"
"io"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

fhttp "github.com/Mag1cFall/fhttp"
quic "github.com/Mag1cFall/uquic"
utls "github.com/Mag1cFall/utls"
)

// TestHTTP3SlowResponse 验证上游超过空闲超时才返回响应头或响应体时,请求流未完成期间的保活使连接不断开,POST 只发一次
func TestHTTP3SlowResponse(t *testing.T) {
for _, flushHeaders := range []bool{false, true} {
name := "headers"
if flushHeaders {
name = "body"
}
t.Run(name, func(t *testing.T) {
var requests atomic.Int32
conn := newTestHTTP3Conn(t, func(stream *quic.Stream) {
requests.Add(1)
block := newQPACKEncoder().encode([]qpackField{{":status", "200"}}, int64(stream.StreamID()))
headers := appendH3Frame(nil, h3FrameHeaders, block)
if flushHeaders {
_, _ = stream.Write(headers)
}
time.Sleep(1500 * time.Millisecond)
if !flushHeaders {
_, _ = stream.Write(headers)
}
_, _ = stream.Write(appendH3Frame(nil, h3FrameData, []byte("ok")))
_ = stream.Close()
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, err := fhttp.NewRequestWithContext(ctx, "POST", "https://localhost/", strings.NewReader("test"))
if err != nil {
t.Fatal(err)
}
response, opened, err := conn.roundTrip(request, h3RequestFields(request, false))
if err != nil || !opened {
t.Fatalf("等待慢响应失败: opened=%v err=%v", opened, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil || string(body) != "ok" || response.StatusCode != 200 {
t.Fatalf("响应=%q 状态=%d err=%v", body, response.StatusCode, err)
}
if requests.Load() != 1 {
t.Fatalf("POST 执行了 %d 次,应为 1 次", requests.Load())
}
})
}
}

// newTestHTTP3Conn 返回连到本地 QUIC 服务的 HTTP/3 连接,连接使用生产 QUIC 配置并把空闲超时改为 500 ms,handler 处理正文为 test 的请求流
func newTestHTTP3Conn(t *testing.T, handler func(*quic.Stream)) *http3Conn {
t.Helper()
certServer := httptest.NewTLSServer(nil)
t.Cleanup(certServer.Close)
cert := certServer.TLS.Certificates[0]
tlsConfig := &utls.Config{
Certificates: []utls.Certificate{{Certificate: cert.Certificate, PrivateKey: cert.PrivateKey}},
NextProtos: []string{"h3"},
}
listener, err := quic.ListenAddr("127.0.0.1:0", tlsConfig, &quic.Config{MaxIdleTimeout: 500 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
done := make(chan struct{})
go func() {
defer close(done)
connection, err := listener.Accept(ctx)
if err != nil {
return
}
defer connection.CloseWithError(0, "")
var handlers sync.WaitGroup
defer handlers.Wait()
for {
stream, err := connection.AcceptStream(ctx)
if err != nil {
return
}
handlers.Add(1)
go func() {
defer handlers.Done()
reader := bufio.NewReader(stream)
if kind, _, err := readH3Frame(reader); err != nil || kind != h3FrameHeaders {
return
}
if kind, body, err := readH3Frame(reader); err == nil && kind == h3FrameData && string(body) == "test" {
handler(stream)
}
}()
}
}()
t.Cleanup(func() {
cancel()
_ = listener.Close()
<-done
})
config := firefoxQUICConfig(neqoIPv4Datagram, nil)
config.MaxIdleTimeout = 500 * time.Millisecond
connection, err := quic.DialAddr(ctx, listener.Addr().String(), &utls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = connection.CloseWithError(0, "") })
conn, err := newHTTP3Conn(connection, nil, func(uint64, uint64) {})
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.close)
return conn
}
Loading