Просмотр исходного кода

QUIC/UDP updates

- In quic.Dial, support non-*net.UDPConn packet conns. This allows for wrapped
  and artificial net.PacketConn transports.

- Clarify state of QUIC ECN bit processing. Guard against accidental future
  exposure of potential ECN fingerprint in obfuscated QUIC connections
  (client-side/upstream only).

- Update UDPConn to use same Control method as TCPConn; add an explicit check
  that the dialed conn is a *net.UDPConn, as required to support QUIC ECN
  operations; and document behavior of non-bound UDP sockets.

- Add UDP write deadline fix to QUICTransport (FRONTED-QUIC) case, missed in
  https://github.com/Psiphon-Labs/psiphon-tunnel-core/commit/b8c5c1948368f64a4191a3b525abfcc71d629429.
Rod Hynes 3 лет назад
Родитель
Сommit
b5fcedc4d5
5 измененных файлов с 216 добавлено и 213 удалено
  1. 62 14
      psiphon/UDPConn.go
  2. 0 65
      psiphon/UDPConn_bind.go
  3. 0 44
      psiphon/UDPConn_nobind.go
  4. 83 42
      psiphon/common/quic/obfuscator.go
  5. 71 48
      psiphon/common/quic/quic.go

+ 62 - 14
psiphon/UDPConn.go

@@ -29,15 +29,16 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 )
 
-// NewUDPConn resolves addr and configures a new UDP conn. The UDP socket is
-// created using options in DialConfig, including DeviceBinder. The returned
-// UDPAddr uses DialConfig options IPv6Synthesizer and ResolvedIPCallback.
+// NewUDPConn resolves addr and configures a new *net.UDPConn. The UDP socket
+// is created using options in DialConfig, including DeviceBinder. The
+// returned UDPAddr uses DialConfig options IPv6Synthesizer and
+// ResolvedIPCallback.
 //
 // The UDP conn is not dialed; it is intended for use with WriteTo using the
 // returned UDPAddr, not Write.
 //
-// The returned conn is not a Closer; the caller is expected to wrap this conn
-// with another higher-level conn that provides that interface.
+// The returned conn is not a common.Closer; the caller is expected to wrap
+// this conn with another higher-level conn that provides that interface.
 func NewUDPConn(
 	ctx context.Context, addr string, config *DialConfig) (net.PacketConn, *net.UDPAddr, error) {
 
@@ -81,23 +82,70 @@ func NewUDPConn(
 		}
 	}
 
-	var domain int
-	if ipAddr != nil && ipAddr.To4() != nil {
-		domain = syscall.AF_INET
-	} else if ipAddr != nil && ipAddr.To16() != nil {
-		domain = syscall.AF_INET6
-	} else {
-		return nil, nil, errors.Tracef("invalid IP address: %s", ipAddr.String())
+	listen := &net.ListenConfig{
+		Control: func(_, _ string, c syscall.RawConn) error {
+			var controlErr error
+			err := c.Control(func(fd uintptr) {
+
+				socketFD := int(fd)
+
+				setAdditionalSocketOptions(socketFD)
+
+				if config.BPFProgramInstructions != nil {
+					err := setSocketBPF(config.BPFProgramInstructions, socketFD)
+					if err != nil {
+						controlErr = errors.Tracef("setSocketBPF failed: %s", err)
+						return
+					}
+				}
+
+				if config.DeviceBinder != nil {
+					_, err := config.DeviceBinder.BindToDevice(socketFD)
+					if err != nil {
+						controlErr = errors.Tracef("BindToDevice failed: %s", err)
+						return
+					}
+				}
+			})
+			if controlErr != nil {
+				return errors.Trace(controlErr)
+			}
+			return errors.Trace(err)
+		},
+	}
+
+	network := "udp4"
+	if ipAddr.To4() == nil {
+		network = "udp6"
 	}
 
-	conn, err := newUDPConn(domain, config)
+	// It's necessary to create an unbound UDP socket, for use with WriteTo,
+	// as required by quic-go. As documented in net.ListenUDP: with an
+	// unspecified IP address, the resulting conn "listens on all available
+	// IP addresses of the local system except multicast IP addresses".
+	//
+	// Limitation: these UDP sockets are not necessarily closed when a device
+	// changes active network (e.g., WiFi to mobile). It's possible that a
+	// QUIC connection does not immediately close on a network change, and
+	// instead outbound packets are sent from a different active interface.
+	// As quic-go does not yet support connection migration, these packets
+	// will be dropped by the server. This situation is mitigated by network
+	// change event detection, which initiates new tunnel connections, and by
+	// timeouts/keep-alives.
+
+	conn, err := listen.ListenPacket(ctx, network, "")
 	if err != nil {
 		return nil, nil, errors.Trace(err)
 	}
 
+	udpConn, ok := conn.(*net.UDPConn)
+	if !ok {
+		return nil, nil, errors.Tracef("unexpected conn type: %T", conn)
+	}
+
 	if config.ResolvedIPCallback != nil {
 		config.ResolvedIPCallback(ipAddr.String())
 	}
 
-	return conn, &net.UDPAddr{IP: ipAddr, Port: port}, nil
+	return udpConn, &net.UDPAddr{IP: ipAddr, Port: port}, nil
 }

