mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
330 lines
10 KiB
Go
330 lines
10 KiB
Go
package ctrld
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/quic-go/quic-go"
|
|
"github.com/quic-go/quic-go/http3"
|
|
)
|
|
|
|
var transportReplacements = []struct {
|
|
name string
|
|
run func(*UpstreamConfig)
|
|
}{
|
|
{"SetupTransport", (*UpstreamConfig).SetupTransport},
|
|
{"ForceReBootstrap", (*UpstreamConfig).ForceReBootstrap},
|
|
}
|
|
|
|
// Accept multiple connections so a replacement must answer on the same real
|
|
// endpoint, while the original query is still held by the server.
|
|
func replacementDOTServer(t *testing.T) (*UpstreamConfig, <-chan struct{}, func(), <-chan struct{}) {
|
|
t.Helper()
|
|
cert := generateTestCertificate(t)
|
|
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert.tlsCert}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entered, release, peerClosed, done := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
var once sync.Once
|
|
unblock := func() { once.Do(func() { close(release) }) }
|
|
var mu sync.Mutex
|
|
var conns []net.Conn
|
|
var wg sync.WaitGroup
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns = append(conns, c)
|
|
mu.Unlock()
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer c.Close()
|
|
dc := &dns.Conn{Conn: c}
|
|
for {
|
|
q, err := dc.ReadMsg()
|
|
if err != nil {
|
|
return
|
|
}
|
|
held := q.Question[0].Name == "held.example."
|
|
if held {
|
|
close(entered)
|
|
defer close(peerClosed)
|
|
<-release
|
|
}
|
|
a := new(dns.Msg)
|
|
a.SetReply(q)
|
|
if err := dc.WriteMsg(a); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
roots := x509.NewCertPool()
|
|
roots.AddCert(cert.cert)
|
|
uc := &UpstreamConfig{Type: ResolverTypeDOT, Endpoint: ln.Addr().String(), BootstrapIP: "127.0.0.1", Domain: "localhost", certPool: roots}
|
|
uc.ensureSetupTransport()
|
|
t.Cleanup(func() {
|
|
unblock()
|
|
uc.CloseTransports()
|
|
_ = ln.Close()
|
|
retirementWait(t, done, "DoT accept loop cleanup") // No more wg.Add after this.
|
|
mu.Lock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
mu.Unlock()
|
|
wg.Wait()
|
|
})
|
|
return uc, entered, unblock, peerClosed
|
|
}
|
|
|
|
func replacementAnswer(t *testing.T, resolve func(context.Context, *dns.Msg) (*dns.Msg, error)) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
q := retirementQuery()
|
|
a, err := resolve(ctx, q)
|
|
if err != nil {
|
|
t.Fatalf("replacement query failed: %v", err)
|
|
}
|
|
if a == nil || !a.Response || a.Id != q.Id || len(a.Question) != 1 || a.Question[0] != q.Question[0] {
|
|
t.Fatalf("replacement returned wrong DNS answer: %v", a)
|
|
}
|
|
}
|
|
|
|
func replacementResult(t *testing.T, result <-chan error) error {
|
|
t.Helper()
|
|
select {
|
|
case err := <-result:
|
|
return err
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("old query survived replacement")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func TestReplaceTransportsDoTActive(t *testing.T) {
|
|
for _, replace := range transportReplacements {
|
|
t.Run(replace.name, func(t *testing.T) {
|
|
uc, entered, release, peerClosed := replacementDOTServer(t)
|
|
old := uc.dotClientPool
|
|
selected := make(chan struct{})
|
|
old.dialer.ControlContext = func(context.Context, string, string, syscall.RawConn) error {
|
|
close(selected)
|
|
return nil
|
|
}
|
|
r := &dotResolver{uc: uc}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
q := new(dns.Msg)
|
|
q.SetQuestion("held.example.", dns.TypeA)
|
|
result := make(chan error, 1)
|
|
go func() { _, err := r.Resolve(ctx, q); result <- err }()
|
|
retirementWait(t, selected, "DoT transport selection")
|
|
retirementWait(t, entered, "active DoT query")
|
|
replace.run(uc)
|
|
if uc.dotClientPool == old {
|
|
t.Fatal("DoT pool was not replaced")
|
|
}
|
|
if err := replacementResult(t, result); !errors.Is(err, net.ErrClosed) {
|
|
t.Fatalf("old query did not fail from socket retirement: %v", err)
|
|
}
|
|
// This answer must arrive before releasing the old server handler.
|
|
replacementAnswer(t, r.Resolve)
|
|
release()
|
|
retirementWait(t, peerClosed, "old DoT peer closure")
|
|
if _, err := old.Resolve(ctx, retirementQuery()); !errors.Is(err, net.ErrClosed) {
|
|
t.Fatalf("late query revived replaced DoT pool: %v", err)
|
|
}
|
|
replacementAnswer(t, r.Resolve)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplaceTransportsDoTLateReturn(t *testing.T) {
|
|
for _, replace := range transportReplacements {
|
|
for _, slot := range []string{"default", "v4", "v6", "aliased"} {
|
|
t.Run(replace.name+"/"+slot, func(t *testing.T) {
|
|
uc, _, _, _ := replacementDOTServer(t)
|
|
old := uc.dotClientPool
|
|
r := &dotResolver{uc: uc}
|
|
replacementAnswer(t, r.Resolve)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
conn, err := old.getConn(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Model each old slot, including the IPv6-to-IPv4 alias, without
|
|
// requiring an external IPv6 capability probe in this fixture.
|
|
switch slot {
|
|
case "v4":
|
|
uc.dotClientPool, uc.dotClientPool4 = nil, old
|
|
case "v6":
|
|
uc.dotClientPool, uc.dotClientPool6 = nil, old
|
|
case "aliased":
|
|
uc.dotClientPool4, uc.dotClientPool6 = old, old
|
|
}
|
|
replace.run(uc)
|
|
// A successful borrower returning after the slots change must not
|
|
// put its connection back into the now-orphaned pool.
|
|
old.putConn(conn, true)
|
|
if len(old.conns) != 0 {
|
|
t.Fatal("late return repopulated replaced DoT pool")
|
|
}
|
|
if _, err := old.getConn(ctx); !errors.Is(err, net.ErrClosed) {
|
|
t.Fatalf("replaced DoT pool accepted late work: %v", err)
|
|
}
|
|
replacementAnswer(t, r.Resolve)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReplaceTransportsDoH3IdleAndLateWork(t *testing.T) {
|
|
for _, replace := range transportReplacements {
|
|
t.Run(replace.name, func(t *testing.T) {
|
|
srv := newTestHTTP3Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
roots := x509.NewCertPool()
|
|
roots.AddCert(srv.cert)
|
|
uc := &UpstreamConfig{Type: ResolverTypeDOH3, Endpoint: "https://" + srv.addr, BootstrapIP: "127.0.0.1", certPool: roots}
|
|
defer uc.CloseTransports()
|
|
// The real warmup path initializes the transport and leaves it idle.
|
|
if err := uc.ErrorPing(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := uc.http3RoundTripper
|
|
defer old.(io.Closer).Close()
|
|
replace.run(uc)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
req, _ := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
|
|
resp, err := old.RoundTrip(req)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil || ctx.Err() != nil {
|
|
t.Fatalf("late work revived idle replaced HTTP/3 transport: %v (context: %v)", err, ctx.Err())
|
|
}
|
|
if err := uc.ErrorPing(); err != nil {
|
|
t.Fatalf("replacement HTTP/3 transport is not usable: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplaceTransportsDoH3ActiveAndLateWork(t *testing.T) {
|
|
for _, replace := range transportReplacements {
|
|
for _, slot := range []string{"default", "v4", "v6", "aliased"} {
|
|
t.Run(replace.name+"/"+slot, func(t *testing.T) {
|
|
entered, release, peerCanceled := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
srv := newTestHTTP3Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/late" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
data, err := base64.RawURLEncoding.DecodeString(r.URL.Query().Get("dns"))
|
|
q := new(dns.Msg)
|
|
if err != nil || q.Unpack(data) != nil || len(q.Question) != 1 {
|
|
http.Error(w, "bad DNS query", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if q.Question[0].Name == "held.example." {
|
|
close(entered)
|
|
select {
|
|
case <-r.Context().Done():
|
|
close(peerCanceled)
|
|
case <-release:
|
|
}
|
|
return
|
|
}
|
|
a := new(dns.Msg)
|
|
a.SetReply(q)
|
|
data, _ = a.Pack()
|
|
w.Header().Set("Content-Type", headerApplicationDNS)
|
|
_, _ = w.Write(data)
|
|
}))
|
|
defer close(release)
|
|
roots := x509.NewCertPool()
|
|
roots.AddCert(srv.cert)
|
|
u, err := url.Parse("https://" + srv.addr + "/dns-query")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc := &UpstreamConfig{Type: ResolverTypeDOH3, Endpoint: u.String(), u: u, BootstrapIP: "127.0.0.1", certPool: roots}
|
|
uc.ensureSetupTransport()
|
|
defer uc.CloseTransports()
|
|
old := uc.http3RoundTripper
|
|
// Clean the old transport even if a regression leaves it orphaned.
|
|
defer old.(io.Closer).Close()
|
|
selected := make(chan struct{})
|
|
rt := old.(*http3.Transport)
|
|
dial := rt.Dial
|
|
var selectedOnce sync.Once
|
|
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
|
selectedOnce.Do(func() { close(selected) })
|
|
return dial(ctx, addr, tlsCfg, cfg)
|
|
}
|
|
r := newDohResolver(uc)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
q := new(dns.Msg)
|
|
q.SetQuestion("held.example.", dns.TypeA)
|
|
result := make(chan error, 1)
|
|
go func() { _, err := r.Resolve(ctx, q); result <- err }()
|
|
// A network exchange alone isn't a Go happens-before edge. Observe
|
|
// selection before writing slots; still execute the production dial.
|
|
retirementWait(t, selected, "HTTP/3 transport selection")
|
|
retirementWait(t, entered, "active HTTP/3 DNS query")
|
|
switch slot {
|
|
case "v4":
|
|
uc.http3RoundTripper, uc.http3RoundTripper4 = nil, old
|
|
case "v6":
|
|
uc.http3RoundTripper, uc.http3RoundTripper6 = nil, old
|
|
case "aliased":
|
|
uc.http3RoundTripper4, uc.http3RoundTripper6 = old, old
|
|
}
|
|
replace.run(uc)
|
|
if uc.http3RoundTripper == old {
|
|
t.Fatal("HTTP/3 transport was not replaced")
|
|
}
|
|
retirementWait(t, peerCanceled, "old HTTP/3 request cancellation")
|
|
if err := replacementResult(t, result); err == nil || ctx.Err() != nil {
|
|
t.Fatalf("old HTTP/3 query not aborted by replacement: %v (context: %v)", err, ctx.Err())
|
|
}
|
|
replacementAnswer(t, r.Resolve)
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", "https://"+srv.addr+"/late", nil)
|
|
resp, err := old.RoundTrip(req)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil || ctx.Err() != nil {
|
|
t.Fatalf("late request revived replaced HTTP/3 transport: %v (context: %v)", err, ctx.Err())
|
|
}
|
|
replacementAnswer(t, r.Resolve)
|
|
})
|
|
}
|
|
}
|
|
}
|