blob: 5b652a2b155e09fc93032d8a949716020b92dcbb [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.21
// The interop command is the client and server used by QUIC interoperability tests.
//
// https://github.com/marten-seemann/quic-interop-runner
package main
import (
"bytes"
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log"
"log/slog"
"net"
"net/url"
"os"
"path/filepath"
"sync"
"golang.org/x/net/quic"
"golang.org/x/net/quic/qlog"
)
var (
listen = flag.String("listen", "", "listen address")
cert = flag.String("cert", "", "certificate")
pkey = flag.String("key", "", "private key")
root = flag.String("root", "", "serve files from this root")
output = flag.String("output", "", "directory to write files to")
qlogdir = flag.String("qlog", "", "directory to write qlog output to")
)
func main() {
ctx := context.Background()
flag.Parse()
urls := flag.Args()
config := &quic.Config{
TLSConfig: &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS13,
NextProtos: []string{"hq-interop"},
},
MaxBidiRemoteStreams: -1,
MaxUniRemoteStreams: -1,
QLogLogger: slog.New(qlog.NewJSONHandler(qlog.HandlerOptions{
Level: quic.QLogLevelFrame,
Dir: *qlogdir,
})),
}
if *cert != "" {
c, err := tls.LoadX509KeyPair(*cert, *pkey)
if err != nil {
log.Fatal(err)
}
config.TLSConfig.Certificates = []tls.Certificate{c}
}
if *root != "" {
config.MaxBidiRemoteStreams = 100
}
if keylog := os.Getenv("SSLKEYLOGFILE"); keylog != "" {
f, err := os.Create(keylog)
if err != nil {
log.Fatal(err)
}
defer f.Close()
config.TLSConfig.KeyLogWriter = f
}
testcase := os.Getenv("TESTCASE")
switch testcase {
case "handshake", "keyupdate":
basicTest(ctx, config, urls)
return
case "chacha20":
// "[...] offer only ChaCha20 as a ciphersuite."
//
// crypto/tls does not support configuring TLS 1.3 ciphersuites,
// so we can't support this test.
case "transfer":
// "The client should use small initial flow control windows
// for both stream- and connection-level flow control
// such that the during the transfer of files on the order of 1 MB
// the flow control window needs to be increased."
config.MaxStreamReadBufferSize = 64 << 10
config.MaxConnReadBufferSize = 64 << 10
basicTest(ctx, config, urls)
return
case "http3":
// TODO
case "multiconnect":
// TODO
case "resumption":
// TODO
case "retry":
// TODO
case "versionnegotiation":
// "The client should start a connection using
// an unsupported version number [...]"
//
// We don't support setting the client's version,
// so only run this test as a server.
if *listen != "" && len(urls) == 0 {
basicTest(ctx, config, urls)
return
}
case "v2":
// We do not support QUIC v2.
case "zerortt":
// TODO
}
fmt.Printf("unsupported test case %q\n", testcase)
os.Exit(127)
}
// basicTest runs the standard test setup.
//
// As a server, it serves the contents of the -root directory.
// As a client, it downloads all the provided URLs in parallel,
// making one connection to each destination server.
func basicTest(ctx context.Context, config *quic.Config, urls []string) {
l, err := quic.Listen("udp", *listen, config)
if err != nil {
log.Fatal(err)
}
log.Printf("listening on %v", l.LocalAddr())
byAuthority := map[string][]*url.URL{}
for _, s := range urls {
u, addr, err := parseURL(s)
if err != nil {
log.Fatal(err)
}
byAuthority[addr] = append(byAuthority[addr], u)
}
var g sync.WaitGroup
defer g.Wait()
for addr, u := range byAuthority {
addr, u := addr, u
g.Add(1)
go func() {
defer g.Done()
fetchFrom(ctx, config, l, addr, u)
}()
}
if config.MaxBidiRemoteStreams >= 0 {
serve(ctx, l)
}
}
func serve(ctx context.Context, l *quic.Endpoint) error {
for {
c, err := l.Accept(ctx)
if err != nil {
return err
}
go serveConn(ctx, c)
}
}
func serveConn(ctx context.Context, c *quic.Conn) {
for {
s, err := c.AcceptStream(ctx)
if err != nil {
return
}
go func() {
if err := serveReq(ctx, s); err != nil {
log.Print("serveReq:", err)
}
}()
}
}
func serveReq(ctx context.Context, s *quic.Stream) error {
defer s.Close()
req, err := io.ReadAll(s)
if err != nil {
return err
}
if !bytes.HasSuffix(req, []byte("\r\n")) {
return errors.New("invalid request")
}
req = bytes.TrimSuffix(req, []byte("\r\n"))
if !bytes.HasPrefix(req, []byte("GET /")) {
return errors.New("invalid request")
}
req = bytes.TrimPrefix(req, []byte("GET /"))
if !filepath.IsLocal(string(req)) {
return errors.New("invalid request")
}
f, err := os.Open(filepath.Join(*root, string(req)))
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(s, f)
return err
}
func parseURL(s string) (u *url.URL, authority string, err error) {
u, err = url.Parse(s)
if err != nil {
return nil, "", err
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
authority = net.JoinHostPort(host, port)
return u, authority, nil
}
func fetchFrom(ctx context.Context, config *quic.Config, l *quic.Endpoint, addr string, urls []*url.URL) {
conn, err := l.Dial(ctx, "udp", addr, config)
if err != nil {
log.Printf("%v: %v", addr, err)
return
}
log.Printf("connected to %v", addr)
defer conn.Close()
var g sync.WaitGroup
for _, u := range urls {
u := u
g.Add(1)
go func() {
defer g.Done()
if err := fetchOne(ctx, conn, u); err != nil {
log.Printf("fetch %v: %v", u, err)
} else {
log.Printf("fetched %v", u)
}
}()
}
g.Wait()
}
func fetchOne(ctx context.Context, conn *quic.Conn, u *url.URL) error {
if len(u.Path) == 0 || u.Path[0] != '/' || !filepath.IsLocal(u.Path[1:]) {
return errors.New("invalid path")
}
file, err := os.Create(filepath.Join(*output, u.Path[1:]))
if err != nil {
return err
}
s, err := conn.NewStream(ctx)
if err != nil {
return err
}
defer s.Close()
if _, err := s.Write([]byte("GET " + u.Path + "\r\n")); err != nil {
return err
}
s.CloseWrite()
if _, err := io.Copy(file, s); err != nil {
return err
}
return nil
}