+ 0 - 65
psiphon/UDPConn_bind.go

@@ -1,65 +0,0 @@
-// +build !windows
-
-/*
- * Copyright (c) 2018, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package psiphon
-
-import (
-	"net"
-	"os"
-	"syscall"
-
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-)
-
-func newUDPConn(domain int, config *DialConfig) (net.PacketConn, error) {
-
-	// TODO: use https://golang.org/pkg/net/#Dialer.Control, introduced in Go 1.11?
-
-	socketFD, err := syscall.Socket(domain, syscall.SOCK_DGRAM, 0)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	syscall.CloseOnExec(socketFD)
-
-	setAdditionalSocketOptions(socketFD)
-
-	if config.DeviceBinder != nil {
-		_, err = config.DeviceBinder.BindToDevice(socketFD)
-		if err != nil {
-			syscall.Close(socketFD)
-			return nil, errors.Tracef("BindToDevice failed: %s", err)
-		}
-	}
-
-	// Convert the socket fd to a net.PacketConn
-	// This code block is from:
-	// https://github.com/golang/go/issues/6966
-
-	file := os.NewFile(uintptr(socketFD), "")
-	conn, err := net.FilePacketConn(file) // net.FilePackateConn() dups socketFD
-	file.Close()                          // file.Close() closes socketFD
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	return conn, nil
-}

+ 0 - 44
psiphon/UDPConn_nobind.go

@@ -1,44 +0,0 @@
-// +build windows
-
-/*
- * Copyright (c) 2018, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package psiphon
-
-import (
-	"net"
-	"syscall"
-
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-)
-
-func newUDPConn(domain int, config *DialConfig) (net.PacketConn, error) {
-
-	if config.DeviceBinder != nil {
-		return nil, errors.TraceNew("newUDPConn with DeviceBinder not supported on this platform")
-	}
-
-	network := "udp4"
-
-	if domain == syscall.AF_INET6 {
-		network = "udp6"
-	}
-
-	return net.ListenUDP(network, nil)
-}

+ 83 - 42
psiphon/common/quic/obfuscator.go

@@ -102,7 +102,7 @@ const (
 // payload will increase UDP packets beyond the QUIC max of 1280 bytes,
 // introducing some risk of fragmentation and/or dropped packets.
 type ObfuscatedPacketConn struct {
-	ietf_quic.OOBCapablePacketConn
+	net.PacketConn
 	isServer         bool
 	isIETFClient     bool
 	isDecoyClient    bool
@@ -130,79 +130,74 @@ func (p *peerMode) isStale() bool {
 
 // NewObfuscatedPacketConn creates a new ObfuscatedPacketConn.
 func NewObfuscatedPacketConn(
-	conn net.PacketConn,
+	packetConn net.PacketConn,
 	isServer bool,
 	isIETFClient bool,
 	isDecoyClient bool,
 	obfuscationKey string,
 	paddingSeed *prng.Seed) (*ObfuscatedPacketConn, error) {
 
-	oobPacketConn, ok := conn.(ietf_quic.OOBCapablePacketConn)
-	if !ok {
-		return nil, errors.TraceNew("conn must support OOBCapablePacketConn")
-	}
-
 	// There is no replay of obfuscation "encryption", just padding.
 	nonceSeed, err := prng.NewSeed()
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
-	packetConn := &ObfuscatedPacketConn{
-		OOBCapablePacketConn: oobPacketConn,
-		isServer:             isServer,
-		isIETFClient:         isIETFClient,
-		isDecoyClient:        isDecoyClient,
-		peerModes:            make(map[string]*peerMode),
-		noncePRNG:            prng.NewPRNGWithSeed(nonceSeed),
-		paddingPRNG:          prng.NewPRNGWithSeed(paddingSeed),
+	conn := &ObfuscatedPacketConn{
+		PacketConn:    packetConn,
+		isServer:      isServer,
+		isIETFClient:  isIETFClient,
+		isDecoyClient: isDecoyClient,
+		peerModes:     make(map[string]*peerMode),
+		noncePRNG:     prng.NewPRNGWithSeed(nonceSeed),
+		paddingPRNG:   prng.NewPRNGWithSeed(paddingSeed),
 	}
 
 	secret := []byte(obfuscationKey)
 	salt := []byte("quic-obfuscation-key")
 	_, err = io.ReadFull(
-		hkdf.New(sha256.New, secret, salt, nil), packetConn.obfuscationKey[:])
+		hkdf.New(sha256.New, secret, salt, nil), conn.obfuscationKey[:])
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
 	if isDecoyClient {
-		packetConn.decoyPacketCount = int32(packetConn.paddingPRNG.Range(
+		conn.decoyPacketCount = int32(conn.paddingPRNG.Range(
 			MIN_DECOY_PACKETS, MAX_DECOY_PACKETS))
-		packetConn.decoyBuffer = make([]byte, MAX_PACKET_SIZE)
+		conn.decoyBuffer = make([]byte, MAX_PACKET_SIZE)
 	}
 
 	if isServer {
 
-		packetConn.runWaitGroup = new(sync.WaitGroup)
-		packetConn.stopBroadcast = make(chan struct{})
+		conn.runWaitGroup = new(sync.WaitGroup)
+		conn.stopBroadcast = make(chan struct{})
 
 		// Reap stale peer mode information to reclaim memory.
 
-		packetConn.runWaitGroup.Add(1)
+		conn.runWaitGroup.Add(1)
 		go func() {
-			defer packetConn.runWaitGroup.Done()
+			defer conn.runWaitGroup.Done()
 
 			ticker := time.NewTicker(SERVER_IDLE_TIMEOUT / 2)
 			defer ticker.Stop()
 			for {
 				select {
 				case <-ticker.C:
-					packetConn.peerModesMutex.Lock()
-					for address, mode := range packetConn.peerModes {
+					conn.peerModesMutex.Lock()
+					for address, mode := range conn.peerModes {
 						if mode.isStale() {
-							delete(packetConn.peerModes, address)
+							delete(conn.peerModes, address)
 						}
 					}
-					packetConn.peerModesMutex.Unlock()
-				case <-packetConn.stopBroadcast:
+					conn.peerModesMutex.Unlock()
+				case <-conn.stopBroadcast:
 					return
 				}
 			}
 		}()
 	}
 
-	return packetConn, nil
+	return conn, nil
 }
 
 func (conn *ObfuscatedPacketConn) Close() error {
@@ -217,7 +212,7 @@ func (conn *ObfuscatedPacketConn) Close() error {
 		conn.runWaitGroup.Wait()
 	}
 
-	return conn.OOBCapablePacketConn.Close()
+	return conn.PacketConn.Close()
 }
 
 type temporaryNetError struct {
@@ -242,7 +237,7 @@ func (e *temporaryNetError) Error() string {
 
 func (conn *ObfuscatedPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
 	n, _, _, addr, _, err := conn.readPacketWithType(p, nil)
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, addr, err
 }
 
@@ -252,7 +247,7 @@ func (conn *ObfuscatedPacketConn) WriteTo(p []byte, addr net.Addr) (int, error)
 		return 0, errors.TraceNew("unexpected addr type")
 	}
 	n, _, err := conn.writePacket(p, nil, udpAddr)
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, err
 }
 
@@ -273,13 +268,13 @@ func (conn *ObfuscatedPacketConn) WriteTo(p []byte, addr net.Addr) (int, error)
 
 func (conn *ObfuscatedPacketConn) ReadMsgUDP(p, oob []byte) (int, int, int, *net.UDPAddr, error) {
 	n, oobn, flags, addr, _, err := conn.readPacketWithType(p, nil)
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, oobn, flags, addr, err
 }
 
 func (conn *ObfuscatedPacketConn) WriteMsgUDP(p, oob []byte, addr *net.UDPAddr) (int, int, error) {
 	n, oobn, err := conn.writePacket(p, oob, addr)
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, oobn, err
 }
 
@@ -298,7 +293,7 @@ func (conn *ObfuscatedPacketConn) ReadBatch(ms []ipv4.Message, _ int) (int, erro
 	ms[0].N, ms[0].NN, ms[0].Flags, ms[0].Addr, _, err =
 		conn.readPacketWithType(ms[0].Buffers[0], ms[0].OOB)
 	if err != nil {
-		// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+		// Do not wrap any I/O err returned by conn.PacketConn
 		return 0, err
 	}
 	return 1, nil
@@ -363,7 +358,7 @@ func (conn *ObfuscatedPacketConn) readPacketWithType(
 			continue
 		}
 
-		// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+		// Do not wrap any I/O err returned by conn.PacketConn
 		return n, oobn, flags, addr, isIETF, err
 	}
 }
@@ -371,7 +366,26 @@ func (conn *ObfuscatedPacketConn) readPacketWithType(
 func (conn *ObfuscatedPacketConn) readPacket(
 	p, oob []byte) (int, int, int, *net.UDPAddr, bool, error) {
 
-	n, oobn, flags, addr, err := conn.OOBCapablePacketConn.ReadMsgUDP(p, oob)
+	var n, oobn, flags int
+	var addr *net.UDPAddr
+	var err error
+
+	oobCapablePacketConn, ok := conn.PacketConn.(ietf_quic.OOBCapablePacketConn)
+	if ok {
+		// Read OOB ECN bits when supported by the packet conn.
+		n, oobn, flags, addr, err = oobCapablePacketConn.ReadMsgUDP(p, oob)
+	} else {
+		// Fall back to a generic ReadFrom, supported by any packet conn.
+		var netAddr net.Addr
+		n, netAddr, err = conn.PacketConn.ReadFrom(p)
+		if netAddr != nil {
+			// Directly convert from net.Addr to *net.UDPAddr, if possible.
+			addr, ok = netAddr.(*net.UDPAddr)
+			if !ok {
+				addr, err = net.ResolveUDPAddr("udp", netAddr.String())
+			}
+		}
+	}
 
 	// Data is processed even when err != nil, as ReadFrom may return both
 	// a packet and an error, such as io.EOF.
@@ -556,7 +570,7 @@ func (conn *ObfuscatedPacketConn) readPacket(
 		}
 	}
 
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, oobn, flags, addr, isIETF, err
 }
 
@@ -648,15 +662,42 @@ func (conn *ObfuscatedPacketConn) writePacket(
 		}
 	}
 
-	_, oobn, err := conn.OOBCapablePacketConn.WriteMsgUDP(p, oob, addr)
+	var oobn int
+	var err error
+
+	oobCapablePacketConn, ok := conn.PacketConn.(ietf_quic.OOBCapablePacketConn)
+	if ok {
+
+		// Write OOB bits if supported by the packet conn.
+		//
+		// At this time, quic-go reads but does not write ECN OOB bits. On the
+		// client-side, the Dial function arranges for conn.PacketConn to not
+		// implement OOBCapablePacketConn when using obfuscated QUIC, and so
+		// quic-go is not expected to write ECN bits -- a potential
+		// obfuscation fingerprint -- in the future, on the client-side.
+		//
+		// Limitation: on the server-side, the single UDP server socket is
+		// wrapped with ObfuscatedPacketConn and supports both obfuscated and
+		// regular QUIC; as it stands, this logic will support writing ECN
+		// bits for both obfuscated and regular QUIC.
+
+		_, oobn, err = oobCapablePacketConn.WriteMsgUDP(p, oob, addr)
+
+	} else {
+
+		// Fall back to WriteTo, supported by any packet conn. If there are
+		// OOB bits to be written, fail.
+
+		if oob != nil {
+			return 0, 0, errors.TraceNew("unexpected OOB payload for non-OOBCapablePacketConn")
+		}
+		_, err = conn.PacketConn.WriteTo(p, addr)
+	}
 
-	// quic-go uses OOB to manipulate ECN bits in the IP header; these are not
-	// obfuscated.
-	//
 	// Return n = len(input p) bytes written even when p is an obfuscated
 	// buffer and longer than the input p.
 
-	// Do not wrap any I/O err returned by conn.OOBCapablePacketConn
+	// Do not wrap any I/O err returned by conn.PacketConn
 	return n, oobn, err
 }
 

+ 71 - 48
psiphon/common/quic/quic.go

@@ -21,7 +21,6 @@
  */
 
 /*
-
 Package quic wraps github.com/lucas-clemente/quic-go with net.Listener and
 net.Conn types that provide a drop-in replacement for net.TCPConn.
 
@@ -39,7 +38,6 @@ Conns mask or translate qerr.PeerGoingAway to io.EOF as appropriate.
 QUIC idle timeouts and keep alives are tuned to mitigate aggressive UDP NAT
 timeouts on mobile data networks while accounting for the fact that mobile
 devices in standby/sleep may not be able to initiate the keep alive.
-
 */
 package quic
 
@@ -50,7 +48,6 @@ import (
 	"io"
 	"net"
 	"net/http"
-	"os"
 	"sync"
 	"sync/atomic"
 	"syscall"
@@ -347,8 +344,8 @@ func (listener *Listener) Close() error {
 // may be cancelled by ctx; packetConn will be closed if the dial is
 // cancelled or fails.
 //
-// Keep alive and idle timeout functionality in QUIC is disabled as these
-// aspects are expected to be handled at a higher level.
+// When packetConn is a *net.UDPConn, QUIC ECN bit operations are supported,
+// unless the specified QUIC version is obfuscated.
 func Dial(
 	ctx context.Context,
 	packetConn net.PacketConn,
@@ -386,14 +383,38 @@ func Dial(
 		return nil, errors.Tracef("invalid destination port: %d", remoteAddr.Port)
 	}
 
-	udpConn, ok := packetConn.(udpConn)
-	if !ok {
-		return nil, errors.TraceNew("packetConn must implement net.UDPConn functions")
-	}
+	udpConn, ok := packetConn.(*net.UDPConn)
 
-	// Ensure blocked packet writes eventually timeout.
-	packetConn = &writeTimeoutUDPConn{
-		udpConn: udpConn,
+	if !ok || isObfuscated(quicVersion) {
+
+		// quic-go uses OOB operations to manipulate ECN bits in IP packet
+		// headers. These operations are available only when the packet conn
+		// is a *net.UDPConn. At this time, quic-go reads but does not write
+		// ECN OOB bits; see quic-go PR 2789.
+		//
+		// To guard against future writes to ECN bits, a potential fingerprint
+		// when using obfuscated QUIC, this non-OOB code path is taken for
+		// isObfuscated QUIC versions. This mitigates upstream fingerprints;
+		// see ObfuscatedPacketConn.writePacket for the server-side
+		// downstream limitation.
+
+		// Ensure blocked packet writes eventually timeout.
+		packetConn = &writeTimeoutPacketConn{
+			PacketConn: packetConn,
+		}
+
+		// Double check that OOB support won't be detected by quic-go.
+		_, ok := packetConn.(ietf_quic.OOBCapablePacketConn)
+		if ok {
+			return nil, errors.TraceNew("unexpected OOBCapablePacketConn")
+		}
+
+	} else {
+
+		// Ensure blocked packet writes eventually timeout.
+		packetConn = &writeTimeoutUDPConn{
+			UDPConn: udpConn,
+		}
 	}
 
 	maxPacketSizeAdjustment := 0
@@ -443,6 +464,7 @@ func Dial(
 	connection, err := dialQUIC(
 		ctx,
 		packetConn,
+		false,
 		remoteAddr,
 		quicSNIAddress,
 		versionNumber,
@@ -501,35 +523,6 @@ func Dial(
 	return conn, nil
 }
 
-// udpConn matches net.UDPConn, which implements both net.Conn and
-// net.PacketConn. udpConn enables handling of Dial packetConn inputs that
-// are not concrete *net.UDPConn types but which still implement all the
-// required functions. A udpConn instance can be passed to quic-go; various
-// quic-go code paths check that the input conn implements net.Conn and/or
-// net.PacketConn.
-//
-// TODO: add *AddrPort functions introduced in Go 1.18
-type udpConn interface {
-	Close() error
-	File() (f *os.File, err error)
-	LocalAddr() net.Addr
-	Read(b []byte) (int, error)
-	ReadFrom(b []byte) (int, net.Addr, error)
-	ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error)
-	ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAddr, err error)
-	RemoteAddr() net.Addr
-	SetDeadline(t time.Time) error
-	SetReadBuffer(bytes int) error
-	SetReadDeadline(t time.Time) error
-	SetWriteBuffer(bytes int) error
-	SetWriteDeadline(t time.Time) error
-	SyscallConn() (syscall.RawConn, error)
-	Write(b []byte) (int, error)
-	WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, err error)
-	WriteTo(b []byte, addr net.Addr) (int, error)
-	WriteToUDP(b []byte, addr *net.UDPAddr) (int, error)
-}
-
 // writeTimeoutUDPConn sets write deadlines before each UDP packet write.
 //
 // Generally, a UDP packet write doesn't block. However, Go's
@@ -543,7 +536,7 @@ type udpConn interface {
 // Note that quic-go manages read deadlines; we set only the write deadline
 // here.
 type writeTimeoutUDPConn struct {
-	udpConn
+	*net.UDPConn
 }
 
 func (conn *writeTimeoutUDPConn) Write(b []byte) (int, error) {
@@ -554,7 +547,7 @@ func (conn *writeTimeoutUDPConn) Write(b []byte) (int, error) {
 	}
 
 	// Do not wrap any I/O err returned by udpConn
-	return conn.udpConn.Write(b)
+	return conn.UDPConn.Write(b)
 }
 
 func (conn *writeTimeoutUDPConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (int, int, error) {
@@ -565,7 +558,7 @@ func (conn *writeTimeoutUDPConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (
 	}
 
 	// Do not wrap any I/O err returned by udpConn
-	return conn.udpConn.WriteMsgUDP(b, oob, addr)
+	return conn.UDPConn.WriteMsgUDP(b, oob, addr)
 }
 
 func (conn *writeTimeoutUDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
@@ -576,7 +569,7 @@ func (conn *writeTimeoutUDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
 	}
 
 	// Do not wrap any I/O err returned by udpConn
-	return conn.udpConn.WriteTo(b, addr)
+	return conn.UDPConn.WriteTo(b, addr)
 }
 
 func (conn *writeTimeoutUDPConn) WriteToUDP(b []byte, addr *net.UDPAddr) (int, error) {
@@ -587,7 +580,24 @@ func (conn *writeTimeoutUDPConn) WriteToUDP(b []byte, addr *net.UDPAddr) (int, e
 	}
 
 	// Do not wrap any I/O err returned by udpConn
-	return conn.udpConn.WriteToUDP(b, addr)
+	return conn.UDPConn.WriteToUDP(b, addr)
+}
+
+// writeTimeoutPacketConn is the equivilent of writeTimeoutUDPConn for
+// non-*net.UDPConns.
+type writeTimeoutPacketConn struct {
+	net.PacketConn
+}
+
+func (conn *writeTimeoutPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
+
+	err := conn.SetWriteDeadline(time.Now().Add(UDP_PACKET_WRITE_TIMEOUT))
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	// Do not wrap any I/O err returned by udpConn
+	return conn.PacketConn.WriteTo(b, addr)
 }
 
 // Conn is a net.Conn and psiphon/common.Closer.
@@ -864,9 +874,21 @@ func (t *QUICTransporter) dialQUIC() (retConnection quicConnection, retErr error
 		return nil, errors.Trace(err)
 	}
 
+	// Check for a *net.UDPConn, as expected, to support OOB operations.
+	udpConn, ok := packetConn.(*net.UDPConn)
+	if !ok {
+		return nil, errors.Tracef("unexpected packetConn type: %T", packetConn)
+	}
+
+	// Ensure blocked packet writes eventually timeout.
+	packetConn = &writeTimeoutUDPConn{
+		UDPConn: udpConn,
+	}
+
 	connection, err := dialQUIC(
 		ctx,
 		packetConn,
+		true,
 		remoteAddr,
 		t.quicSNIAddress,
 		versionNumber,
@@ -994,6 +1016,7 @@ func (c *ietfQUICConnection) isErrorIndicatingClosed(err error) bool {
 func dialQUIC(
 	ctx context.Context,
 	packetConn net.PacketConn,
+	expectNetUDPConn bool,
 	remoteAddr *net.UDPAddr,
 	quicSNIAddress string,
 	versionNumber uint32,
@@ -1153,7 +1176,7 @@ func (conn *muxPacketConn) SetWriteDeadline(t time.Time) error {
 // https://godoc.org/github.com/lucas-clemente/quic-go#ECNCapablePacketConn.
 
 func (conn *muxPacketConn) SetReadBuffer(bytes int) error {
-	c, ok := conn.listener.conn.OOBCapablePacketConn.(interface {
+	c, ok := conn.listener.conn.PacketConn.(interface {
 		SetReadBuffer(int) error
 	})
 	if !ok {
@@ -1163,7 +1186,7 @@ func (conn *muxPacketConn) SetReadBuffer(bytes int) error {
 }
 
 func (conn *muxPacketConn) SyscallConn() (syscall.RawConn, error) {
-	c, ok := conn.listener.conn.OOBCapablePacketConn.(interface {
+	c, ok := conn.listener.conn.PacketConn.(interface {
 		SyscallConn() (syscall.RawConn, error)
 	})
 	if !ok {