Browse Source

Merge pull request #383 from rod-hynes/packet-tunnel

Packet tunnel
Rod Hynes 8 years ago
parent
commit
d718fece36

+ 3 - 0
.travis.yml

@@ -12,6 +12,8 @@ script:
 - go test -race -v ./common
 - go test -race -v ./common/osl
 - go test -race -v ./common/protocol
+# TODO: enable once known race condition is addressed
+#- go test -race -v ./common/tun
 - go test -race -v ./transferstats
 - go test -race -v ./server
 - go test -race -v ./server/psinet
@@ -19,6 +21,7 @@ script:
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=osl.coverprofile ./common/osl
 - go test -v -covermode=count -coverprofile=protocol.coverprofile ./common/protocol
+- go test -v -covermode=count -coverprofile=tun.coverprofile ./common/tun
 - go test -v -covermode=count -coverprofile=transferstats.coverprofile ./transferstats
 - go test -v -covermode=count -coverprofile=server.coverprofile ./server
 - go test -v -covermode=count -coverprofile=psinet.coverprofile ./server/psinet

+ 86 - 20
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -102,6 +102,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     private Thread mTun2SocksThread;
     private AtomicBoolean mIsWaitingForNetworkConnectivity;
 
+    // mUsePacketTunnel specifies whether to use the packet
+    // tunnel instead of tun2socks; currently this is for
+    // testing only and is disabled.
+    private boolean mUsePacketTunnel = false;
+
     // Only one PsiphonVpn instance may exist at a time, as the underlying
     // go.psi.Psi and tun2socks implementations each contain global state.
     private static PsiphonTunnel mPsiphonTunnel;
@@ -134,12 +139,17 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     //----------------------------------------------------------------------------------------------
 
     // To start, call in sequence: startRouting(), then startTunneling(). After startRouting()
-    // succeeds, the caller must call stop() to clean up.
+    // succeeds, the caller must call stop() to clean up. These functions should not be called
+    // concurrently. Do not call stop() while startRouting() or startTunneling() is in progress.
 
     // Returns true when the VPN routing is established; returns false if the VPN could not
     // be started due to lack of prepare or revoked permissions (called should re-prepare and
     // try again); throws exception for other error conditions.
     public synchronized boolean startRouting() throws Exception {
+
+        // Note: tun2socks is loaded even in mUsePacketTunnel mode,
+        // as disableUdpGwKeepalive will still be called.
+
         // Load tun2socks library embedded in the aar
         // If this method is called more than once with the same library name, the second and subsequent calls are ignored.
         // http://docs.oracle.com/javase/7/docs/api/java/lang/Runtime.html#loadLibrary%28java.lang.String%29
@@ -210,14 +220,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             // Workaround for https://code.google.com/p/android/issues/detail?id=61096
             Locale.setDefault(new Locale("en"));
 
+            int mtu = VPN_INTERFACE_MTU;
+            String dnsResolver = mPrivateAddress.mRouter;
+
+            if (mUsePacketTunnel) {
+                mtu = (int)Psi.GetPacketTunnelMTU();
+                dnsResolver = Psi.GetPacketTunnelDNSResolverIPv4Address();
+            }
+
             ParcelFileDescriptor tunFd =
                     ((VpnService.Builder) mHostService.newVpnServiceBuilder())
                             .setSession(mHostService.getAppName())
-                            .setMtu(VPN_INTERFACE_MTU)
+                            .setMtu(mtu)
                             .addAddress(mPrivateAddress.mIpAddress, mPrivateAddress.mPrefixLength)
                             .addRoute("0.0.0.0", 0)
                             .addRoute(mPrivateAddress.mSubnet, mPrivateAddress.mPrefixLength)
-                            .addDnsServer(mPrivateAddress.mRouter)
+                            .addDnsServer(dnsResolver)
                             .establish();
             if (tunFd == null) {
                 // As per http://developer.android.com/reference/android/net/VpnService.Builder.html#establish%28%29,
@@ -255,20 +273,24 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         if (!mRoutingThroughTunnel.compareAndSet(false, true)) {
             return;
         }
-        ParcelFileDescriptor tunFd = mTunFd.getAndSet(null);
-        if (tunFd == null) {
-            return;
+
+        if (!mUsePacketTunnel) {
+            ParcelFileDescriptor tunFd = mTunFd.getAndSet(null);
+            if (tunFd == null) {
+                return;
+            }
+            String socksServerAddress = "127.0.0.1:" + Integer.toString(mLocalSocksProxyPort.get());
+            String udpgwServerAddress = "127.0.0.1:" + Integer.toString(UDPGW_SERVER_PORT);
+            startTun2Socks(
+                    tunFd,
+                    VPN_INTERFACE_MTU,
+                    mPrivateAddress.mRouter,
+                    VPN_INTERFACE_NETMASK,
+                    socksServerAddress,
+                    udpgwServerAddress,
+                    true);
         }
-        String socksServerAddress = "127.0.0.1:" + Integer.toString(mLocalSocksProxyPort.get());
-        String udpgwServerAddress = "127.0.0.1:" + Integer.toString(UDPGW_SERVER_PORT);
-        startTun2Socks(
-                tunFd,
-                VPN_INTERFACE_MTU,
-                mPrivateAddress.mRouter,
-                VPN_INTERFACE_NETMASK,
-                socksServerAddress,
-                udpgwServerAddress,
-                true);
+
         mHostService.onDiagnosticMessage("routing through tunnel");
 
         // TODO: should double-check tunnel routing; see:
@@ -276,7 +298,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     }
 
     private void stopVpn() {
-        stopTun2Socks();
+
+        if (!mUsePacketTunnel) {
+            stopTun2Socks();
+        }
+
         ParcelFileDescriptor tunFd = mTunFd.getAndSet(null);
         if (tunFd != null) {
             try {
@@ -345,16 +371,50 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     private void startPsiphon(String embeddedServerEntries) throws Exception {
         stopPsiphon();
         mHostService.onDiagnosticMessage("starting Psiphon library");
+
+        // In packet tunnel mode, Psi.Start will dup the tun file descriptor
+        // passed in via the config. So here we "check out" mTunFd, to ensure
+        // it can't be closed before it's duplicated. (This would only happen
+        // if stop() is called concurrently with startTunneling(), which should
+        // not be done -- this could also cause file descriptor issues in
+        // tun2socks mode. With the "check out", a closed and recycled file
+        // descriptor will not be copied; but a different race condition takes
+        // the place of that one: stop() may fail to close the tun fd. So the
+        // prohibition on concurrent calls remains.)
+        //
+        // In tun2socks mode, the ownership of the fd is transferred to tun2socks.
+        // In packet tunnel mode, tunnel code dups the fd and manages  that copy
+        // while PsiphonTunnel retains ownership of the original mTunFd copy. Both
+        // file descriptors must be closed to halt VpnService, and stop() does
+        // this.
+
+        ParcelFileDescriptor tunFd = null;
+        int fd = -1;
+        if (mUsePacketTunnel) {
+            tunFd = mTunFd.getAndSet(null);
+            if (tunFd != null) {
+                fd = tunFd.getFd();
+            }
+        }
+
         try {
             Psi.Start(
-                    loadPsiphonConfig(mHostService.getContext()),
+                    loadPsiphonConfig(mHostService.getContext(), fd),
                     embeddedServerEntries,
                     this,
                     isVpnMode(),
-                    false /* Do not use IPv6 synthesizer for android */);
+                    false // Do not use IPv6 synthesizer for android
+                    );
         } catch (java.lang.Exception e) {
             throw new Exception("failed to start Psiphon library", e);
+        } finally {
+
+            if (mUsePacketTunnel) {
+                mTunFd.getAndSet(tunFd);
+            }
+
         }
+
         mHostService.onDiagnosticMessage("Psiphon library started");
     }
 
@@ -364,7 +424,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         mHostService.onDiagnosticMessage("Psiphon library stopped");
     }
 
-    private String loadPsiphonConfig(Context context)
+    private String loadPsiphonConfig(Context context, int tunFd)
             throws IOException, JSONException {
 
         // Load settings from the raw resource JSON config file and
@@ -433,6 +493,12 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
         json.put("DeviceRegion", getDeviceRegion(mHostService.getContext()));
 
+        if (mUsePacketTunnel) {
+            json.put("PacketTunnelTunFileDescriptor", tunFd);
+            json.put("DisableLocalSocksProxy", true);
+            json.put("DisableLocalHTTPProxy", true);
+        }
+
         return json.toString();
     }
 

+ 1 - 1
MobileLibrary/Android/make.bash

@@ -16,7 +16,7 @@ BUILD_TAGS="OPENSSL ${PRIVATE_PLUGINS_TAG}"
 # the latest versions. Outside of Docker, be aware that these dependencies
 # will not be overridden w/ new versions if they already exist in $GOPATH
 
-GOOS=arm go get -d -v -tags "${BUILD_TAGS}" github.com/Psiphon-Labs/psiphon-tunnel-core/MobileLibrary/psi
+GOOS=android go get -d -v -tags "${BUILD_TAGS}" github.com/Psiphon-Labs/psiphon-tunnel-core/MobileLibrary/psi
 if [ $? != 0 ]; then
   echo "..'go get -d -v github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon' failed, exiting"
   exit $?

+ 54 - 1
MobileLibrary/psi/psi.go

@@ -19,7 +19,7 @@
 
 package psi
 
-// This package is a shim between Java and the "psiphon" package. Due to limitations
+// This package is a shim between Java/Obj-C and the "psiphon" package. Due to limitations
 // on what Go types may be exposed (http://godoc.org/golang.org/x/mobile/cmd/gobind),
 // a psiphon.Controller cannot be directly used by Java. This shim exposes a trivial
 // Start/Stop interface on top of a single Controller instance.
@@ -31,6 +31,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
 
 type PsiphonProvider interface {
@@ -40,6 +41,7 @@ type PsiphonProvider interface {
 	IPv6Synthesize(IPv4Addr string) string
 	GetPrimaryDnsServer() string
 	GetSecondaryDnsServer() string
+	GetPacketTunnelDeviceBridge() *PacketTunnelDeviceBridge
 }
 
 var controllerMutex sync.Mutex
@@ -74,6 +76,11 @@ func Start(
 		config.IPv6Synthesizer = provider
 	}
 
+	deviceBridge := provider.GetPacketTunnelDeviceBridge()
+	if deviceBridge != nil {
+		config.PacketTunnelDeviceBridge = deviceBridge.bridge
+	}
+
 	psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
 			provider.Notice(string(notice))
@@ -152,3 +159,49 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 		psiphon.NoticeInfo("Feedback uploaded successfully")
 	}
 }
+
+func GetPacketTunnelMTU() int {
+	return tun.DEFAULT_MTU
+}
+
+func GetPacketTunnelDNSResolverIPv4Address() string {
+	return tun.GetTransparentDNSResolverIPv4Address().String()
+}
+
+func GetPacketTunnelDNSResolverIPv6Address() string {
+	return tun.GetTransparentDNSResolverIPv6Address().String()
+}
+
+// PacketTunnelDeviceBridge is a shim for tun.DeviceBeidge,
+// exposing just the necessary "psi" caller integration points.
+//
+// For performant packet tunneling, it's important to avoid memory
+// allocation and garbage collection per packet I/O.
+//
+// In Obj-C, gobind generates code that will not allocate/copy byte
+// slices _as long as a NSMutableData is passed to ReceivedFromDevice_;
+// and generates code that will not allocate/copy when calling
+// SendToDevice. E.g., generated code calls go_seq_to_objc_bytearray
+// and go_seq_from_objc_bytearray with copy set to 0.
+type PacketTunnelDeviceBridge struct {
+	bridge *tun.DeviceBridge
+}
+
+type PacketTunnelDeviceSender interface {
+	SendToDevice(packet []byte)
+}
+
+func NewPacketTunnelDeviceBridge(
+	sender PacketTunnelDeviceSender) *PacketTunnelDeviceBridge {
+
+	return &PacketTunnelDeviceBridge{
+		bridge: tun.NewDeviceBridge(
+			GetPacketTunnelMTU(),
+			0,
+			sender.SendToDevice),
+	}
+}
+
+func (r *PacketTunnelDeviceBridge) ReceivedFromDevice(packet []byte) {
+	r.bridge.ReceivedFromDevice(packet)
+}

+ 6 - 2
Server/main.go

@@ -118,12 +118,16 @@ func main() {
 		serverIPaddress := generateServerIPaddress
 
 		if generateServerNetworkInterface != "" {
-			var err error
-			serverIPaddress, err = common.GetInterfaceIPAddress(generateServerNetworkInterface)
+			// TODO: IPv6 support
+			serverIPv4Address, _, err := common.GetInterfaceIPAddresses(generateServerNetworkInterface)
+			if err == nil && serverIPv4Address == nil {
+				err = fmt.Errorf("no IPv4 address for interface %s", generateServerNetworkInterface)
+			}
 			if err != nil {
 				fmt.Printf("generate failed: %s\n", err)
 				os.Exit(1)
 			}
+			serverIPaddress = serverIPv4Address.String()
 		}
 
 		tunnelProtocolPorts := make(map[string]int)

+ 385 - 6
psiphon/common/authPackage.go

@@ -20,7 +20,9 @@
 package common
 
 import (
+	"bufio"
 	"bytes"
+	"compress/zlib"
 	"crypto"
 	"crypto/rand"
 	"crypto/rsa"
@@ -29,6 +31,11 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"sync"
 )
 
 // AuthenticatedDataPackage is a JSON record containing some Psiphon data
@@ -63,9 +70,8 @@ func GenerateAuthenticatedDataPackageKeys() (string, string, error) {
 }
 
 func sha256sum(data string) []byte {
-	hash := sha256.New()
-	hash.Write([]byte(data))
-	return hash.Sum(nil)
+	digest := sha256.Sum256([]byte(data))
+	return digest[:]
 }
 
 // WriteAuthenticatedDataPackage creates an AuthenticatedDataPackage
@@ -103,17 +109,22 @@ func WriteAuthenticatedDataPackage(
 		return nil, ContextError(err)
 	}
 
-	return packageJSON, nil
+	return Compress(packageJSON), nil
 }
 
 // ReadAuthenticatedDataPackage extracts and verifies authenticated
 // data from an AuthenticatedDataPackage. The package must have been
 // signed with the given key.
 func ReadAuthenticatedDataPackage(
-	packageJSON []byte, signingPublicKey string) (string, error) {
+	compressedPackage []byte, signingPublicKey string) (string, error) {
+
+	packageJSON, err := Decompress(compressedPackage)
+	if err != nil {
+		return "", ContextError(err)
+	}
 
 	var authenticatedDataPackage *AuthenticatedDataPackage
-	err := json.Unmarshal(packageJSON, &authenticatedDataPackage)
+	err = json.Unmarshal(packageJSON, &authenticatedDataPackage)
 	if err != nil {
 		return "", ContextError(err)
 	}
@@ -149,3 +160,371 @@ func ReadAuthenticatedDataPackage(
 
 	return authenticatedDataPackage.Data, nil
 }
+
+// StreamingReadAuthenticatedDataPackage extracts and verifies authenticated
+// data from an AuthenticatedDataPackage stored in the specified file. The
+// package must have been signed with the given key.
+// StreamingReadAuthenticatedDataPackage does not load the entire package nor
+// the entire data into memory. It streams the package while verifying, and
+// returns an io.ReadCloser that the caller may use to stream the authenticated
+// data payload. The caller _must_ close the io.Closer to free resources and
+// close the underlying file.
+func StreamingReadAuthenticatedDataPackage(
+	packageFileName string, signingPublicKey string) (io.ReadCloser, error) {
+
+	file, err := os.Open(packageFileName)
+	if err != nil {
+		return nil, ContextError(err)
+	}
+
+	closeOnError := file
+	defer func() {
+		if closeOnError != nil {
+			closeOnError.Close()
+		}
+	}()
+
+	var payload io.ReadCloser
+
+	// The file is streamed in 2 passes. The first pass verifies the package
+	// signature. No payload data should be accepted/processed until the signature
+	// check is complete. The second pass repositions to the data payload and returns
+	// a reader the caller will use to stream the authenticated payload.
+	//
+	// Note: No exclusive file lock is held between passes, so it's possible to
+	// verify the data in one pass, and read different data in the second pass.
+	// For Psiphon's use cases, this will not happen in practise -- the packageFileName
+	// will not change while StreamingReadAuthenticatedDataPackage is running -- unless
+	// the client host is compromised; a compromised client host is outside of our threat
+	// model.
+
+	for pass := 0; pass < 2; pass++ {
+
+		_, err = file.Seek(0, 0)
+		if err != nil {
+			return nil, ContextError(err)
+		}
+
+		decompressor, err := zlib.NewReader(file)
+		if err != nil {
+			return nil, ContextError(err)
+		}
+		// TODO: need to Close decompressor to ensure zlib checksum is verified?
+
+		hash := sha256.New()
+
+		var jsonData io.Reader
+		var jsonSigningPublicKey []byte
+		var jsonSignature []byte
+
+		jsonReadBase64Value := func(value io.Reader) ([]byte, error) {
+			base64Value, err := ioutil.ReadAll(value)
+			if err != nil {
+				return nil, ContextError(err)
+			}
+			decodedValue, err := base64.StdEncoding.DecodeString(string(base64Value))
+			if err != nil {
+				return nil, ContextError(err)
+			}
+			return decodedValue, nil
+		}
+
+		jsonHandler := func(key string, value io.Reader) (bool, error) {
+			switch key {
+
+			case "data":
+				if pass == 0 {
+
+					_, err := io.Copy(hash, value)
+					if err != nil {
+						return false, ContextError(err)
+					}
+					return true, nil
+
+				} else { // pass == 1
+
+					jsonData = value
+
+					// The JSON stream parser must halt at this position,
+					// leaving the reader to be returned to the caller positioned
+					// at the start of the data payload.
+					return false, nil
+				}
+
+			case "signingPublicKeyDigest":
+				jsonSigningPublicKey, err = jsonReadBase64Value(value)
+				if err != nil {
+					return false, ContextError(err)
+				}
+				return true, nil
+
+			case "signature":
+				jsonSignature, err = jsonReadBase64Value(value)
+				if err != nil {
+					return false, ContextError(err)
+				}
+				return true, nil
+			}
+
+			return false, ContextError(fmt.Errorf("unexpected key '%s'", key))
+		}
+
+		// Using a buffered reader to consume zlib output in batches
+		// yields a significant speed up in the BenchmarkAuthenticatedPackage.
+		jsonStreamer := &limitedJSONStreamer{
+			reader:  bufio.NewReader(decompressor),
+			handler: jsonHandler,
+		}
+
+		err = jsonStreamer.Stream()
+		if err != nil {
+			return nil, ContextError(err)
+		}
+
+		if pass == 0 {
+
+			if jsonSigningPublicKey == nil || jsonSignature == nil {
+				return nil, ContextError(errors.New("missing expected field"))
+			}
+
+			derEncodedPublicKey, err := base64.StdEncoding.DecodeString(signingPublicKey)
+			if err != nil {
+				return nil, ContextError(err)
+			}
+			publicKey, err := x509.ParsePKIXPublicKey(derEncodedPublicKey)
+			if err != nil {
+				return nil, ContextError(err)
+			}
+			rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
+			if !ok {
+				return nil, ContextError(errors.New("unexpected signing public key type"))
+			}
+
+			if 0 != bytes.Compare(jsonSigningPublicKey, sha256sum(signingPublicKey)) {
+				return nil, ContextError(errors.New("unexpected signing public key digest"))
+			}
+
+			err = rsa.VerifyPKCS1v15(
+				rsaPublicKey,
+				crypto.SHA256,
+				hash.Sum(nil),
+				jsonSignature)
+			if err != nil {
+				return nil, ContextError(err)
+			}
+
+		} else { // pass == 1
+
+			if jsonData == nil {
+				return nil, ContextError(errors.New("missing expected field"))
+			}
+
+			payload = struct {
+				io.Reader
+				io.Closer
+			}{
+				jsonData,
+				file,
+			}
+		}
+	}
+
+	closeOnError = nil
+
+	return payload, nil
+}
+
+// limitedJSONStreamer is a streaming JSON parser that supports just the
+// JSON required for the AuthenticatedDataPackage format and expected data payloads.
+//
+// Unlike other common streaming JSON parsers, limitedJSONStreamer streams the JSON
+// _values_, as the AuthenticatedDataPackage "data" value may be too large to fit into
+// memory.
+//
+// limitedJSONStreamer is not intended for use outside of AuthenticatedDataPackage
+// and supports only a small subset of JSON: one object with string values only,
+// no escaped characters, no nested objects, no arrays, no numbers, etc.
+//
+// limitedJSONStreamer does support any JSON spec (http://www.json.org/) format
+// for its limited subset. So, for example, any whitespace/formatting should be
+// supported and the creator of AuthenticatedDataPackage should be able to use
+// any valid JSON that results in a AuthenticatedDataPackage object.
+//
+// For each key/value pair, handler is invoked with the key name and a reader
+// to stream the value. The handler _must_ read value to EOF (or return an error).
+type limitedJSONStreamer struct {
+	reader  io.Reader
+	handler func(key string, value io.Reader) (bool, error)
+}
+
+const (
+	stateJSONSeekingObjectStart = iota
+	stateJSONSeekingKeyStart
+	stateJSONSeekingKeyEnd
+	stateJSONSeekingColon
+	stateJSONSeekingStringValueStart
+	stateJSONSeekingStringValueEnd
+	stateJSONSeekingNextPair
+	stateJSONObjectEnd
+)
+
+func (streamer *limitedJSONStreamer) Stream() error {
+
+	// TODO: validate that strings are valid Unicode?
+
+	isWhitespace := func(b byte) bool {
+		return b == ' ' || b == '\t' || b == '\r' || b == '\n'
+	}
+
+	nextByte := make([]byte, 1)
+	keyBuffer := new(bytes.Buffer)
+	state := stateJSONSeekingObjectStart
+
+	for {
+		n, readErr := streamer.reader.Read(nextByte)
+
+		if n > 0 {
+
+			b := nextByte[0]
+
+			switch state {
+
+			case stateJSONSeekingObjectStart:
+				if b == '{' {
+					state = stateJSONSeekingKeyStart
+				} else if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U while seeking object start", b))
+				}
+
+			case stateJSONSeekingKeyStart:
+				if b == '"' {
+					state = stateJSONSeekingKeyEnd
+					keyBuffer.Reset()
+				} else if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U while seeking key start", b))
+				}
+
+			case stateJSONSeekingKeyEnd:
+				if b == '\\' {
+					return ContextError(errors.New("unsupported escaped character"))
+				} else if b == '"' {
+					state = stateJSONSeekingColon
+				} else {
+					keyBuffer.WriteByte(b)
+				}
+
+			case stateJSONSeekingColon:
+				if b == ':' {
+					state = stateJSONSeekingStringValueStart
+				} else if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U while seeking colon", b))
+				}
+
+			case stateJSONSeekingStringValueStart:
+				if b == '"' {
+					state = stateJSONSeekingStringValueEnd
+
+					key := string(keyBuffer.Bytes())
+
+					// Wrap the main reader in a reader that will read up to the end
+					// of the value and then EOF. The handler is expected to consume
+					// the full value, and then stream parsing will resume after the
+					// end of the value.
+					valueStreamer := &limitedJSONValueStreamer{
+						reader: streamer.reader,
+					}
+
+					continueStreaming, err := streamer.handler(key, valueStreamer)
+					if err != nil {
+						return ContextError(err)
+					}
+
+					// The handler may request that streaming halt at this point; no
+					// further changes are made to streamer.reader, leaving the value
+					// exactly where the hander leaves it.
+					if !continueStreaming {
+						return nil
+					}
+
+					state = stateJSONSeekingNextPair
+
+				} else if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U while seeking value start", b))
+				}
+
+			case stateJSONSeekingNextPair:
+				if b == ',' {
+					state = stateJSONSeekingKeyStart
+				} else if b == '}' {
+					state = stateJSONObjectEnd
+				} else if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U while seeking next name/value pair", b))
+				}
+
+			case stateJSONObjectEnd:
+				if !isWhitespace(b) {
+					return ContextError(fmt.Errorf("unexpected character %#U after object end", b))
+				}
+
+			default:
+				return ContextError(errors.New("unexpected state"))
+
+			}
+		}
+
+		if readErr != nil {
+			if readErr == io.EOF {
+				if state != stateJSONObjectEnd {
+					return ContextError(errors.New("unexpected EOF before object end"))
+				}
+				return nil
+			}
+			return ContextError(readErr)
+		}
+	}
+}
+
+// limitedJSONValueStreamer wraps the limitedJSONStreamer reader
+// with a reader that reads to the end of a string value and then
+// terminates with EOF.
+type limitedJSONValueStreamer struct {
+	mutex  sync.Mutex
+	eof    bool
+	reader io.Reader
+}
+
+// Read implements the io.Reader interface.
+func (streamer *limitedJSONValueStreamer) Read(p []byte) (int, error) {
+	streamer.mutex.Lock()
+	defer streamer.mutex.Unlock()
+
+	if streamer.eof {
+		return 0, io.EOF
+	}
+
+	var i int
+	var err error
+
+	for i = 0; i < len(p); i++ {
+
+		var n int
+		n, err = streamer.reader.Read(p[i : i+1])
+
+		if n == 1 {
+			if p[i] == '"' {
+				n = 0
+				streamer.eof = true
+				err = io.EOF
+			} else if p[i] == '\\' {
+				n = 0
+				err = ContextError(errors.New("unsupported escaped character"))
+			}
+		}
+
+		if err != nil {
+			break
+		}
+	}
+
+	return i, err
+}

+ 146 - 36
psiphon/common/authPackage_test.go

@@ -20,35 +20,67 @@
 package common
 
 import (
+	"encoding/base64"
 	"encoding/json"
+	"io"
+	"io/ioutil"
+	"math/rand"
+	"os"
 	"testing"
 )
 
 func TestAuthenticatedPackage(t *testing.T) {
 
-	var signingPublicKey, signingPrivateKey string
-
-	t.Run("generate package keys", func(t *testing.T) {
-		var err error
-		signingPublicKey, signingPrivateKey, err = GenerateAuthenticatedDataPackageKeys()
-		if err != nil {
-			t.Fatalf("GenerateAuthenticatedDataPackageKeys failed: %s", err)
-		}
-	})
+	signingPublicKey, signingPrivateKey, err := GenerateAuthenticatedDataPackageKeys()
+	if err != nil {
+		t.Fatalf("GenerateAuthenticatedDataPackageKeys failed: %s", err)
+	}
 
 	expectedContent := "TestAuthenticatedPackage"
-	var packagePayload []byte
-
-	t.Run("write package", func(t *testing.T) {
-		var err error
-		packagePayload, err = WriteAuthenticatedDataPackage(
-			expectedContent,
-			signingPublicKey,
-			signingPrivateKey)
-		if err != nil {
-			t.Fatalf("WriteAuthenticatedDataPackage failed: %s", err)
-		}
-	})
+
+	packagePayload, err := WriteAuthenticatedDataPackage(
+		expectedContent,
+		signingPublicKey,
+		signingPrivateKey)
+	if err != nil {
+		t.Fatalf("WriteAuthenticatedDataPackage failed: %s", err)
+	}
+
+	tempFileName, err := makeTempFile(packagePayload)
+	if err != nil {
+		t.Fatalf("makeTempFile failed: %s", err)
+	}
+	defer os.Remove(tempFileName)
+
+	wrongSigningPublicKey, _, err := GenerateAuthenticatedDataPackageKeys()
+	if err != nil {
+		t.Fatalf("GenerateAuthenticatedDataPackageKeys failed: %s", err)
+	}
+
+	packageJSON, err := Decompress(packagePayload)
+	if err != nil {
+		t.Fatalf("Uncompress failed: %s", err)
+	}
+
+	var authDataPackage AuthenticatedDataPackage
+	err = json.Unmarshal(packageJSON, &authDataPackage)
+	if err != nil {
+		t.Fatalf("Unmarshal failed: %s", err)
+	}
+	authDataPackage.Data = "TamperedData"
+
+	tamperedPackageJSON, err := json.Marshal(&authDataPackage)
+	if err != nil {
+		t.Fatalf("Marshal failed: %s", err)
+	}
+
+	tamperedPackagePayload := Compress(tamperedPackageJSON)
+
+	tamperedTempFileName, err := makeTempFile(tamperedPackagePayload)
+	if err != nil {
+		t.Fatalf("makeTempFile failed: %s", err)
+	}
+	defer os.Remove(tempFileName)
 
 	t.Run("read package: success", func(t *testing.T) {
 		content, err := ReadAuthenticatedDataPackage(
@@ -63,11 +95,24 @@ func TestAuthenticatedPackage(t *testing.T) {
 		}
 	})
 
-	t.Run("read package: wrong signing key", func(t *testing.T) {
-		wrongSigningPublicKey, _, err := GenerateAuthenticatedDataPackageKeys()
+	t.Run("streaming read package: success", func(t *testing.T) {
+		contentReader, err := StreamingReadAuthenticatedDataPackage(
+			tempFileName, signingPublicKey)
 		if err != nil {
-			t.Fatalf("GenerateAuthenticatedDataPackageKeys failed: %s", err)
+			t.Fatalf("StreamingReadAuthenticatedDataPackage failed: %s", err)
 		}
+		content, err := ioutil.ReadAll(contentReader)
+		if err != nil {
+			t.Fatalf("ReadAll failed: %s", err)
+		}
+		if string(content) != expectedContent {
+			t.Fatalf(
+				"unexpected package content: expected %s got %s",
+				expectedContent, content)
+		}
+	})
+
+	t.Run("read package: wrong signing key", func(t *testing.T) {
 		_, err = ReadAuthenticatedDataPackage(
 			packagePayload, wrongSigningPublicKey)
 		if err == nil {
@@ -75,24 +120,89 @@ func TestAuthenticatedPackage(t *testing.T) {
 		}
 	})
 
-	t.Run("read package: tampered data", func(t *testing.T) {
-
-		var authDataPackage AuthenticatedDataPackage
-		err := json.Unmarshal(packagePayload, &authDataPackage)
-		if err != nil {
-			t.Fatalf("Unmarshal failed: %s", err)
-		}
-		authDataPackage.Data = "TamperedData"
-
-		tamperedPackagePayload, err := json.Marshal(&authDataPackage)
-		if err != nil {
-			t.Fatalf("Marshal failed: %s", err)
+	t.Run("streaming read package: wrong signing key", func(t *testing.T) {
+		_, err = StreamingReadAuthenticatedDataPackage(
+			tempFileName, wrongSigningPublicKey)
+		if err == nil {
+			t.Fatalf("StreamingReadAuthenticatedDataPackage unexpectedly succeeded")
 		}
+	})
 
+	t.Run("read package: tampered data", func(t *testing.T) {
 		_, err = ReadAuthenticatedDataPackage(
 			tamperedPackagePayload, signingPublicKey)
 		if err == nil {
 			t.Fatalf("ReadAuthenticatedDataPackage unexpectedly succeeded")
 		}
 	})
+
+	t.Run("streaming read package: tampered data", func(t *testing.T) {
+		_, err = StreamingReadAuthenticatedDataPackage(
+			tamperedTempFileName, signingPublicKey)
+		if err == nil {
+			t.Fatalf("StreamingReadAuthenticatedDataPackage unexpectedly succeeded")
+		}
+	})
+}
+
+func BenchmarkAuthenticatedPackage(b *testing.B) {
+
+	signingPublicKey, signingPrivateKey, err := GenerateAuthenticatedDataPackageKeys()
+	if err != nil {
+		b.Fatalf("GenerateAuthenticatedDataPackageKeys failed: %s", err)
+	}
+
+	data := make([]byte, 104857600)
+	rand.Read(data)
+
+	packagePayload, err := WriteAuthenticatedDataPackage(
+		base64.StdEncoding.EncodeToString(data),
+		signingPublicKey,
+		signingPrivateKey)
+	if err != nil {
+		b.Fatalf("WriteAuthenticatedDataPackage failed: %s", err)
+	}
+
+	tempFileName, err := makeTempFile(packagePayload)
+	if err != nil {
+		b.Fatalf("makeTempFile failed: %s", err)
+	}
+	defer os.Remove(tempFileName)
+
+	b.Run("read package", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_, err := ReadAuthenticatedDataPackage(
+				packagePayload, signingPublicKey)
+			if err != nil {
+				b.Fatalf("ReadAuthenticatedDataPackage failed: %s", err)
+			}
+		}
+	})
+
+	b.Run("streaming read package", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			contentReader, err := StreamingReadAuthenticatedDataPackage(
+				tempFileName, signingPublicKey)
+			if err != nil {
+				b.Fatalf("StreamingReadAuthenticatedDataPackage failed: %s", err)
+			}
+			_, err = io.Copy(ioutil.Discard, contentReader)
+			if err != nil {
+				b.Fatalf("Read failed: %s", err)
+			}
+		}
+	})
+}
+
+func makeTempFile(data []byte) (string, error) {
+	file, err := ioutil.TempFile("", "authPackage_test")
+	if err != nil {
+		return "", ContextError(err)
+	}
+	defer file.Close()
+	_, err = file.Write(data)
+	if err != nil {
+		return "", ContextError(err)
+	}
+	return file.Name(), nil
 }

+ 44 - 0
psiphon/common/logger.go

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2017, 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 common
+
+// Logger exposes a logging interface that's compatible with
+// psiphon/server.ContextLogger. This interface allows packages
+// to implement logging that will integrate with psiphon/server
+// without importing that package. Other implementations of
+// Logger may also be provided.
+type Logger interface {
+	WithContext() LogContext
+	WithContextFields(fields LogFields) LogContext
+	LogMetric(metric string, fields LogFields)
+}
+
+// LogContext is interface-compatible with the return values from
+// psiphon/server.ContextLogger.WithContext/WithContextFields.
+type LogContext interface {
+	Debug(args ...interface{})
+	Info(args ...interface{})
+	Warning(args ...interface{})
+	Error(args ...interface{})
+}
+
+// LogFields is type-compatible with psiphon/server.LogFields
+// and logrus.LogFields.
+type LogFields map[string]interface{}

+ 38 - 33
psiphon/common/networkInterface.go

@@ -20,48 +20,53 @@
 package common
 
 import (
-	"errors"
+	"fmt"
 	"net"
 )
 
-// Take in an interface name ("lo", "eth0", "any") passed from either
-// a config setting, by using the -listenInterface flag on client or
-// -interface flag on server from the command line and return the IP
-// address associated with it.
-// If no interface is provided use the default loopback interface (127.0.0.1).
-// If "any" is passed then listen on 0.0.0.0 for client (invalid with server)
-func GetInterfaceIPAddress(listenInterface string) (string, error) {
-	var ip net.IP
-	if listenInterface == "" {
-		ip = net.ParseIP("127.0.0.1")
-		return ip.String(), nil
-	} else if listenInterface == "any" {
-		ip = net.ParseIP("0.0.0.0")
-		return ip.String(), nil
-	} else {
-		availableInterfaces, err := net.InterfaceByName(listenInterface)
-		if err != nil {
-			return "", ContextError(err)
-		}
+// GetInterfaceIPAddress takes an interface name, such as "eth0", and returns
+// the first IPv4 and IPv6 addresses associated with it. Either of the IPv4 or
+// IPv6 address may be nil. If neither type of address is found, an error
+// is returned.
+func GetInterfaceIPAddresses(interfaceName string) (net.IP, net.IP, error) {
+
+	var IPv4Address, IPv6Address net.IP
+
+	availableInterfaces, err := net.InterfaceByName(interfaceName)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	addrs, err := availableInterfaces.Addrs()
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
 
-		addrs, err := availableInterfaces.Addrs()
-		if err != nil {
-			return "", ContextError(err)
+	for _, addr := range addrs {
+
+		ipNet := addr.(*net.IPNet)
+		if ipNet == nil {
+			continue
 		}
-		for _, addr := range addrs {
-			iptype := addr.(*net.IPNet)
-			if iptype == nil {
-				continue
+
+		if ipNet.IP.To4() != nil {
+			if IPv4Address == nil {
+				IPv4Address = ipNet.IP
 			}
-			// TODO: IPv6 support
-			ip = iptype.IP.To4()
-			if ip == nil {
-				continue
+		} else {
+			if IPv6Address == nil {
+				IPv6Address = ipNet.IP
 			}
-			return ip.String(), nil
+		}
+
+		if IPv4Address != nil && IPv6Address != nil {
+			break
 		}
 	}
 
-	return "", ContextError(errors.New("Could not find IP address of specified interface"))
+	if IPv4Address != nil || IPv6Address != nil {
+		return IPv4Address, IPv6Address, nil
+	}
 
+	return nil, nil, ContextError(fmt.Errorf("Could not find any IP address for interface %s", interfaceName))
 }

+ 2 - 0
psiphon/common/protocol/protocol.go

@@ -54,6 +54,8 @@ const (
 
 	PSIPHON_SSH_API_PROTOCOL = "ssh"
 	PSIPHON_WEB_API_PROTOCOL = "web"
+
+	PACKET_TUNNEL_CHANNEL_TYPE = "tun@psiphon.ca"
 )
 
 var SupportedTunnelProtocols = []string{

+ 2382 - 0
psiphon/common/tun/tun.go

@@ -0,0 +1,2382 @@
+/*
+ * Copyright (c) 2017, 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 tun is an IP packet tunnel server and client. It supports tunneling
+both IPv4 and IPv6.
+
+ .........................................................       .-,(  ),-.
+ . [server]                                     .-----.  .    .-(          )-.
+ .                                              | NIC |<---->(    Internet    )
+ . .......................................      '-----'  .    '-(          ).-'
+ . . [packet tunnel daemon]              .         ^     .        '-.( ).-'
+ . .                                     .         |     .
+ . . ...........................         .         |     .
+ . . . [session]               .         .        NAT    .
+ . . .                         .         .         |     .
+ . . .                         .         .         v     .
+ . . .                         .         .       .---.   .
+ . . .                         .         .       | t |   .
+ . . .                         .         .       | u |   .
+ . . .                 .---.   .  .---.  .       | n |   .
+ . . .                 | q |   .  | d |  .       |   |   .
+ . . .                 | u |   .  | e |  .       | d |   .
+ . . .          .------| e |<-----| m |<---------| e |   .
+ . . .          |      | u |   .  | u |  .       | v |   .
+ . . .          |      | e |   .  | x |  .       | i |   .
+ . . .       rewrite   '---'   .  '---'  .       | c |   .
+ . . .          |              .         .       | e |   .
+ . . .          v              .         .       '---'   .
+ . . .     .---------.         .         .         ^     .
+ . . .     | channel |--rewrite--------------------'     .
+ . . .     '---------'         .         .               .
+ . . ...........^...............         .               .
+ . .............|.........................               .
+ ...............|.........................................
+                |
+                | (typically via Internet)
+                |
+ ...............|.................
+ . [client]     |                .
+ .              |                .
+ . .............|............... .
+ . .            v              . .
+ . .       .---------.         . .
+ . .       | channel |         . .
+ . .       '---------'         . .
+ . .            ^              . .
+ . .............|............... .
+ .              v                .
+ .        .------------.         .
+ .        | tun device |         .
+ .        '------------'         .
+ .................................
+
+
+The client relays IP packets between a local tun device and a channel, which
+is a transport to the server. In Psiphon, the channel will be an SSH channel
+within an SSH connection to a Psiphon server.
+
+The server relays packets between each client and its own tun device. The
+server tun device is NATed to the Internet via an external network interface.
+In this way, client traffic is tunneled and will egress from the server host.
+
+Similar to a typical VPN, IP addresses are assigned to each client. Unlike
+a typical VPN, the assignment is not transmitted to the client. Instead, the
+server transparently rewrites the source addresses of client packets to
+the assigned IP address. The server also rewrites the destination address of
+certain DNS packets. The purpose of this is to allow clients to reconnect
+to different servers without having to tear down or change their local
+network configuration. Clients may configure their local tun device with an
+arbitrary IP address and a static DNS resolver address.
+
+The server uses the 24-bit 10.0.0.0/8 IPv4 private address space to maximize
+the number of addresses available, due to Psiphon client churn and minimum
+address lease time constraints. For IPv6, a 24-bit unique local space is used.
+When a client is allocated addresses, a unique, unused 24-bit "index" is
+reserved/leased. This index maps to and from IPv4 and IPv6 private addresses.
+The server multiplexes all client packets into a single tun device. When a
+packet is read, the destination address is used to map the packet back to the
+correct index, which maps back to the client.
+
+The server maintains client "sessions". A session maintains client IP
+address state and effectively holds the lease on assigned addresses. If a
+client is disconnected and quickly reconnects, it will resume its previous
+session, retaining its IP address and network connection states. Idle
+sessions with no client connection will eventually expire.
+
+Packet count and bytes transferred metrics are logged for each client session.
+
+The server integrates with and enforces Psiphon traffic rules and logging
+facilities. The server parses and validates packets. Client-to-client packets
+are not permitted. Only global unicast packets are permitted. Only TCP and UDP
+packets are permitted. The client also filters out, before sending, packets
+that the server won't route.
+
+Certain aspects of packet tunneling are outside the scope of this package;
+e.g, the Psiphon client and server are responsible for establishing an SSH
+channel and negotiating the correct MTU and DNS settings. The Psiphon
+server will call Server.ClientConnected when a client connects and establishes
+a packet tunnel channel; and Server.ClientDisconnected when the client closes
+the channel and/or disconnects.
+
+*/
+package tun
+
+import (
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"net"
+	"os"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	// TODO: use stdlib in Go 1.9
+	"golang.org/x/sync/syncmap"
+
+	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+const (
+	DEFAULT_MTU                          = 1500
+	DEFAULT_DOWNSTREAM_PACKET_QUEUE_SIZE = 64
+	DEFAULT_BRIDGE_PACKET_QUEUE_SIZE     = 64
+	DEFAULT_IDLE_SESSION_EXPIRY_SECONDS  = 300
+	ORPHAN_METRICS_CHECKPOINTER_PERIOD   = 30 * time.Minute
+)
+
+// ServerConfig specifies the configuration of a packet tunnel server.
+type ServerConfig struct {
+
+	// Logger is used for logging events and metrics.
+	Logger common.Logger
+
+	// SudoNetworkConfigCommands specifies whether to use "sudo"
+	// when executing network configuration commands. This is required
+	// when the packet tunnel server is not run as root and when
+	// process capabilities are not available (only Linux kernel 4.3+
+	// has the required capabilities support). The host sudoers file
+	// must be configured to allow the tunnel server process user to
+	// execute the commands invoked in configureServerInterface; see
+	// the implementation for the appropriate platform.
+	SudoNetworkConfigCommands bool
+
+	// EgressInterface is the interface to which client traffic is
+	// masqueraded/NATed. For example, "eth0". If blank, a platform-
+	// appropriate default is used.
+	EgressInterface string
+
+	// GetDNSResolverIPv4Addresses is a function which returns the
+	// DNS resolvers to use as transparent DNS rewrite targets for
+	// IPv4 DNS traffic.
+	//
+	// GetDNSResolverIPv4Addresses is invoked for each new client
+	// session and the list of resolvers is stored with the session.
+	// This is a compromise between checking current resolvers for
+	// each packet (too expensive) and simply passing in a static
+	// list (won't pick up resolver changes). As implemented, only
+	// new client sessions will pick up resolver changes.
+	//
+	// Transparent DNS rewriting occurs when the client uses the
+	// specific, target transparent DNS addresses specified by
+	// GetTransparentDNSResolverIPv4/6Address.
+	//
+	// For outbound DNS packets with a target resolver IP address,
+	// a random resolver is selected and used for the rewrite.
+	// For inbound packets, _any_ resolver in the list is rewritten
+	// back to the target resolver IP address. As a side-effect,
+	// responses to client DNS packets originally destined for a
+	// resolver in GetDNSResolverIPv4Addresses will be lost.
+	GetDNSResolverIPv4Addresses func() []net.IP
+
+	// GetDNSResolverIPv6Addresses is a function which returns the
+	// DNS resolvers to use as transparent DNS rewrite targets for
+	// IPv6 DNS traffic. It functions like GetDNSResolverIPv4Addresses.
+	GetDNSResolverIPv6Addresses func() []net.IP
+
+	// DownStreamPacketQueueSize specifies the size of the downstream
+	// packet queue. The packet tunnel server multiplexes all client
+	// packets through a single tun device, so when a packet is read,
+	// it must be queued or dropped if it cannot be immediately routed
+	// to the appropriate client. Note that the TCP and SSH windows
+	// for the underlying channel transport will impact transfer rate
+	// and queuing.
+	// When DownStreamPacketQueueSize is 0, a default value is used.
+	DownStreamPacketQueueSize int
+
+	// MTU specifies the maximum transmission unit for the packet
+	// tunnel. Clients must be configured with the same MTU. The
+	// server's tun device will be set to this MTU value and is
+	// assumed not to change for the duration of the server.
+	// When MTU is 0, a default value is used.
+	MTU int
+
+	// SessionIdleExpirySeconds specifies how long to retain client
+	// sessions which have no client attached. Sessions are retained
+	// across client connections so reconnecting clients can resume
+	// a previous session. Resuming avoids leasing new IP addresses
+	// for reconnection, and also retains NAT state for active
+	// tunneled connections.
+	//
+	// SessionIdleExpirySeconds is also, effectively, the lease
+	// time for assigned IP addresses.
+	SessionIdleExpirySeconds int
+}
+
+// Server is a packet tunnel server. A packet tunnel server
+// maintains client sessions, relays packets through client
+// channels, and multiplexes packets through a single tun
+// device. The server assigns IP addresses to clients, performs
+// IP address and transparent DNS rewriting, and enforces
+// traffic rules.
+type Server struct {
+	config              *ServerConfig
+	device              *Device
+	indexToSession      syncmap.Map
+	sessionIDToIndex    syncmap.Map
+	connectedInProgress *sync.WaitGroup
+	workers             *sync.WaitGroup
+	runContext          context.Context
+	stopRunning         context.CancelFunc
+	orphanMetrics       *packetMetrics
+}
+
+// NewServer initializes a server.
+func NewServer(config *ServerConfig) (*Server, error) {
+
+	device, err := NewServerDevice(config)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	runContext, stopRunning := context.WithCancel(context.Background())
+
+	return &Server{
+		config:              config,
+		device:              device,
+		connectedInProgress: new(sync.WaitGroup),
+		workers:             new(sync.WaitGroup),
+		runContext:          runContext,
+		stopRunning:         stopRunning,
+		orphanMetrics:       new(packetMetrics),
+	}, nil
+}
+
+// Start starts a server and returns with it running.
+func (server *Server) Start() {
+
+	server.config.Logger.WithContext().Info("starting")
+
+	server.workers.Add(1)
+	go server.runSessionReaper()
+
+	server.workers.Add(1)
+	go server.runOrphanMetricsCheckpointer()
+
+	server.workers.Add(1)
+	go server.runDeviceDownstream()
+}
+
+// Stop halts a running server.
+func (server *Server) Stop() {
+
+	server.config.Logger.WithContext().Info("stopping")
+
+	server.stopRunning()
+
+	// Interrupt blocked device read/writes.
+	server.device.Close()
+
+	// Wait for any in-progress ClientConnected calls to complete.
+	server.connectedInProgress.Wait()
+
+	// After this point, no futher clients will be added: all
+	// in-progress ClientConnected calls have finished; and any
+	// later ClientConnected calls won't get past their
+	// server.runContext.Done() checks.
+
+	// Close all clients. Client workers will be joined
+	// by the following server.workers.Wait().
+	server.indexToSession.Range(func(_, value interface{}) bool {
+		session := value.(*session)
+		server.interruptSession(session)
+		return true
+	})
+
+	server.workers.Wait()
+
+	server.config.Logger.WithContext().Info("stopped")
+}
+
+type AllowedPortChecker func(upstreamIPAddress net.IP, port int) bool
+
+// ClientConnected handles new client connections, creating or resuming
+// a session and returns with client packet handlers running.
+//
+// sessionID is used to identify sessions for resumption.
+//
+// transport provides the channel for relaying packets to and from
+// the client.
+//
+// checkAllowedTCPPortFunc/checkAllowedUDPPortFunc are callbacks used
+// to enforce traffic rules. For each TCP/UDP packet, the corresponding
+// function is called to check if traffic to the packet's port is
+// permitted. These callbacks must be efficient and safe for concurrent
+// calls.
+//
+// It is safe to make concurrent calls to ClientConnected for distinct
+// session IDs. The caller is responsible for serializing calls with the
+// same session ID. Futher, the caller must ensure, in the case of a client
+// transport reconnect when an existing transport has not yet disconnected,
+// that ClientDisconnected is called first -- so it doesn't undo the new
+// ClientConnected. (psiphond meets these constraints by closing any
+// existing SSH client with duplicate session ID early in the lifecycle of
+// a new SSH client connection.)
+func (server *Server) ClientConnected(
+	sessionID string,
+	transport io.ReadWriteCloser,
+	checkAllowedTCPPortFunc, checkAllowedUDPPortFunc AllowedPortChecker) error {
+
+	// It's unusual to call both sync.WaitGroup.Add() _and_ Done() in the same
+	// goroutine. There's no other place to call Add() since ClientConnected is
+	// an API entrypoint. And Done() works because the invariant enforced by
+	// connectedInProgress.Wait() is not that no ClientConnected calls are in
+	// progress, but that no such calls are in progress past the
+	// server.runContext.Done() check.
+
+	server.connectedInProgress.Add(1)
+	defer server.connectedInProgress.Done()
+
+	select {
+	case <-server.runContext.Done():
+		return common.ContextError(errors.New("server stopping"))
+	default:
+	}
+
+	server.config.Logger.WithContextFields(
+		common.LogFields{"sessionID": sessionID}).Info("client connected")
+
+	MTU := getMTU(server.config.MTU)
+
+	clientSession := server.getSession(sessionID)
+
+	if clientSession != nil {
+
+		// Call interruptSession to ensure session is in the
+		// expected idle state.
+
+		server.interruptSession(clientSession)
+
+		// Note: we don't check the session expiry; whether it has
+		// already expired and not yet been reaped; or is about
+		// to expire very shortly. It could happen that the reaper
+		// will kill this session between now and when the expiry
+		// is reset in the following resumeSession call. In this
+		// unlikely case, the packet tunnel client should reconnect.
+
+	} else {
+
+		downStreamPacketQueueSize := DEFAULT_DOWNSTREAM_PACKET_QUEUE_SIZE
+		if server.config.DownStreamPacketQueueSize > 0 {
+			downStreamPacketQueueSize = server.config.DownStreamPacketQueueSize
+		}
+
+		// Store IPv4 resolver addresses in 4-byte representation
+		// for use in rewritting.
+		resolvers := server.config.GetDNSResolverIPv4Addresses()
+		DNSResolverIPv4Addresses := make([]net.IP, len(resolvers))
+		for i, resolver := range resolvers {
+			// Assumes To4 is non-nil
+			DNSResolverIPv4Addresses[i] = resolver.To4()
+		}
+
+		clientSession = &session{
+			lastActivity:             int64(monotime.Now()),
+			sessionID:                sessionID,
+			metrics:                  new(packetMetrics),
+			DNSResolverIPv4Addresses: append([]net.IP(nil), DNSResolverIPv4Addresses...),
+			DNSResolverIPv6Addresses: append([]net.IP(nil), server.config.GetDNSResolverIPv6Addresses()...),
+			checkAllowedTCPPortFunc:  checkAllowedTCPPortFunc,
+			checkAllowedUDPPortFunc:  checkAllowedUDPPortFunc,
+			downstreamPackets:        NewPacketQueue(server.runContext, MTU, downStreamPacketQueueSize),
+			workers:                  new(sync.WaitGroup),
+		}
+
+		// allocateIndex initializes session.index, session.assignedIPv4Address,
+		// and session.assignedIPv6Address; and updates server.indexToSession and
+		// server.sessionIDToIndex.
+
+		err := server.allocateIndex(clientSession)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	server.resumeSession(clientSession, NewChannel(transport, MTU))
+
+	return nil
+}
+
+// ClientDisconnected handles clients disconnecting. Packet handlers
+// are halted, but the client session is left intact to reserve the
+// assigned IP addresses and retain network state in case the client
+// soon reconnects.
+func (server *Server) ClientDisconnected(sessionID string) {
+
+	session := server.getSession(sessionID)
+	if session != nil {
+
+		server.config.Logger.WithContextFields(
+			common.LogFields{"sessionID": sessionID}).Info("client disconnected")
+
+		server.interruptSession(session)
+	}
+}
+
+func (server *Server) getSession(sessionID string) *session {
+
+	if index, ok := server.sessionIDToIndex.Load(sessionID); ok {
+		s, ok := server.indexToSession.Load(index.(int32))
+		if ok {
+			return s.(*session)
+		}
+		server.config.Logger.WithContext().Warning("unexpected missing session")
+	}
+	return nil
+}
+
+func (server *Server) resumeSession(session *session, channel *Channel) {
+
+	session.mutex.Lock()
+	session.mutex.Unlock()
+
+	session.channel = channel
+
+	// Parent context is not server.runContext so that session workers
+	// need only check session.stopRunning to act on shutdown events.
+	session.runContext, session.stopRunning = context.WithCancel(context.Background())
+
+	// When a session is interrupted, all goroutines in session.workers
+	// are joined. When the server is stopped, all goroutines in
+	// server.workers are joined. So, in both cases we synchronously
+	// stop all workers associated with this session.
+
+	session.workers.Add(1)
+	go server.runClientUpstream(session)
+
+	session.workers.Add(1)
+	go server.runClientDownstream(session)
+
+	session.touch()
+}
+
+func (server *Server) interruptSession(session *session) {
+
+	session.mutex.Lock()
+	defer session.mutex.Unlock()
+
+	wasRunning := (session.channel != nil)
+
+	session.stopRunning()
+	if session.channel != nil {
+		// Interrupt blocked channel read/writes.
+		session.channel.Close()
+	}
+	session.workers.Wait()
+	if session.channel != nil {
+		// Don't hold a reference to channel, allowing both it and
+		// its conn to be garbage collected.
+		// Setting channel to nil must happen after workers.Wait()
+		// to ensure no goroutines remains which may access
+		// session.channel.
+		session.channel = nil
+	}
+
+	// interruptSession may be called for idle sessions, to ensure
+	// the session is in an expected state: in ClientConnected,
+	// and in server.Stop(); don't log in those cases.
+	if wasRunning {
+		session.metrics.checkpoint(
+			server.config.Logger, "packet_metrics", packetMetricsAll)
+	}
+
+}
+
+func (server *Server) runSessionReaper() {
+
+	defer server.workers.Done()
+
+	// Periodically iterate over all sessions and discard expired
+	// sessions. This action, removing the index from server.indexToSession,
+	// releases the IP addresses assigned  to the session.
+
+	idleExpiry := server.sessionIdleExpiry()
+
+	ticker := time.NewTicker(idleExpiry / 2)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ticker.C:
+			server.indexToSession.Range(func(_, value interface{}) bool {
+				session := value.(*session)
+				if session.expired(idleExpiry) {
+					server.removeSession(session)
+				}
+				return true
+			})
+		case <-server.runContext.Done():
+			return
+		}
+	}
+}
+
+func (server *Server) sessionIdleExpiry() time.Duration {
+	sessionIdleExpirySeconds := DEFAULT_IDLE_SESSION_EXPIRY_SECONDS
+	if server.config.SessionIdleExpirySeconds > 2 {
+		sessionIdleExpirySeconds = server.config.SessionIdleExpirySeconds
+	}
+	return time.Duration(sessionIdleExpirySeconds) * time.Second
+}
+
+func (server *Server) removeSession(session *session) {
+	server.sessionIDToIndex.Delete(session.sessionID)
+	server.indexToSession.Delete(session.index)
+	server.interruptSession(session)
+}
+
+func (server *Server) runOrphanMetricsCheckpointer() {
+
+	defer server.workers.Done()
+
+	// Periodically log orphan packet metrics. Orphan metrics
+	// are not associated with any session. This includes
+	// packets that are rejected before they can be associated
+	// with a session.
+
+	ticker := time.NewTicker(ORPHAN_METRICS_CHECKPOINTER_PERIOD)
+	defer ticker.Stop()
+
+	for {
+		done := false
+		select {
+		case <-ticker.C:
+		case <-server.runContext.Done():
+			done = true
+		}
+
+		// TODO: skip log if all zeros?
+		server.orphanMetrics.checkpoint(
+			server.config.Logger, "orphan_packet_metrics", packetMetricsRejected)
+		if done {
+			return
+		}
+	}
+}
+
+func (server *Server) runDeviceDownstream() {
+
+	defer server.workers.Done()
+
+	// Read incoming packets from the tun device, parse and validate the
+	// packets, map them to a session/client, perform rewriting, and relay
+	// the packets to the client.
+
+	for {
+		readPacket, err := server.device.ReadPacket()
+
+		select {
+		case <-server.runContext.Done():
+			// No error is logged as shutdown may have interrupted read.
+			return
+		default:
+		}
+
+		if err != nil {
+			server.config.Logger.WithContextFields(
+				common.LogFields{"error": err}).Warning("read device packet failed")
+			// May be temporary error condition, keep reading.
+			continue
+		}
+
+		// destinationIPAddress determines which client recieves this packet.
+		// At this point, only enough of the packet is inspected to determine
+		// this routing info; further validation happens in subsequent
+		// processPacket in runClientDownstream.
+
+		// Note that masquerading/NAT stands between the Internet and the tun
+		// device, so arbitrary packets cannot be sent through to this point.
+
+		// TODO: getPacketDestinationIPAddress and processPacket perform redundant
+		// packet parsing; refactor to avoid extra work?
+
+		destinationIPAddress, ok := getPacketDestinationIPAddress(
+			server.orphanMetrics, packetDirectionServerDownstream, readPacket)
+
+		if !ok {
+			// Packet is dropped. Reason will be counted in orphan metrics.
+			continue
+		}
+
+		// Map destination IP address to client session.
+
+		index := server.convertIPAddressToIndex(destinationIPAddress)
+		s, ok := server.indexToSession.Load(index)
+
+		if !ok {
+			server.orphanMetrics.rejectedPacket(
+				packetDirectionServerDownstream, packetRejectNoSession)
+			continue
+		}
+
+		session := s.(*session)
+
+		// Simply enqueue the packet for client handling, and move on to
+		// read the next packet. The packet tunnel server multiplexes all
+		// client packets through a single tun device, so we must not block
+		// on client channel I/O here.
+		//
+		// When the queue is full, the packet is dropped. This is standard
+		// behavior for routers, VPN servers, etc.
+		//
+		// We allow packets to enqueue in an idle session in case a client
+		// is in the process of reconnecting.
+
+		ok = session.downstreamPackets.Enqueue(readPacket)
+		if !ok {
+			// Enqueue aborted due to server.runContext.Done()
+			return
+		}
+	}
+}
+
+func (server *Server) runClientUpstream(session *session) {
+
+	defer session.workers.Done()
+
+	// Read incoming packets from the client channel, validate the packets,
+	// perform rewriting, and send them through to the tun device.
+
+	for {
+		readPacket, err := session.channel.ReadPacket()
+
+		select {
+		case <-session.runContext.Done():
+			// No error is logged as shutdown may have interrupted read.
+			return
+		default:
+		}
+
+		if err != nil {
+			server.config.Logger.WithContextFields(
+				common.LogFields{"error": err}).Warning("read channel packet failed")
+			// Tear down the session. Must be invoked asynchronously.
+			go server.interruptSession(session)
+			return
+		}
+
+		session.touch()
+
+		// processPacket transparently rewrites the source address to the
+		// session's assigned address and rewrites the destination of any
+		// DNS packets destined to the target DNS resolver.
+		//
+		// The first time the source address is rewritten, the original
+		// value is recorded so inbound packets can have the reverse
+		// rewrite applied. This assumes that the client will send a
+		// packet before receiving any packet, which is the case since
+		// only clients can initiate TCP or UDP connections or flows.
+
+		if !processPacket(
+			session.metrics,
+			session,
+			packetDirectionServerUpstream,
+			readPacket) {
+
+			// Packet is rejected and dropped. Reason will be counted in metrics.
+			continue
+		}
+
+		err = server.device.WritePacket(readPacket)
+
+		if err != nil {
+			server.config.Logger.WithContextFields(
+				common.LogFields{"error": err}).Warning("write device packet failed")
+			// May be temporary error condition, keep working. The packet is
+			// most likely dropped.
+			continue
+		}
+	}
+}
+
+func (server *Server) runClientDownstream(session *session) {
+
+	defer session.workers.Done()
+
+	// Dequeue, process, and relay packets to be sent to the client channel.
+
+	for {
+
+		packet, ok := session.downstreamPackets.Dequeue()
+		if !ok {
+			// Dequeue aborted due to server.runContext.Done()
+			return
+		}
+
+		// In downstream mode, processPacket rewrites the destination address
+		// to the original client source IP address, and also rewrites DNS
+		// packets. As documented in runClientUpstream, the original address
+		// should already be populated via an upstream packet; if not, the
+		// packet will be rejected.
+
+		if !processPacket(
+			session.metrics,
+			session,
+			packetDirectionServerDownstream,
+			packet) {
+
+			// Packet is rejected and dropped. Reason will be counted in metrics.
+
+			session.downstreamPackets.Replace(packet)
+			continue
+		}
+
+		err := session.channel.WritePacket(packet)
+		if err != nil {
+
+			server.config.Logger.WithContextFields(
+				common.LogFields{"error": err}).Warning("write channel packet failed")
+
+			// Tear down the session. Must be invoked asynchronously.
+			go server.interruptSession(session)
+
+			session.downstreamPackets.Replace(packet)
+			return
+		}
+
+		session.touch()
+
+		session.downstreamPackets.Replace(packet)
+	}
+}
+
+var (
+	serverIPv4AddressCIDR             = "10.0.0.1/8"
+	transparentDNSResolverIPv4Address = net.ParseIP("10.0.0.2").To4() // 4-byte for rewriting
+	_, privateSubnetIPv4, _           = net.ParseCIDR("10.0.0.0/8")
+	assignedIPv4AddressTemplate       = "10.%02d.%02d.%02d"
+
+	serverIPv6AddressCIDR             = "fd19:ca83:e6d5:1c44:0000:0000:0000:0001/64"
+	transparentDNSResolverIPv6Address = net.ParseIP("fd19:ca83:e6d5:1c44:0000:0000:0000:0002")
+	_, privateSubnetIPv6, _           = net.ParseCIDR("fd19:ca83:e6d5:1c44::/64")
+	assignedIPv6AddressTemplate       = "fd19:ca83:e6d5:1c44:8c57:4434:ee%02x:%02x%02x"
+)
+
+func (server *Server) allocateIndex(newSession *session) error {
+
+	// Find and assign an available index in the 24-bit index space.
+	// The index directly maps to and so determines the assigned
+	// IPv4 and IPv6 addresses.
+
+	// Search is a random index selection followed by a linear probe.
+	// TODO: is this the most effective (fast on average, simple) algorithm?
+
+	max := 0x00FFFFFF
+
+	randomInt, err := common.MakeSecureRandomInt(max + 1)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	index := int32(randomInt)
+	index &= int32(max)
+
+	idleExpiry := server.sessionIdleExpiry()
+
+	for tries := 0; tries < 100000; index++ {
+
+		tries++
+
+		// The index/address space isn't exactly 24-bits:
+		// - 0 and 0x00FFFFFF are reserved since they map to
+		//   the network identifier (10.0.0.0) and broadcast
+		//   address (10.255.255.255) respectively
+		// - 1 is reserved as the server tun device address,
+		//   (10.0.0.1, and IPv6 equivilent)
+		// - 2 is reserver as the transparent DNS target
+		//   address (10.0.0.2, and IPv6 equivilent)
+
+		if index <= 2 {
+			continue
+		}
+		if index == 0x00FFFFFF {
+			index = 0
+			continue
+		}
+		if s, ok := server.indexToSession.LoadOrStore(index, newSession); ok {
+			// Index is already in use or aquired concurrently.
+			// If the existing session is expired, reap it and use index.
+			existingSession := s.(*session)
+			if existingSession.expired(idleExpiry) {
+				server.removeSession(existingSession)
+			} else {
+				continue
+			}
+		}
+
+		// Note: the To4() for assignedIPv4Address is essential since
+		// that address value is assumed to be 4 bytes when rewriting.
+
+		newSession.index = index
+		newSession.assignedIPv4Address = server.convertIndexToIPv4Address(index).To4()
+		newSession.assignedIPv6Address = server.convertIndexToIPv6Address(index)
+		server.sessionIDToIndex.Store(newSession.sessionID, index)
+
+		server.resetRouting(newSession.assignedIPv4Address, newSession.assignedIPv6Address)
+
+		return nil
+	}
+
+	return common.ContextError(errors.New("unallocated index not found"))
+}
+
+func (server *Server) resetRouting(IPv4Address, IPv6Address net.IP) {
+
+	// Attempt to clear the NAT table of any existing connection
+	// states. This will prevent the (already unlikely) delivery
+	// of packets to the wrong client when an assigned IP address is
+	// recycled. Silently has no effect on some platforms, see
+	// resetNATTables implementations.
+
+	err := resetNATTables(server.config, IPv4Address)
+	if err != nil {
+		server.config.Logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("reset IPv4 routing failed")
+
+	}
+
+	err = resetNATTables(server.config, IPv6Address)
+	if err != nil {
+		server.config.Logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("reset IPv6 routing failed")
+
+	}
+}
+
+func (server *Server) convertIPAddressToIndex(IP net.IP) int32 {
+	// Assumes IP is at least 3 bytes.
+	size := len(IP)
+	return int32(IP[size-3])<<16 | int32(IP[size-2])<<8 | int32(IP[size-1])
+}
+
+func (server *Server) convertIndexToIPv4Address(index int32) net.IP {
+	return net.ParseIP(
+		fmt.Sprintf(
+			assignedIPv4AddressTemplate,
+			(index>>16)&0xFF,
+			(index>>8)&0xFF,
+			index&0xFF))
+}
+
+func (server *Server) convertIndexToIPv6Address(index int32) net.IP {
+	return net.ParseIP(
+		fmt.Sprintf(
+			assignedIPv6AddressTemplate,
+			(index>>16)&0xFF,
+			(index>>8)&0xFF,
+			index&0xFF))
+}
+
+// GetTransparentDNSResolverIPv4Address returns the static IPv4 address
+// to use as a DNS resolver when transparent DNS rewriting is desired.
+func GetTransparentDNSResolverIPv4Address() net.IP {
+	return transparentDNSResolverIPv4Address
+}
+
+// GetTransparentDNSResolverIPv6Address returns the static IPv6 address
+// to use as a DNS resolver when transparent DNS rewriting is desired.
+func GetTransparentDNSResolverIPv6Address() net.IP {
+	return transparentDNSResolverIPv6Address
+}
+
+type session struct {
+	// Note: 64-bit ints used with atomic operations are placed
+	// at the start of struct to ensure 64-bit alignment.
+	// (https://golang.org/pkg/sync/atomic/#pkg-note-BUG)
+	lastActivity             int64
+	metrics                  *packetMetrics
+	sessionID                string
+	index                    int32
+	DNSResolverIPv4Addresses []net.IP
+	assignedIPv4Address      net.IP
+	setOriginalIPv4Address   int32
+	originalIPv4Address      net.IP
+	DNSResolverIPv6Addresses []net.IP
+	assignedIPv6Address      net.IP
+	setOriginalIPv6Address   int32
+	originalIPv6Address      net.IP
+	checkAllowedTCPPortFunc  AllowedPortChecker
+	checkAllowedUDPPortFunc  AllowedPortChecker
+	downstreamPackets        *PacketQueue
+	workers                  *sync.WaitGroup
+	mutex                    sync.Mutex
+	channel                  *Channel
+	runContext               context.Context
+	stopRunning              context.CancelFunc
+}
+
+func (session *session) touch() {
+	atomic.StoreInt64(&session.lastActivity, int64(monotime.Now()))
+}
+
+func (session *session) expired(idleExpiry time.Duration) bool {
+	lastActivity := monotime.Time(atomic.LoadInt64(&session.lastActivity))
+	return monotime.Since(lastActivity) > idleExpiry
+}
+
+func (session *session) setOriginalIPv4AddressIfNotSet(IPAddress net.IP) {
+	if !atomic.CompareAndSwapInt32(&session.setOriginalIPv4Address, 0, 1) {
+		return
+	}
+	// Make a copy of IPAddress; don't reference a slice of a reusable
+	// packet buffer, which will be overwritten.
+	session.originalIPv4Address = net.IP(append([]byte(nil), []byte(IPAddress)...))
+}
+
+func (session *session) getOriginalIPv4Address() net.IP {
+	if atomic.LoadInt32(&session.setOriginalIPv4Address) == 0 {
+		return nil
+	}
+	return session.originalIPv4Address
+}
+
+func (session *session) setOriginalIPv6AddressIfNotSet(IPAddress net.IP) {
+	if !atomic.CompareAndSwapInt32(&session.setOriginalIPv6Address, 0, 1) {
+		return
+	}
+	// Make a copy of IPAddress.
+	session.originalIPv6Address = net.IP(append([]byte(nil), []byte(IPAddress)...))
+}
+
+func (session *session) getOriginalIPv6Address() net.IP {
+	if atomic.LoadInt32(&session.setOriginalIPv6Address) == 0 {
+		return nil
+	}
+	return session.originalIPv6Address
+}
+
+type packetMetrics struct {
+	upstreamRejectReasons   [packetRejectReasonCount]int64
+	downstreamRejectReasons [packetRejectReasonCount]int64
+	TCPIPv4                 relayedPacketMetrics
+	TCPIPv6                 relayedPacketMetrics
+	UDPIPv4                 relayedPacketMetrics
+	UDPIPv6                 relayedPacketMetrics
+}
+
+type relayedPacketMetrics struct {
+	packetsUp   int64
+	packetsDown int64
+	bytesUp     int64
+	bytesDown   int64
+}
+
+func (metrics *packetMetrics) rejectedPacket(
+	direction packetDirection,
+	reason packetRejectReason) {
+
+	if direction == packetDirectionServerUpstream ||
+		direction == packetDirectionClientUpstream {
+
+		atomic.AddInt64(&metrics.upstreamRejectReasons[reason], 1)
+
+	} else { // packetDirectionDownstream
+
+		atomic.AddInt64(&metrics.downstreamRejectReasons[reason], 1)
+
+	}
+}
+
+func (metrics *packetMetrics) relayedPacket(
+	direction packetDirection,
+	version int,
+	protocol internetProtocol,
+	upstreamIPAddress net.IP,
+	packetLength int) {
+
+	// TODO: OSL integration
+	// - Update OSL up/down progress for upstreamIPAddress.
+	// - For port forwards, OSL progress tracking involves one SeedSpecs subnets
+	//   lookup per port forward; this may be too much overhead per packet; OSL
+	//   progress tracking also uses port forward duration as an input.
+	// - Can we do simple flow tracking to achive the same (a) lookup rate,
+	//   (b) duration measurement? E.g., track flow via 4-tuple of source/dest
+	//   IP/port?
+
+	var packetsMetric, bytesMetric *int64
+
+	if direction == packetDirectionServerUpstream ||
+		direction == packetDirectionClientUpstream {
+
+		if version == 4 {
+
+			if protocol == internetProtocolTCP {
+				packetsMetric = &metrics.TCPIPv4.packetsUp
+				bytesMetric = &metrics.TCPIPv4.bytesUp
+			} else { // UDP
+				packetsMetric = &metrics.UDPIPv4.packetsUp
+				bytesMetric = &metrics.UDPIPv4.bytesUp
+			}
+
+		} else { // IPv6
+
+			if protocol == internetProtocolTCP {
+				packetsMetric = &metrics.TCPIPv6.packetsUp
+				bytesMetric = &metrics.TCPIPv6.bytesUp
+			} else { // UDP
+				packetsMetric = &metrics.UDPIPv6.packetsUp
+				bytesMetric = &metrics.UDPIPv6.bytesUp
+			}
+		}
+
+	} else { // packetDirectionDownstream
+
+		if version == 4 {
+
+			if protocol == internetProtocolTCP {
+				packetsMetric = &metrics.TCPIPv4.packetsDown
+				bytesMetric = &metrics.TCPIPv4.bytesDown
+			} else { // UDP
+				packetsMetric = &metrics.UDPIPv4.packetsDown
+				bytesMetric = &metrics.UDPIPv4.bytesDown
+			}
+
+		} else { // IPv6
+
+			if protocol == internetProtocolTCP {
+				packetsMetric = &metrics.TCPIPv6.packetsDown
+				bytesMetric = &metrics.TCPIPv6.bytesDown
+			} else { // UDP
+				packetsMetric = &metrics.UDPIPv6.packetsDown
+				bytesMetric = &metrics.UDPIPv6.bytesDown
+			}
+		}
+	}
+
+	// Note: packet length, and so bytes transferred, includes IP and TCP/UDP
+	// headers, not just payload data, as is counted in port forwarding. It
+	// makes sense to include this packet overhead, since we have to tunnel it.
+
+	atomic.AddInt64(packetsMetric, 1)
+	atomic.AddInt64(bytesMetric, int64(packetLength))
+}
+
+const (
+	packetMetricsRejected = 1
+	packetMetricsRelayed  = 2
+	packetMetricsAll      = packetMetricsRejected | packetMetricsRelayed
+)
+
+func (metrics *packetMetrics) checkpoint(
+	logger common.Logger, logName string, whichMetrics int) {
+
+	// Report all metric counters in a single log message. Each
+	// counter is reset to 0 when added to the log.
+
+	logFields := make(common.LogFields)
+
+	if whichMetrics&packetMetricsRejected != 0 {
+
+		for i := 0; i < packetRejectReasonCount; i++ {
+			logFields["upstream_packet_rejected_"+packetRejectReasonDescription(packetRejectReason(i))] =
+				atomic.SwapInt64(&metrics.upstreamRejectReasons[i], 0)
+			logFields["downstream_packet_rejected_"+packetRejectReasonDescription(packetRejectReason(i))] =
+				atomic.SwapInt64(&metrics.downstreamRejectReasons[i], 0)
+		}
+	}
+
+	if whichMetrics&packetMetricsRelayed != 0 {
+
+		relayedMetrics := []struct {
+			prefix  string
+			metrics *relayedPacketMetrics
+		}{
+			{"tcp_ipv4_", &metrics.TCPIPv4},
+			{"tcp_ipv6_", &metrics.TCPIPv6},
+			{"udp_ipv4_", &metrics.UDPIPv4},
+			{"udp_ipv6_", &metrics.UDPIPv6},
+		}
+
+		for _, r := range relayedMetrics {
+			logFields[r.prefix+"packets_up"] = atomic.SwapInt64(&r.metrics.packetsUp, 0)
+			logFields[r.prefix+"packets_down"] = atomic.SwapInt64(&r.metrics.packetsDown, 0)
+			logFields[r.prefix+"bytes_up"] = atomic.SwapInt64(&r.metrics.bytesUp, 0)
+			logFields[r.prefix+"bytes_down"] = atomic.SwapInt64(&r.metrics.bytesDown, 0)
+		}
+	}
+
+	logger.LogMetric(logName, logFields)
+}
+
+// TODO: PacketQueue optimizations
+//
+// - Instead of a fixed number of packets, a fixed-size buffer could store
+//   a variable number of packets. This would allow enqueueing many more
+//   small packets in the same amount of memory.
+//
+// - Further, when dequeued packets are to be relayed to a Channel, if
+//   the queue was already stored in the Channel framing format, the entire
+//   queue could simply be copied to the Channel in one copy operation.
+
+// PacketQueue is a fixed-size, preallocated queue of packets.
+type PacketQueue struct {
+	runContext  context.Context
+	packets     chan []byte
+	freeBuffers chan []byte
+}
+
+// NewPacketQueue creates a new PacketQueue.
+func NewPacketQueue(
+	runContext context.Context,
+	MTU, queueSize int) *PacketQueue {
+
+	queue := &PacketQueue{
+		runContext:  runContext,
+		packets:     make(chan []byte, queueSize),
+		freeBuffers: make(chan []byte, queueSize),
+	}
+
+	// To avoid GC churn, downstream packet buffers are allocated
+	// once and reused. Available buffers are sent to the freeBuffers
+	// channel. When a packet is enqueued, a buffer is obtained from
+	// freeBuffers and sent to packets.
+	// TODO: allocate on first use? if the full queue size is not
+	// often used, preallocating all buffers is unnecessary.
+
+	for i := 0; i < queueSize; i++ {
+		queue.freeBuffers <- make([]byte, MTU)
+	}
+
+	return queue
+}
+
+// Enqueue enqueues the packet. The contents of packet are assumed
+// to be <= MTU and are copied into a preallocated, free packet
+// buffer area. If the queue is full, the packet is dropped.
+// Enqueue returns false if it receives runContext.Done().
+func (queue *PacketQueue) Enqueue(packet []byte) bool {
+
+	var packetBuffer []byte
+	select {
+	case packetBuffer = <-queue.freeBuffers:
+	case <-queue.runContext.Done():
+		return false
+	default:
+		// Queue is full, so drop packet.
+		return true
+	}
+
+	// Reuse the preallocated packet buffer. This slice indexing
+	// assumes the size of the packet <= MTU and the preallocated
+	// capacity == MTU.
+	packetBuffer = packetBuffer[0:len(packet)]
+	copy(packetBuffer, packet)
+
+	// This won't block: both freeBuffers/packets have queue-size
+	// capacity, and only queue-size packet buffers exist.
+	queue.packets <- packetBuffer
+
+	return true
+}
+
+// Dequeue waits until a packet is available and then dequeues and
+// returns it. The returned packet buffer remains part of the
+// PacketQueue and the caller must call Replace when done with the
+// packet.
+// Dequeue unblocks and returns false if it receives runContext.Done().
+func (queue *PacketQueue) Dequeue() ([]byte, bool) {
+	var packet []byte
+	select {
+	case packet = <-queue.packets:
+		return packet, true
+	case <-queue.runContext.Done():
+	}
+	return nil, false
+}
+
+// Replace returns a dequeued packet buffer to the free list. It
+// must be called for all, and must be called only with packets
+// returned by Dequeue.
+func (queue *PacketQueue) Replace(packet []byte) {
+
+	// This won't block (as long as it is a Dequeue return value).
+	queue.freeBuffers <- packet
+}
+
+// ClientConfig specifies the configuration of a packet tunnel client.
+type ClientConfig struct {
+
+	// Logger is used for logging events and metrics.
+	Logger common.Logger
+
+	// SudoNetworkConfigCommands specifies whether to use "sudo"
+	// when executing network configuration commands. See description
+	// for ServerConfig.SudoNetworkConfigCommands.
+	SudoNetworkConfigCommands bool
+
+	// MTU is the packet MTU value to use; this value
+	// should be obtained from the packet tunnel server.
+	// When MTU is 0, a default value is used.
+	MTU int
+
+	// Transport is an established transport channel that
+	// will be used to relay packets to and from a packet
+	// tunnel server.
+	Transport io.ReadWriteCloser
+
+	// TunFileDescriptor specifies a file descriptor to use to
+	// read and write packets to be relayed to the client. When
+	// TunFileDescriptor is specified, the Client will use this
+	// existing tun device and not create its own; in this case,
+	// network address and routing configuration is not performed
+	// by the Client. As the packet tunnel server performs
+	// transparent source IP address and DNS rewriting, the tun
+	// device may have any assigned IP address, but should be
+	// configured with the given MTU; and DNS should be configured
+	// to use the transparent DNS target resolver addresses.
+	// Set TunFileDescriptor to <= 0 to ignore this parameter
+	// and create and configure a tun device.
+	TunFileDescriptor int
+
+	// TunDeviceBridge specifies a DeviceBridge to use to read
+	// and write packets. Client operation is the same as when a
+	// TunFileDescriptor is used.
+	TunDeviceBridge *DeviceBridge
+
+	// IPv4AddressCIDR is the IPv4 address and netmask to
+	// assign to a newly created tun device.
+	IPv4AddressCIDR string
+
+	// IPv6AddressCIDR is the IPv6 address and prefix to
+	// assign to a newly created tun device.
+	IPv6AddressCIDR string
+
+	// RouteDestinations are hosts (IPs) or networks (CIDRs)
+	// to be configured to be routed through a newly
+	// created tun device.
+	RouteDestinations []string
+}
+
+// Client is a packet tunnel client. A packet tunnel client
+// relays packets between a local tun device and a packet
+// tunnel server via a transport channel.
+type Client struct {
+	config      *ClientConfig
+	device      *Device
+	channel     *Channel
+	metrics     *packetMetrics
+	runContext  context.Context
+	stopRunning context.CancelFunc
+	workers     *sync.WaitGroup
+}
+
+// NewClient initializes a new Client. Unless using the
+// TunFileDescriptor configuration parameter, a new tun
+// device is created for the client.
+func NewClient(config *ClientConfig) (*Client, error) {
+
+	var device *Device
+	var err error
+
+	if config.TunFileDescriptor > 0 {
+		device, err = NewClientDeviceFromFD(config)
+	} else if config.TunDeviceBridge != nil {
+		device, err = NewClientDeviceFromBridge(config)
+	} else {
+		device, err = NewClientDevice(config)
+	}
+
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	runContext, stopRunning := context.WithCancel(context.Background())
+
+	return &Client{
+		config:      config,
+		device:      device,
+		channel:     NewChannel(config.Transport, getMTU(config.MTU)),
+		metrics:     new(packetMetrics),
+		runContext:  runContext,
+		stopRunning: stopRunning,
+		workers:     new(sync.WaitGroup),
+	}, nil
+}
+
+// Start starts a client and returns with it running.
+func (client *Client) Start() {
+
+	client.config.Logger.WithContext().Info("starting")
+
+	client.workers.Add(1)
+	go func() {
+		defer client.workers.Done()
+		for {
+			readPacket, err := client.device.ReadPacket()
+
+			select {
+			case <-client.runContext.Done():
+				// No error is logged as shutdown may have interrupted read.
+				return
+			default:
+			}
+
+			if err != nil {
+				client.config.Logger.WithContextFields(
+					common.LogFields{"error": err}).Info("read device packet failed")
+				// May be temporary error condition, keep working.
+				continue
+			}
+
+			// processPacket will check for packets the server will reject
+			// and drop those without sending.
+
+			// Limitation: packet metrics, including successful relay count,
+			// are incremented _before_ the packet is written to the channel.
+
+			if !processPacket(
+				client.metrics,
+				nil,
+				packetDirectionClientUpstream,
+				readPacket) {
+				continue
+			}
+
+			err = client.channel.WritePacket(readPacket)
+
+			if err != nil {
+				client.config.Logger.WithContextFields(
+					common.LogFields{"error": err}).Info("write channel packet failed")
+				// Only this goroutine exits and no alarm is raised. It's assumed
+				// that if the channel fails, the outer client will know about it.
+				return
+			}
+		}
+	}()
+
+	client.workers.Add(1)
+	go func() {
+		defer client.workers.Done()
+		for {
+			readPacket, err := client.channel.ReadPacket()
+
+			select {
+			case <-client.runContext.Done():
+				// No error is logged as shutdown may have interrupted read.
+				return
+			default:
+			}
+
+			if err != nil {
+				client.config.Logger.WithContextFields(
+					common.LogFields{"error": err}).Info("read channel packet failed")
+				// Only this goroutine exits and no alarm is raised. It's assumed
+				// that if the channel fails, the outer client will know about it.
+				return
+			}
+
+			if !processPacket(
+				client.metrics,
+				nil,
+				packetDirectionClientDownstream,
+				readPacket) {
+				continue
+			}
+
+			err = client.device.WritePacket(readPacket)
+
+			if err != nil {
+				client.config.Logger.WithContextFields(
+					common.LogFields{"error": err}).Info("write device packet failed")
+				// May be temporary error condition, keep working. The packet is
+				// most likely dropped.
+				continue
+			}
+		}
+	}()
+}
+
+// Stop halts a running client.
+func (client *Client) Stop() {
+
+	client.config.Logger.WithContext().Info("stopping")
+
+	client.stopRunning()
+	client.device.Close()
+	client.channel.Close()
+
+	client.workers.Wait()
+
+	client.metrics.checkpoint(
+		client.config.Logger, "packet_metrics", packetMetricsAll)
+
+	client.config.Logger.WithContext().Info("stopped")
+}
+
+/*
+   Packet offset constants in getPacketDestinationIPAddress and
+   processPacket are from the following RFC definitions.
+
+
+   IPv4 header: https://tools.ietf.org/html/rfc791
+
+    0                   1                   2                   3
+    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |Version|  IHL  |Type of Service|          Total Length         |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |         Identification        |Flags|      Fragment Offset    |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |  Time to Live |    Protocol   |         Header Checksum       |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                       Source Address                          |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                    Destination Address                        |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                    Options                    |    Padding    |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+   IPv6 header: https://tools.ietf.org/html/rfc2460
+
+    0                   1                   2                   3
+    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |Version| Traffic Class |           Flow Label                  |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |         Payload Length        |  Next Header  |   Hop Limit   |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                                                               |
+   +                                                               +
+   |                                                               |
+   +                         Source Address                        +
+   |                                                               |
+   +                                                               +
+   |                                                               |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                                                               |
+   +                                                               +
+   |                                                               |
+   +                      Destination Address                      +
+   |                                                               |
+   +                                                               +
+   |                                                               |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+   TCP header: https://tools.ietf.org/html/rfc793
+
+    0                   1                   2                   3
+    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |          Source Port          |       Destination Port        |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                        Sequence Number                        |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                    Acknowledgment Number                      |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |  Data |           |U|A|P|R|S|F|                               |
+   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
+   |       |           |G|K|H|T|N|N|                               |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |           Checksum            |         Urgent Pointer        |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                    Options                    |    Padding    |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+   |                             data                              |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+   UDP header: https://tools.ietf.org/html/rfc768
+
+                  0      7 8     15 16    23 24    31
+                 +--------+--------+--------+--------+
+                 |     Source      |   Destination   |
+                 |      Port       |      Port       |
+                 +--------+--------+--------+--------+
+                 |                 |                 |
+                 |     Length      |    Checksum     |
+                 +--------+--------+--------+--------+
+                 |
+                 |          data octets ...
+                 +---------------- ...
+*/
+
+const (
+	packetDirectionServerUpstream   = 0
+	packetDirectionServerDownstream = 1
+	packetDirectionClientUpstream   = 2
+	packetDirectionClientDownstream = 3
+
+	internetProtocolTCP = 6
+	internetProtocolUDP = 17
+
+	portNumberDNS = 53
+
+	packetRejectNoSession          = 0
+	packetRejectDestinationAddress = 1
+	packetRejectLength             = 2
+	packetRejectVersion            = 3
+	packetRejectOptions            = 4
+	packetRejectProtocol           = 5
+	packetRejectTCPProtocolLength  = 6
+	packetRejectUDPProtocolLength  = 7
+	packetRejectTCPPort            = 8
+	packetRejectUDPPort            = 9
+	packetRejectNoOriginalAddress  = 10
+	packetRejectNoDNSResolvers     = 11
+	packetRejectReasonCount        = 12
+	packetOk                       = 12
+)
+
+type packetDirection int
+type internetProtocol int
+type packetRejectReason int
+
+func packetRejectReasonDescription(reason packetRejectReason) string {
+
+	// Description strings follow the metrics naming
+	// convention: all lowercase; underscore seperators.
+
+	switch reason {
+	case packetRejectNoSession:
+		return "no_session"
+	case packetRejectDestinationAddress:
+		return "invalid_destination_address"
+	case packetRejectLength:
+		return "invalid_ip_packet_length"
+	case packetRejectVersion:
+		return "invalid_ip_header_version"
+	case packetRejectOptions:
+		return "invalid_ip_header_options"
+	case packetRejectProtocol:
+		return "invalid_ip_header_protocol"
+	case packetRejectTCPProtocolLength:
+		return "invalid_tcp_packet_length"
+	case packetRejectUDPProtocolLength:
+		return "invalid_tcp_packet_length"
+	case packetRejectTCPPort:
+		return "disallowed_tcp_destination_port"
+	case packetRejectUDPPort:
+		return "disallowed_udp_destination_port"
+	case packetRejectNoOriginalAddress:
+		return "no_original_address"
+	case packetRejectNoDNSResolvers:
+		return "no_dns_resolvers"
+	}
+
+	return "unknown_reason"
+}
+
+// Caller: the destination IP address return value is
+// a slice of the packet input value and only valid while
+// the packet buffer remains valid.
+func getPacketDestinationIPAddress(
+	metrics *packetMetrics,
+	direction packetDirection,
+	packet []byte) (net.IP, bool) {
+
+	// TODO: this function duplicates a subset of the packet
+	// parsing code in processPacket. Refactor to reuse code;
+	// also, both getPacketDestinationIPAddress and processPacket
+	// are called for some packets; refactor to only parse once.
+
+	if len(packet) < 1 {
+		metrics.rejectedPacket(direction, packetRejectLength)
+		return nil, false
+	}
+
+	version := packet[0] >> 4
+
+	if version != 4 && version != 6 {
+		metrics.rejectedPacket(direction, packetRejectVersion)
+		return nil, false
+	}
+
+	if version == 4 {
+		if len(packet) < 20 {
+			metrics.rejectedPacket(direction, packetRejectLength)
+			return nil, false
+		}
+
+		return packet[16:20], true
+
+	} else { // IPv6
+		if len(packet) < 40 {
+			metrics.rejectedPacket(direction, packetRejectLength)
+			return nil, false
+		}
+
+		return packet[24:40], true
+	}
+
+	return nil, false
+}
+
+func processPacket(
+	metrics *packetMetrics,
+	session *session,
+	direction packetDirection,
+	packet []byte) bool {
+
+	// Parse and validate packets and perform either upstream
+	// or downstream rewriting.
+	// Failures may result in partially rewritten packets.
+
+	// Must have an IP version field.
+
+	if len(packet) < 1 {
+		metrics.rejectedPacket(direction, packetRejectLength)
+		return false
+	}
+
+	version := packet[0] >> 4
+
+	// Must be IPv4 or IPv6.
+
+	if version != 4 && version != 6 {
+		metrics.rejectedPacket(direction, packetRejectVersion)
+		return false
+	}
+
+	var protocol internetProtocol
+	var sourceIPAddress, destinationIPAddress net.IP
+	var sourcePort, destinationPort uint16
+	var IPChecksum, TCPChecksum, UDPChecksum []byte
+
+	if version == 4 {
+
+		// IHL must be 5: options are not supported; a fixed
+		// 20 byte header is expected.
+
+		headerLength := packet[0] & 0x0F
+
+		if headerLength != 5 {
+			metrics.rejectedPacket(direction, packetRejectOptions)
+			return false
+		}
+
+		if len(packet) < 20 {
+			metrics.rejectedPacket(direction, packetRejectLength)
+			return false
+		}
+
+		// Protocol must be TCP or UDP.
+
+		protocol = internetProtocol(packet[9])
+
+		if protocol == internetProtocolTCP {
+			if len(packet) < 40 {
+				metrics.rejectedPacket(direction, packetRejectTCPProtocolLength)
+				return false
+			}
+		} else if protocol == internetProtocolUDP {
+			if len(packet) < 28 {
+				metrics.rejectedPacket(direction, packetRejectUDPProtocolLength)
+				return false
+			}
+		} else {
+			metrics.rejectedPacket(direction, packetRejectProtocol)
+			return false
+		}
+
+		// Slices reference packet bytes to be rewritten.
+
+		sourceIPAddress = packet[12:16]
+		destinationIPAddress = packet[16:20]
+		IPChecksum = packet[10:12]
+
+		// Port numbers have the same offset in TCP and UDP.
+
+		sourcePort = binary.BigEndian.Uint16(packet[20:22])
+		destinationPort = binary.BigEndian.Uint16(packet[22:24])
+
+		if protocol == internetProtocolTCP {
+			TCPChecksum = packet[36:38]
+		} else { // UDP
+			UDPChecksum = packet[26:28]
+		}
+
+	} else { // IPv6
+
+		if len(packet) < 40 {
+			metrics.rejectedPacket(direction, packetRejectLength)
+			return false
+		}
+
+		// Next Header must be TCP or UDP.
+
+		nextHeader := packet[6]
+
+		protocol = internetProtocol(nextHeader)
+
+		if protocol == internetProtocolTCP {
+			if len(packet) < 60 {
+				metrics.rejectedPacket(direction, packetRejectTCPProtocolLength)
+				return false
+			}
+		} else if protocol == internetProtocolUDP {
+			if len(packet) < 48 {
+				metrics.rejectedPacket(direction, packetRejectUDPProtocolLength)
+				return false
+			}
+		} else {
+			metrics.rejectedPacket(direction, packetRejectProtocol)
+			return false
+		}
+
+		// Slices reference packet bytes to be rewritten.
+
+		sourceIPAddress = packet[8:24]
+		destinationIPAddress = packet[24:40]
+
+		// Port numbers have the same offset in TCP and UDP.
+
+		sourcePort = binary.BigEndian.Uint16(packet[40:42])
+		destinationPort = binary.BigEndian.Uint16(packet[42:44])
+
+		if protocol == internetProtocolTCP {
+			TCPChecksum = packet[56:58]
+		} else { // UDP
+			UDPChecksum = packet[46:48]
+		}
+	}
+
+	var upstreamIPAddress net.IP
+	if direction == packetDirectionServerUpstream {
+
+		upstreamIPAddress = destinationIPAddress
+
+	} else if direction == packetDirectionServerDownstream {
+
+		upstreamIPAddress = sourceIPAddress
+	}
+
+	// Enforce traffic rules (allowed TCP/UDP ports).
+
+	checkPort := 0
+	if direction == packetDirectionServerUpstream ||
+		direction == packetDirectionClientUpstream {
+
+		checkPort = int(destinationPort)
+
+	} else if direction == packetDirectionServerDownstream ||
+		direction == packetDirectionClientDownstream {
+
+		checkPort = int(sourcePort)
+	}
+
+	if protocol == internetProtocolTCP {
+
+		if checkPort == 0 ||
+			(session != nil &&
+				!session.checkAllowedTCPPortFunc(upstreamIPAddress, checkPort)) {
+
+			metrics.rejectedPacket(direction, packetRejectTCPPort)
+			return false
+		}
+
+	} else if protocol == internetProtocolUDP {
+
+		if checkPort == 0 ||
+			(session != nil &&
+				!session.checkAllowedUDPPortFunc(upstreamIPAddress, checkPort)) {
+
+			metrics.rejectedPacket(direction, packetRejectUDPPort)
+			return false
+		}
+	}
+
+	// Enforce no localhost, multicast or broadcast packets; and
+	// no client-to-client packets.
+
+	if !destinationIPAddress.IsGlobalUnicast() ||
+
+		(direction == packetDirectionServerUpstream &&
+			((version == 4 &&
+				!destinationIPAddress.Equal(transparentDNSResolverIPv4Address) &&
+				privateSubnetIPv4.Contains(destinationIPAddress)) ||
+				(version == 6 &&
+					!destinationIPAddress.Equal(transparentDNSResolverIPv6Address) &&
+					privateSubnetIPv6.Contains(destinationIPAddress)))) {
+
+		metrics.rejectedPacket(direction, packetRejectDestinationAddress)
+		return false
+	}
+
+	// Configure rewriting.
+
+	var checksumAccumulator int32
+	var rewriteSourceIPAddress, rewriteDestinationIPAddress net.IP
+
+	if direction == packetDirectionServerUpstream {
+
+		// Store original source IP address to be replaced in
+		// downstream rewriting.
+
+		if version == 4 {
+			session.setOriginalIPv4AddressIfNotSet(sourceIPAddress)
+			rewriteSourceIPAddress = session.assignedIPv4Address
+		} else { // version == 6
+			session.setOriginalIPv6AddressIfNotSet(sourceIPAddress)
+			rewriteSourceIPAddress = session.assignedIPv6Address
+		}
+
+		// Rewrite DNS packets destinated for the transparent DNS target
+		// addresses to go to one of the server's resolvers.
+
+		if destinationPort == portNumberDNS {
+			if version == 4 && destinationIPAddress.Equal(transparentDNSResolverIPv4Address) {
+				numResolvers := len(session.DNSResolverIPv4Addresses)
+				if numResolvers > 0 {
+					rewriteDestinationIPAddress = session.DNSResolverIPv4Addresses[rand.Intn(numResolvers)]
+				} else {
+					metrics.rejectedPacket(direction, packetRejectNoDNSResolvers)
+					return false
+				}
+
+			} else if version == 6 && destinationIPAddress.Equal(transparentDNSResolverIPv6Address) {
+				numResolvers := len(session.DNSResolverIPv6Addresses)
+				if numResolvers > 0 {
+					rewriteDestinationIPAddress = session.DNSResolverIPv6Addresses[rand.Intn(numResolvers)]
+				} else {
+					metrics.rejectedPacket(direction, packetRejectNoDNSResolvers)
+					return false
+				}
+			}
+		}
+
+	} else if direction == packetDirectionServerDownstream {
+
+		// Destination address will be original source address.
+
+		if version == 4 {
+			rewriteDestinationIPAddress = session.getOriginalIPv4Address()
+		} else { // version == 6
+			rewriteDestinationIPAddress = session.getOriginalIPv6Address()
+		}
+
+		if rewriteDestinationIPAddress == nil {
+			metrics.rejectedPacket(direction, packetRejectNoOriginalAddress)
+			return false
+		}
+
+		// Source address for DNS packets from the server's resolvers
+		// will be changed to transparent DNS target address.
+
+		// Limitation: responses to client DNS packets _originally
+		// destined_ for a resolver in GetDNSResolverIPv4Addresses will
+		// be lost. This would happen if some process on the client
+		// ignores the system set DNS values; and forces use of the same
+		// resolvers as the server.
+
+		if sourcePort == portNumberDNS {
+			if version == 4 {
+				for _, IPAddress := range session.DNSResolverIPv4Addresses {
+					if sourceIPAddress.Equal(IPAddress) {
+						rewriteSourceIPAddress = transparentDNSResolverIPv4Address
+						break
+					}
+				}
+			} else if version == 6 {
+				for _, IPAddress := range session.DNSResolverIPv6Addresses {
+					if sourceIPAddress.Equal(IPAddress) {
+						rewriteSourceIPAddress = transparentDNSResolverIPv6Address
+						break
+					}
+				}
+			}
+		}
+	}
+
+	// Apply rewrites. IP (v4 only) and TCP/UDP all have packet
+	// checksums which are updated to relect the rewritten headers.
+
+	if rewriteSourceIPAddress != nil {
+		checksumAccumulate(sourceIPAddress, false, &checksumAccumulator)
+		copy(sourceIPAddress, rewriteSourceIPAddress)
+		checksumAccumulate(sourceIPAddress, true, &checksumAccumulator)
+	}
+
+	if rewriteDestinationIPAddress != nil {
+		checksumAccumulate(destinationIPAddress, false, &checksumAccumulator)
+		copy(destinationIPAddress, rewriteDestinationIPAddress)
+		checksumAccumulate(destinationIPAddress, true, &checksumAccumulator)
+	}
+
+	if rewriteSourceIPAddress != nil || rewriteDestinationIPAddress != nil {
+
+		// IPv6 doesn't have an IP header checksum.
+		if version == 4 {
+			checksumAdjust(IPChecksum, checksumAccumulator)
+		}
+
+		if protocol == internetProtocolTCP {
+			checksumAdjust(TCPChecksum, checksumAccumulator)
+		} else { // UDP
+			checksumAdjust(UDPChecksum, checksumAccumulator)
+		}
+	}
+
+	metrics.relayedPacket(direction, int(version), protocol, upstreamIPAddress, len(packet))
+
+	return true
+}
+
+// Checksum code based on https://github.com/OpenVPN/openvpn:
+/*
+OpenVPN (TM) -- An Open Source VPN daemon
+
+Copyright (C) 2002-2017 OpenVPN Technologies, Inc. <sales@openvpn.net>
+
+OpenVPN license:
+----------------
+
+OpenVPN is distributed under the GPL license version 2 (see COPYRIGHT.GPL).
+*/
+
+func checksumAccumulate(data []byte, newData bool, accumulator *int32) {
+
+	// Based on ADD_CHECKSUM_32 and SUB_CHECKSUM_32 macros from OpenVPN:
+	// https://github.com/OpenVPN/openvpn/blob/58716979640b5d8850b39820f91da616964398cc/src/openvpn/proto.h#L177
+
+	// Assumes length of data is factor of 4.
+
+	for i := 0; i < len(data); i += 4 {
+		var word uint32
+		word = uint32(data[i+0])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])
+		if newData {
+			*accumulator -= int32(word & 0xFFFF)
+			*accumulator -= int32(word >> 16)
+		} else {
+			*accumulator += int32(word & 0xFFFF)
+			*accumulator += int32(word >> 16)
+		}
+	}
+}
+
+func checksumAdjust(checksumData []byte, accumulator int32) {
+
+	// Based on ADJUST_CHECKSUM macro from OpenVPN:
+	// https://github.com/OpenVPN/openvpn/blob/58716979640b5d8850b39820f91da616964398cc/src/openvpn/proto.h#L177
+
+	// Assumes checksumData is 2 byte slice.
+
+	checksum := uint16(checksumData[0])<<8 | uint16(checksumData[1])
+
+	accumulator += int32(checksum)
+	if accumulator < 0 {
+		accumulator = -accumulator
+		accumulator = (accumulator >> 16) + (accumulator & 0xFFFF)
+		accumulator += accumulator >> 16
+		checksum = uint16(^accumulator)
+	} else {
+		accumulator = (accumulator >> 16) + (accumulator & 0xFFFF)
+		accumulator += accumulator >> 16
+		checksum = uint16(accumulator)
+	}
+
+	checksumData[0] = byte(checksum >> 8)
+	checksumData[1] = byte(checksum & 0xFF)
+}
+
+/*
+
+packet debugging snippet:
+
+	import (
+        "github.com/google/gopacket"
+        "github.com/google/gopacket/layers"
+	)
+
+
+	func tracePacket(where string, packet []byte) {
+		var p gopacket.Packet
+		if len(packet) > 0 && packet[0]>>4 == 4 {
+			p = gopacket.NewPacket(packet, layers.LayerTypeIPv4, gopacket.Default)
+		} else {
+			p = gopacket.NewPacket(packet, layers.LayerTypeIPv6, gopacket.Default)
+		}
+		fmt.Printf("[%s packet]:\n%s\n\n", where, p)
+	}
+*/
+
+// Device manages a tun device. It handles packet I/O using static,
+// preallocated buffers to avoid GC churn.
+type Device struct {
+	name           string
+	usingBridge    bool
+	deviceIO       io.ReadWriteCloser
+	inboundBuffer  []byte
+	outboundBuffer []byte
+}
+
+// NewServerDevice creates and configures a new server tun device.
+// Since the server uses fixed address spaces, only one server
+// device may exist per host.
+func NewServerDevice(config *ServerConfig) (*Device, error) {
+
+	deviceIO, deviceName, err := createTunDevice()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	err = configureServerInterface(config, deviceName)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return newDevice(
+		deviceName,
+		false,
+		deviceIO,
+		getMTU(config.MTU)), nil
+}
+
+// NewClientDevice creates and configures a new client tun device.
+// Multiple client tun devices may exist per host.
+func NewClientDevice(config *ClientConfig) (*Device, error) {
+
+	deviceIO, deviceName, err := createTunDevice()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	err = configureClientInterface(
+		config, deviceName)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return newDevice(
+		deviceName,
+		false,
+		deviceIO,
+		getMTU(config.MTU)), nil
+}
+
+func newDevice(
+	name string,
+	usingBridge bool,
+	deviceIO io.ReadWriteCloser,
+	MTU int) *Device {
+
+	return &Device{
+		name:           name,
+		usingBridge:    usingBridge,
+		deviceIO:       deviceIO,
+		inboundBuffer:  makeDeviceInboundBuffer(usingBridge, MTU),
+		outboundBuffer: makeDeviceOutboundBuffer(usingBridge, MTU),
+	}
+}
+
+// NewClientDeviceFromFD wraps an existing tun device.
+func NewClientDeviceFromFD(config *ClientConfig) (*Device, error) {
+
+	dupFD, err := dupFD(config.TunFileDescriptor)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	file := os.NewFile(uintptr(dupFD), "")
+
+	MTU := getMTU(config.MTU)
+
+	return &Device{
+		name:           "",
+		usingBridge:    false,
+		deviceIO:       file,
+		inboundBuffer:  makeDeviceInboundBuffer(false, MTU),
+		outboundBuffer: makeDeviceOutboundBuffer(false, MTU),
+	}, nil
+}
+
+// NewClientDeviceFromBridge wraps an existing tun device that is
+// accessed via a DeviceBridge.
+func NewClientDeviceFromBridge(config *ClientConfig) (*Device, error) {
+	return newDevice(
+		"",
+		true,
+		config.TunDeviceBridge,
+		getMTU(config.MTU)), nil
+}
+
+// Name returns the interface name for a created tun device,
+// or returns "" for a device created by NewClientDeviceFromFD
+// or NewClientDeviceFromBridge.
+// The interface name may be used for additional network and
+// routing configuration.
+func (device *Device) Name() string {
+	return device.name
+}
+
+// ReadPacket reads one full packet from the tun device. The
+// return value is a slice of a static, reused buffer, so the
+// value is only valid until the next ReadPacket call.
+// Concurrent calls to ReadPacket are not supported.
+func (device *Device) ReadPacket() ([]byte, error) {
+
+	// readTunPacket performs the platform dependent
+	// packet read operation.
+	offset, size, err := device.readTunPacket()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return device.inboundBuffer[offset : offset+size], nil
+}
+
+// WritePacket writes one full packet to the tun device.
+// Concurrent calls to WritePacket are not supported.
+func (device *Device) WritePacket(packet []byte) error {
+
+	// writeTunPacket performs the platform dependent
+	// packet write operation.
+	err := device.writeTunPacket(packet)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+// Close interrupts any blocking Read/Write calls and
+// tears down the tun device.
+func (device *Device) Close() error {
+
+	// TODO: dangerous data race exists until Go 1.9
+	//
+	// https://github.com/golang/go/issues/7970
+	//
+	// Unlike net.Conns, os.File doesn't use the poller and
+	// it's not correct to use Close() cannot to interrupt
+	// blocking reads and writes. This changes in Go 1.9,
+	// which changes os.File to use the poller.
+	//
+	// Severity may be high since there's a remote possibility
+	// that a Write could send a packet to wrong fd, including
+	// sending as plaintext to a network socket.
+	//
+	// As of this writing, we do not expect to put this
+	// code into production before Go 1.9 is released. Since
+	// interrupting blocking Read/Writes is necessary, the
+	// race condition is left as-is.
+	//
+	// This appears running tun_test with the race detector
+	// enabled:
+	//
+	// ==================
+	// WARNING: DATA RACE
+	// Write at 0x00c4200ce220 by goroutine 16:
+	//   os.(*file).close()
+	//       /usr/local/go/src/os/file_unix.go:143 +0x10a
+	//   os.(*File).Close()
+	//       /usr/local/go/src/os/file_unix.go:132 +0x55
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*Device).Close()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun.go:1999 +0x53
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*Client).Stop()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun.go:1314 +0x1a8
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*testClient).stop()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun_test.go:426 +0x77
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.testTunneledTCP.func1()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun_test.go:172 +0x550
+	//
+	// Previous read at 0x00c4200ce220 by goroutine 100:
+	//   os.(*File).Read()
+	//       /usr/local/go/src/os/file.go:98 +0x70
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*Device).readTunPacket()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun_linux.go:109 +0x84
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*Device).ReadPacket()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun.go:1974 +0x3c
+	//   _/root/psiphon-tunnel-core/psiphon/common/tun.(*Client).Start.func1()
+	//       /root/psiphon-tunnel-core/psiphon/common/tun/tun.go:1224 +0xaf
+	// ==================
+
+	return device.deviceIO.Close()
+}
+
+// DeviceBridge is a bridge between a function-based packet I/O
+// API, such as Apple's NEPacketTunnelFlow, and the Device interface,
+// which expects an io.ReadWriteCloser.
+//
+// The API side provides a sendToDevice call back to write packets
+// to the tun device and calls ReceivedFromDevice with packets that
+// have been read from the tun device. The Device uses Read/Write/Close.
+type DeviceBridge struct {
+	runContext   context.Context
+	stopRunning  context.CancelFunc
+	readPackets  *PacketQueue
+	writeMutex   sync.Mutex
+	sendToDevice func(p []byte)
+}
+
+// NewDeviceBridge creates a new DeviceBridge.
+// Calls to sendToDevice are serialized, so it need not be safe for
+// concurrent access. Calls to sendToDevice will block calls to
+// Write and will not be interrupted by Close; sendToDevice _should_
+// not block.
+func NewDeviceBridge(
+	MTU, readPacketQueueSize int,
+	sendToDevice func(p []byte)) *DeviceBridge {
+
+	runContext, stopRunning := context.WithCancel(context.Background())
+
+	if readPacketQueueSize <= 0 {
+		readPacketQueueSize = DEFAULT_BRIDGE_PACKET_QUEUE_SIZE
+	}
+
+	return &DeviceBridge{
+		runContext:   runContext,
+		stopRunning:  stopRunning,
+		readPackets:  NewPacketQueue(runContext, MTU, readPacketQueueSize),
+		sendToDevice: sendToDevice,
+	}
+}
+
+// ReceivedFromDevice accepts packets read from the tun device. Packets
+// are enqueued for subsequent return to callers of Read. ReceivedFromDevice
+// does not block when the queue is full or waiting for a Read call. When
+// the queue is full, packets are dropped.
+func (bridge *DeviceBridge) ReceivedFromDevice(p []byte) {
+	_ = bridge.readPackets.Enqueue(p)
+}
+
+// Read blocks until an enqueued packet is available or the DeviceBridge
+// is closed.
+func (bridge *DeviceBridge) Read(p []byte) (int, error) {
+	packet, ok := bridge.readPackets.Dequeue()
+	if !ok {
+		return 0, common.ContextError(errors.New("bridge is closed"))
+	}
+
+	// Assumes both p and packet are <= MTU
+	copy(p, packet)
+
+	bridge.readPackets.Replace(packet)
+
+	return len(p), nil
+}
+
+// Write calls through to sendToDevice. Close will not interrupt a
+// blocking call to writeToDevice.
+func (bridge *DeviceBridge) Write(p []byte) (int, error) {
+
+	// Use mutex since writeToDevice isn't required
+	// to be safe for concurrent calls.
+	bridge.writeMutex.Lock()
+	defer bridge.writeMutex.Unlock()
+
+	bridge.sendToDevice(p)
+
+	return len(p), nil
+}
+
+// Close interrupts blocking reads.
+func (bridge *DeviceBridge) Close() error {
+	bridge.stopRunning()
+	return nil
+}
+
+// Channel manages packet transport over a communications channel.
+// Any io.ReadWriteCloser can provide transport. In psiphond, the
+// io.ReadWriteCloser will be an SSH channel. Channel I/O frames
+// packets with a length header and uses static, preallocated
+// buffers to avoid GC churn.
+type Channel struct {
+	transport      io.ReadWriteCloser
+	inboundBuffer  []byte
+	outboundBuffer []byte
+}
+
+// IP packets cannot be larger that 64K, so a 16-bit length
+// header is sufficient.
+const (
+	channelHeaderSize = 2
+)
+
+// NewChannel initializes a new Channel.
+func NewChannel(transport io.ReadWriteCloser, MTU int) *Channel {
+	return &Channel{
+		transport:      transport,
+		inboundBuffer:  make([]byte, channelHeaderSize+MTU),
+		outboundBuffer: make([]byte, channelHeaderSize+MTU),
+	}
+}
+
+// ReadPacket reads one full packet from the channel. The
+// return value is a slice of a static, reused buffer, so the
+// value is only valid until the next ReadPacket call.
+// Concurrent calls to ReadPacket are not supported.
+func (channel *Channel) ReadPacket() ([]byte, error) {
+
+	header := channel.inboundBuffer[0:channelHeaderSize]
+	_, err := io.ReadFull(channel.transport, header)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	size := int(binary.BigEndian.Uint16(header))
+	if size > len(channel.inboundBuffer[channelHeaderSize:]) {
+		return nil, common.ContextError(fmt.Errorf("packet size exceeds MTU: %d", size))
+	}
+
+	packet := channel.inboundBuffer[channelHeaderSize : channelHeaderSize+size]
+	_, err = io.ReadFull(channel.transport, packet)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return packet, nil
+}
+
+// WritePacket writes one full packet to the channel.
+// Concurrent calls to WritePacket are not supported.
+func (channel *Channel) WritePacket(packet []byte) error {
+
+	// Flow control assumed to be provided by the transport. In the case
+	// of SSH, the channel window size will determine whether the packet
+	// data is transmitted immediately or whether the transport.Write will
+	// block. When the channel window is full and transport.Write blocks,
+	// the sender's tun device will not be read (client case) or the send
+	// queue will fill (server case) and packets will be dropped. In this
+	// way, the channel window size will influence the TCP window size for
+	// tunneled traffic.
+
+	// Writes are not batched up but dispatched immediately. When the
+	// transport is an SSH channel, the overhead per tunneled packet includes:
+	//
+	// - SSH_MSG_CHANNEL_DATA: 5 bytes (https://tools.ietf.org/html/rfc4254#section-5.2)
+	// - SSH packet: ~28 bytes (https://tools.ietf.org/html/rfc4253#section-5.3), with MAC
+	// - TCP/IP transport for SSH: 40 bytes for IPv4
+	//
+	// Also, when the transport in an SSH channel, batching of packets will
+	// naturally occur when the SSH channel window is full.
+
+	// Assumes MTU <= 64K and len(packet) <= MTU
+
+	size := len(packet)
+	binary.BigEndian.PutUint16(channel.outboundBuffer, uint16(size))
+	copy(channel.outboundBuffer[channelHeaderSize:], packet)
+	_, err := channel.transport.Write(channel.outboundBuffer[0 : channelHeaderSize+size])
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+// Close interrupts any blocking Read/Write calls and
+// closes the channel transport.
+func (channel *Channel) Close() error {
+	return channel.transport.Close()
+}

+ 449 - 0
psiphon/common/tun/tun_darwin.go

@@ -0,0 +1,449 @@
+/*
+ * Copyright (c) 2017, 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/>.
+ *
+ */
+
+// Darwin utun code based on https://github.com/songgao/water:
+/*
+Copyright (c) 2016, Song Gao <song@gao.io>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of water nor the names of its contributors may be used to
+  endorse or promote products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+package tun
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"strconv"
+	"syscall"
+	"unsafe"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+const (
+	DEFAULT_PUBLIC_INTERFACE_NAME = "en0"
+)
+
+func makeDeviceInboundBuffer(usingBridge bool, MTU int) []byte {
+	if usingBridge {
+		// No utun packet header when using a bridge
+		return make([]byte, MTU)
+	}
+	// 4 extra bytes to read a utun packet header
+	return make([]byte, 4+MTU)
+}
+
+func makeDeviceOutboundBuffer(usingBridge bool, MTU int) []byte {
+	if usingBridge {
+		// No outbound buffer is used
+		return nil
+	}
+	// 4 extra bytes to write a utun packet header
+	return make([]byte, 4+MTU)
+}
+
+func createTunDevice() (io.ReadWriteCloser, string, error) {
+
+	// Prevent fork between creating fd and setting CLOEXEC
+	syscall.ForkLock.RLock()
+	defer syscall.ForkLock.RUnlock()
+
+	// Darwin utun code based on:
+	// https://github.com/songgao/water/blob/70591d249921d075889cc49aaef072987e6b354a/syscalls_darwin.go
+
+	// Definitions from <ioctl.h>, <sys/socket.h>, <sys/sys_domain.h>
+
+	const (
+		TUN_CONTROL_NAME = "com.apple.net.utun_control"
+		CTLIOCGINFO      = (0x40000000 | 0x80000000) | ((100 & 0x1fff) << 16) | uint32(byte('N'))<<8 | 3
+		TUNSIFMODE       = (0x80000000) | ((4 & 0x1fff) << 16) | uint32(byte('t'))<<8 | 94
+		PF_SYSTEM        = syscall.AF_SYSTEM
+		SYSPROTO_CONTROL = 2
+		AF_SYS_CONTROL   = 2
+		UTUN_OPT_IFNAME  = 2
+	)
+
+	fd, err := syscall.Socket(
+		PF_SYSTEM,
+		syscall.SOCK_DGRAM,
+		SYSPROTO_CONTROL)
+	if err != nil {
+		return nil, "", common.ContextError(err)
+	}
+
+	// Set CLOEXEC so file descriptor not leaked to network config command subprocesses
+	syscall.CloseOnExec(fd)
+
+	var tunControlName [96]byte
+	copy(tunControlName[:], TUN_CONTROL_NAME)
+
+	ctlInfo := struct {
+		ctlID   uint32
+		ctlName [96]byte
+	}{
+		0,
+		tunControlName,
+	}
+
+	_, _, errno := syscall.Syscall(
+		syscall.SYS_IOCTL,
+		uintptr(fd),
+		uintptr(CTLIOCGINFO),
+		uintptr(unsafe.Pointer(&ctlInfo)))
+	if errno != 0 {
+		return nil, "", common.ContextError(errno)
+	}
+
+	sockaddrCtlSize := 32
+	sockaddrCtl := struct {
+		scLen      uint8
+		scFamily   uint8
+		ssSysaddr  uint16
+		scID       uint32
+		scUnit     uint32
+		scReserved [5]uint32
+	}{
+		uint8(sockaddrCtlSize),
+		syscall.AF_SYSTEM,
+		AF_SYS_CONTROL,
+		ctlInfo.ctlID,
+		0,
+		[5]uint32{},
+	}
+
+	_, _, errno = syscall.RawSyscall(
+		syscall.SYS_CONNECT,
+		uintptr(fd),
+		uintptr(unsafe.Pointer(&sockaddrCtl)),
+		uintptr(sockaddrCtlSize))
+	if errno != 0 {
+		return nil, "", common.ContextError(errno)
+	}
+
+	ifNameSize := uintptr(16)
+	ifName := struct {
+		name [16]byte
+	}{}
+
+	_, _, errno = syscall.Syscall6(
+		syscall.SYS_GETSOCKOPT,
+		uintptr(fd),
+		SYSPROTO_CONTROL,
+		UTUN_OPT_IFNAME,
+		uintptr(unsafe.Pointer(&ifName)),
+		uintptr(unsafe.Pointer(&ifNameSize)),
+		0)
+	if errno != 0 {
+		return nil, "", common.ContextError(errno)
+	}
+
+	deviceName := string(ifName.name[:ifNameSize-1])
+	file := os.NewFile(uintptr(fd), deviceName)
+
+	return file, deviceName, nil
+}
+
+func (device *Device) readTunPacket() (int, int, error) {
+
+	// Assumes MTU passed to makeDeviceInboundBuffer is actual MTU and
+	// so buffer is sufficiently large to always read a complete packet,
+	// along with the 4 byte utun header.
+
+	n, err := device.deviceIO.Read(device.inboundBuffer)
+	if err != nil {
+		return 0, 0, common.ContextError(err)
+	}
+
+	if device.usingBridge {
+		// No utun packet header when using a bridge
+		return 0, n, nil
+	}
+
+	if n < 4 {
+		return 0, 0, common.ContextError(errors.New("missing packet prefix"))
+	}
+
+	return 4, n - 4, nil
+}
+
+func (device *Device) writeTunPacket(packet []byte) error {
+
+	if device.usingBridge {
+		// No utun packet header when using a bridge
+		_, err := device.deviceIO.Write(packet)
+		if err != nil {
+			return common.ContextError(err)
+		}
+		return nil
+	}
+
+	// Note: can't use writev via net.Buffers. os.File isn't
+	// a net.Conn and can't wrap with net.FileConn due to
+	// fd type. So writes use an intermediate buffer to add
+	// the header.
+
+	// Assumes:
+	// - device.outboundBuffer[0..2] will be 0, the zero value
+	// - packet already validated as 4 or 6
+	// - max len(packet) won't exceed MTU, prellocated size of
+	//   outboundBuffer.
+
+	// Write utun header
+	if len(packet) > 0 && packet[0]>>4 == 4 {
+		device.outboundBuffer[3] = syscall.AF_INET
+	} else { // IPv6
+		device.outboundBuffer[3] = syscall.AF_INET6
+	}
+
+	copy(device.outboundBuffer[4:], packet)
+
+	size := 4 + len(packet)
+
+	_, err := device.deviceIO.Write(device.outboundBuffer[:size])
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func configureNetworkConfigSubprocessCapabilities() error {
+	// Not supported on Darwin
+	return nil
+}
+
+func resetNATTables(_ *ServerConfig, _ net.IP) error {
+	// Not supported on Darwin
+	// TODO: could use pfctl -K?
+	return nil
+}
+
+func configureServerInterface(
+	config *ServerConfig,
+	tunDeviceName string) error {
+
+	// Set tun device network addresses and MTU
+
+	IPv4Address, IPv4Netmask, err := splitIPMask(serverIPv4AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		IPv4Address, IPv4Address, IPv4Netmask,
+		"mtu", strconv.Itoa(getMTU(config.MTU)),
+		"up")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	IPv6Address, IPv6Prefixlen, err := splitIPPrefixLen(serverIPv6AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		"inet6", IPv6Address, "prefixlen", IPv6Prefixlen)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// NAT tun device to external interface
+	//
+	// Uses configuration described here:
+	// https://discussions.apple.com/thread/5538749
+
+	egressInterface := config.EgressInterface
+	if egressInterface == "" {
+		egressInterface = DEFAULT_PUBLIC_INTERFACE_NAME
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"sysctl",
+		"net.inet.ip.forwarding=1")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"sysctl",
+		"net.inet6.ip6.forwarding=1")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// TODO:
+	// - should use -E and preserve existing pf state?
+	// - OR should use "-F all" to reset everything?
+
+	pfConf := fmt.Sprintf(
+		"nat on %s from %s to any -> (%s)\n"+
+			"nat on %s from %s to any -> (%s)\n"+
+			"pass from %s to any keep state\n"+
+			"pass from %s to any keep state\n\n",
+		egressInterface, privateSubnetIPv4.String(), egressInterface,
+		egressInterface, privateSubnetIPv6.String(), egressInterface,
+		privateSubnetIPv4.String(),
+		privateSubnetIPv6.String())
+
+	tempFile, err := ioutil.TempFile("", "tun_pf_conf")
+	if err != nil {
+		return common.ContextError(err)
+	}
+	defer os.Remove(tempFile.Name())
+
+	_, err = tempFile.Write([]byte(pfConf))
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	tempFile.Close()
+
+	config.Logger.WithContextFields(common.LogFields{
+		"content": pfConf,
+	}).Debug("pf.conf")
+
+	// Disable first to avoid "pfctl: pf already enabled"
+	_ = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"pfctl",
+		"-q",
+		"-d")
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"pfctl",
+		"-q",
+		"-e",
+		"-f", tempFile.Name())
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func configureClientInterface(
+	config *ClientConfig,
+	tunDeviceName string) error {
+
+	// Set tun device network addresses and MTU
+
+	IPv4Address, IPv4Netmask, err := splitIPMask(config.IPv4AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		IPv4Address, IPv4Address,
+		"netmask", IPv4Netmask,
+		"mtu", strconv.Itoa(getMTU(config.MTU)),
+		"up")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	IPv6Address, IPv6Prefixlen, err := splitIPPrefixLen(serverIPv6AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		"inet6", IPv6Address, "prefixlen", IPv6Prefixlen)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// Set routing. Routes set here should automatically
+	// drop when the tun device is removed.
+
+	for _, destination := range config.RouteDestinations {
+
+		// TODO: IPv6
+
+		err = runNetworkConfigCommand(
+			config.Logger,
+			config.SudoNetworkConfigCommands,
+			"route",
+			"add",
+			"-ifscope", tunDeviceName,
+			destination,
+			IPv4Address)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
+}
+
+func fixBindToDevice(_ common.Logger, _ bool, _ string) error {
+	// Not required on Darwin
+	return nil
+}

+ 389 - 0
psiphon/common/tun/tun_linux.go

@@ -0,0 +1,389 @@
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"fmt"
+	"io"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+	"syscall"
+	"unsafe"
+
+	"github.com/Psiphon-Inc/gocapability/capability"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+const (
+	DEFAULT_PUBLIC_INTERFACE_NAME = "eth0"
+)
+
+func makeDeviceInboundBuffer(usingBridge bool, MTU int) []byte {
+	return make([]byte, MTU)
+}
+
+func makeDeviceOutboundBuffer(usingBridge bool, MTU int) []byte {
+	// On Linux, no outbound buffer is used
+	return nil
+}
+
+func createTunDevice() (io.ReadWriteCloser, string, error) {
+
+	// Prevent fork between creating fd and setting CLOEXEC
+	syscall.ForkLock.RLock()
+	defer syscall.ForkLock.RUnlock()
+
+	// Requires process to run as root or have CAP_NET_ADMIN
+
+	// This code follows snippets in this thread:
+	// https://groups.google.com/forum/#!msg/golang-nuts/x_c_pZ6p95c/8T0JBZLpTwAJ
+
+	file, err := os.OpenFile("/dev/net/tun", os.O_RDWR, 0)
+	if err != nil {
+		return nil, "", common.ContextError(err)
+	}
+
+	// Set CLOEXEC so file descriptor not leaked to network config command subprocesses
+	syscall.CloseOnExec(int(file.Fd()))
+
+	// Definitions from <linux/if.h>, <linux/if_tun.h>
+
+	// Note: using IFF_NO_PI, so packets have no size/flags header. This does mean
+	// that if the MTU is changed after the tun device is initialized, packets could
+	// be truncated when read.
+
+	const (
+		IFNAMSIZ        = 16
+		IF_REQ_PAD_SIZE = 40 - 18
+		IFF_TUN         = 0x0001
+		IFF_NO_PI       = 0x1000
+	)
+
+	var ifName [IFNAMSIZ]byte
+	copy(ifName[:], []byte("tun%d"))
+
+	ifReq := struct {
+		name  [IFNAMSIZ]byte
+		flags uint16
+		pad   [IF_REQ_PAD_SIZE]byte
+	}{
+		ifName,
+		uint16(IFF_TUN | IFF_NO_PI),
+		[IF_REQ_PAD_SIZE]byte{},
+	}
+
+	_, _, errno := syscall.Syscall(
+		syscall.SYS_IOCTL,
+		file.Fd(),
+		uintptr(syscall.TUNSETIFF),
+		uintptr(unsafe.Pointer(&ifReq)))
+	if errno != 0 {
+		return nil, "", common.ContextError(errno)
+	}
+
+	deviceName := strings.Trim(string(ifReq.name[:]), "\x00")
+
+	return file, deviceName, nil
+}
+
+func (device *Device) readTunPacket() (int, int, error) {
+
+	// Assumes MTU passed to makeDeviceInboundBuffer is actual MTU and
+	// so buffer is sufficiently large to always read a complete packet.
+
+	n, err := device.deviceIO.Read(device.inboundBuffer)
+	if err != nil {
+		return 0, 0, common.ContextError(err)
+	}
+	return 0, n, nil
+}
+
+func (device *Device) writeTunPacket(packet []byte) error {
+
+	// Doesn't need outboundBuffer since there's no header; write directly to device.
+
+	_, err := device.deviceIO.Write(packet)
+	if err != nil {
+		return common.ContextError(err)
+	}
+	return nil
+}
+
+func configureNetworkConfigSubprocessCapabilities() error {
+
+	// If this process has CAP_NET_ADMIN, make it available to be inherited
+	// be child processes via ambient mechanism described here:
+	// https://github.com/torvalds/linux/commit/58319057b7847667f0c9585b9de0e8932b0fdb08
+	//
+	// The ambient mechanim is available in Linux kernel 4.3 and later.
+
+	// When using capabilities, this process should have CAP_NET_ADMIN in order
+	// to create tun devices. And the subprocess operations such as using "ifconfig"
+	// and "iptables" for network config require the same CAP_NET_ADMIN capability.
+
+	cap, err := capability.NewPid(0)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	if cap.Get(capability.EFFECTIVE, capability.CAP_NET_ADMIN) {
+
+		cap.Set(capability.INHERITABLE|capability.AMBIENT, capability.CAP_NET_ADMIN)
+
+		err = cap.Apply(capability.AMBIENT)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
+}
+
+func resetNATTables(
+	config *ServerConfig,
+	IPAddress net.IP) error {
+
+	// Uses the "conntrack" command, which is often not installed by default.
+
+	// conntrack --delete -src-nat --orig-src <address> will clear NAT tables of existing
+	// connections, making it less likely that traffic for a previous client using the
+	// specified address will be forwarded to a new client using this address. This is in
+	// the already unlikely event that there's still in-flight traffic when the address is
+	// recycled.
+
+	err := runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"conntrack",
+		"--delete",
+		"--src-nat",
+		"--orig-src",
+		IPAddress.String())
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func configureServerInterface(
+	config *ServerConfig,
+	tunDeviceName string) error {
+
+	// Set tun device network addresses and MTU
+
+	IPv4Address, IPv4Netmask, err := splitIPMask(serverIPv4AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		IPv4Address, "netmask", IPv4Netmask,
+		"mtu", strconv.Itoa(getMTU(config.MTU)),
+		"up")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		"add", serverIPv6AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	egressInterface := config.EgressInterface
+	if egressInterface == "" {
+		egressInterface = DEFAULT_PUBLIC_INTERFACE_NAME
+	}
+
+	// NAT tun device to external interface
+
+	// TODO: need only set forwarding for specific interfaces?
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"sysctl",
+		"net.ipv4.conf.all.forwarding=1")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"sysctl",
+		"net.ipv6.conf.all.forwarding=1")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// To avoid duplicates, first try to drop existing rule, then add
+
+	for _, mode := range []string{"-D", "-A"} {
+
+		err = runNetworkConfigCommand(
+			config.Logger,
+			config.SudoNetworkConfigCommands,
+			"iptables",
+			"-t", "nat",
+			mode, "POSTROUTING",
+			"-s", privateSubnetIPv4.String(),
+			"-o", egressInterface,
+			"-j", "MASQUERADE")
+		if mode != "-D" && err != nil {
+			return common.ContextError(err)
+		}
+
+		err = runNetworkConfigCommand(
+			config.Logger,
+			config.SudoNetworkConfigCommands,
+			"ip6tables",
+			"-t", "nat",
+			mode, "POSTROUTING",
+			"-s", privateSubnetIPv6.String(),
+			"-o", egressInterface,
+			"-j", "MASQUERADE")
+		if mode != "-D" && err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
+}
+
+func configureClientInterface(
+	config *ClientConfig,
+	tunDeviceName string) error {
+
+	// Set tun device network addresses and MTU
+
+	IPv4Address, IPv4Netmask, err := splitIPMask(config.IPv4AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		IPv4Address,
+		"netmask", IPv4Netmask,
+		"mtu", strconv.Itoa(getMTU(config.MTU)),
+		"up")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		config.Logger,
+		config.SudoNetworkConfigCommands,
+		"ifconfig",
+		tunDeviceName,
+		"add", config.IPv6AddressCIDR)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// Set routing. Routes set here should automatically
+	// drop when the tun device is removed.
+
+	// TODO: appear to need explict routing only for IPv6?
+
+	for _, destination := range config.RouteDestinations {
+
+		// Destination may be host (IP) or network (CIDR)
+
+		IP := net.ParseIP(destination)
+		if IP == nil {
+			var err error
+			IP, _, err = net.ParseCIDR(destination)
+			if err != nil {
+				return common.ContextError(err)
+			}
+		}
+		if IP.To4() != nil {
+			continue
+		}
+
+		// Note: use "replace" instead of "add" as route from
+		// previous run (e.g., tun_test case) may not yet be cleared.
+
+		err = runNetworkConfigCommand(
+			config.Logger,
+			config.SudoNetworkConfigCommands,
+			"ip",
+			"-6",
+			"route", "replace",
+			destination,
+			"dev", tunDeviceName)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
+}
+
+func fixBindToDevice(logger common.Logger, useSudo bool, tunDeviceName string) error {
+
+	// Fix the problem described here:
+	// https://stackoverflow.com/questions/24011205/cant-perform-tcp-handshake-through-a-nat-between-two-nics-with-so-bindtodevice/
+
+	err := runNetworkConfigCommand(
+		logger,
+		useSudo,
+		"sysctl",
+		"net.ipv4.conf.all.accept_local=1")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		logger,
+		useSudo,
+		"sysctl",
+		"net.ipv4.conf.all.rp_filter=0")
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = runNetworkConfigCommand(
+		logger,
+		useSudo,
+		"sysctl",
+		fmt.Sprintf("net.ipv4.conf.%s.rp_filter=0", tunDeviceName))
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}

+ 695 - 0
psiphon/common/tun/tun_test.go

@@ -0,0 +1,695 @@
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"net"
+	"os"
+	"strconv"
+	"sync"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+const (
+	UNIX_DOMAIN_SOCKET_NAME = "/tmp/tun_test.sock"
+	SESSION_ID_LENGTH       = 16
+	TCP_PORT                = 8000
+	TCP_RELAY_CHUNK_SIZE    = int64(65536)
+	TCP_RELAY_TOTAL_SIZE    = int64(1073741824)
+	CONCURRENT_CLIENT_COUNT = 5
+	PACKET_METRICS_TIMEOUT  = 10 * time.Second
+)
+
+func TestTunneledTCPIPv4(t *testing.T) {
+	testTunneledTCP(t, false)
+}
+
+func TestTunneledTCPIPv6(t *testing.T) {
+	testTunneledTCP(t, true)
+}
+
+func TestTunneledDNS(t *testing.T) {
+	t.Skip("TODO: test DNS tunneling")
+}
+
+func TestSessionExpiry(t *testing.T) {
+	t.Skip("TODO: test short session TTLs actually persist/expire as expected")
+}
+
+func TestTrafficRules(t *testing.T) {
+	t.Skip("TODO: negative tests for checkAllowedTCPPortFunc, checkAllowedUDPPortFunc")
+}
+
+func TestResetRouting(t *testing.T) {
+	t.Skip("TODO: test conntrack delete effectiveness")
+}
+
+func testTunneledTCP(t *testing.T, useIPv6 bool) {
+
+	// This test harness does the following:
+	//
+	// - starts a TCP server; this server echoes the data it receives
+	// - starts a packet tunnel server that uses a unix domain socket for client channels
+	// - starts CONCURRENT_CLIENT_COUNT concurrent clients
+	// - each client runs a packet tunnel client connected to the server unix domain socket
+	//   and establishes a TCP client connection to the TCP through the packet tunnel
+	// - each TCP client transfers TCP_RELAY_TOTAL_SIZE bytes to the TCP server
+	// - the test checks that all data echoes back correctly and that the server packet
+	//   metrics reflects the expected amount of data transferred through the tunnel
+	// - this test runs in either IPv4 or IPv6 mode
+	// - the test host's public IP address is used as the TCP server IP address; it is
+	//   expected that the server tun device will NAT to the public interface; clients
+	//   use SO_BINDTODEVICE/IP_BOUND_IF to force the TCP client connections through the
+	//   tunnel
+	//
+	// Note: this test can modify host network configuration; in addition to tun device
+	// and routing config, see the changes made in fixBindToDevice.
+
+	MTU := DEFAULT_MTU
+
+	testTCPServer, err := startTestTCPServer(useIPv6)
+	if err != nil {
+		if err == errLinkLocalAddress {
+			t.Skipf("test unsupported: %s", errLinkLocalAddress)
+		}
+		t.Fatalf("startTestTCPServer failed: %s", err)
+	}
+
+	testServer, err := startTestServer(MTU)
+	if err != nil {
+		t.Fatalf("startTestServer failed: %s", err)
+	}
+
+	results := make(chan error, CONCURRENT_CLIENT_COUNT)
+
+	for i := 0; i < CONCURRENT_CLIENT_COUNT; i++ {
+		go func() {
+
+			testClient, err := startTestClient(
+				MTU, []string{testTCPServer.getListenerIPAddress()})
+			if err != nil {
+				results <- fmt.Errorf("startTestClient failed: %s", err)
+				return
+			}
+
+			// The TCP client will bind to the packet tunnel client tun
+			// device and connect to the TCP server. With the bind to
+			// device, TCP packets will flow through the packet tunnel
+			// client to the packet tunnel server, through the packet tunnel
+			// server's tun device, NATed to the server's public interface,
+			// and finally reaching the TCP server. All this happens on
+			// the single host running the test.
+
+			testTCPClient, err := startTestTCPClient(
+				testClient.tunClient.device.Name(),
+				testTCPServer.getListenerIPAddress())
+			if err != nil {
+				results <- fmt.Errorf("startTestTCPClient failed: %s", err)
+				return
+			}
+
+			// Send TCP_RELAY_TOTAL_SIZE random bytes to the TCP server, and
+			// check that it echoes back the same bytes.
+
+			sendChunk, receiveChunk := make([]byte, TCP_RELAY_CHUNK_SIZE), make([]byte, TCP_RELAY_CHUNK_SIZE)
+
+			// Note: data transfer doesn't have to be exactly TCP_RELAY_TOTAL_SIZE,
+			// so not handling TCP_RELAY_TOTAL_SIZE%TCP_RELAY_CHUNK_SIZE != 0.
+			for i := int64(0); i < TCP_RELAY_TOTAL_SIZE; i += TCP_RELAY_CHUNK_SIZE {
+
+				_, err := rand.Read(sendChunk)
+				if err != nil {
+					results <- fmt.Errorf("rand.Read failed: %s", err)
+					return
+				}
+
+				_, err = testTCPClient.Write(sendChunk)
+				if err != nil {
+					results <- fmt.Errorf("mockTCPClient.Write failed: %s", err)
+					return
+				}
+
+				_, err = io.ReadFull(testTCPClient, receiveChunk)
+				if err != nil {
+					results <- fmt.Errorf("io.ReadFull failed: %s", err)
+					return
+				}
+
+				if 0 != bytes.Compare(sendChunk, receiveChunk) {
+					results <- fmt.Errorf("bytes.Compare failed")
+					return
+				}
+			}
+
+			testTCPClient.stop()
+
+			testClient.stop()
+
+			// Check metrics to ensure traffic was tunneled and metrics reported
+
+			// Note: this code does not ensure that the "last" packet metrics was
+			// for this very client; but all packet metrics should be the same.
+
+			packetMetricsFields := testServer.logger.getLastPacketMetrics()
+
+			if packetMetricsFields == nil {
+				results <- fmt.Errorf("testServer.logger.getLastPacketMetrics failed")
+				return
+			}
+
+			expectedFields := []struct {
+				nameSuffix   string
+				minimumValue int64
+			}{
+				{"packets_up", TCP_RELAY_TOTAL_SIZE / int64(MTU)},
+				{"packets_down", TCP_RELAY_TOTAL_SIZE / int64(MTU)},
+				{"bytes_up", TCP_RELAY_TOTAL_SIZE},
+				{"bytes_down", TCP_RELAY_TOTAL_SIZE},
+			}
+
+			for _, expectedField := range expectedFields {
+				var name string
+				if useIPv6 {
+					name = "tcp_ipv6_" + expectedField.nameSuffix
+				} else {
+					name = "tcp_ipv4_" + expectedField.nameSuffix
+				}
+				field, ok := packetMetricsFields[name]
+				if !ok {
+					results <- fmt.Errorf("missing expected metric field: %s", name)
+					return
+				}
+				value, ok := field.(int64)
+				if !ok {
+					results <- fmt.Errorf("unexpected metric field type: %s", name)
+					return
+				}
+				if value < expectedField.minimumValue {
+					results <- fmt.Errorf("unexpected metric field value: %s: %d", name, value)
+					return
+				}
+			}
+
+			results <- nil
+		}()
+	}
+
+	for i := 0; i < CONCURRENT_CLIENT_COUNT; i++ {
+		result := <-results
+		if result != nil {
+			t.Fatalf(result.Error())
+		}
+	}
+
+	testServer.stop()
+
+	testTCPServer.stop()
+}
+
+type testServer struct {
+	logger       *testLogger
+	tunServer    *Server
+	unixListener net.Listener
+	clientConns  *common.Conns
+	workers      *sync.WaitGroup
+}
+
+func startTestServer(MTU int) (*testServer, error) {
+
+	logger := newTestLogger(true)
+
+	noDNSResolvers := func() []net.IP { return make([]net.IP, 0) }
+
+	config := &ServerConfig{
+		Logger: logger,
+		GetDNSResolverIPv4Addresses: noDNSResolvers,
+		GetDNSResolverIPv6Addresses: noDNSResolvers,
+		MTU: MTU,
+	}
+
+	tunServer, err := NewServer(config)
+	if err != nil {
+		return nil, fmt.Errorf("startTestServer(): NewServer failed: %s", err)
+	}
+
+	tunServer.Start()
+
+	_ = syscall.Unlink(UNIX_DOMAIN_SOCKET_NAME)
+
+	unixListener, err := net.Listen("unix", UNIX_DOMAIN_SOCKET_NAME)
+	if err != nil {
+		return nil, fmt.Errorf("startTestServer(): net.Listen failed: %s", err)
+	}
+
+	server := &testServer{
+		logger:       logger,
+		tunServer:    tunServer,
+		unixListener: unixListener,
+		clientConns:  new(common.Conns),
+		workers:      new(sync.WaitGroup),
+	}
+
+	server.workers.Add(1)
+	go server.run()
+
+	return server, nil
+}
+
+func (server *testServer) run() {
+	defer server.workers.Done()
+
+	for {
+		clientConn, err := server.unixListener.Accept()
+		if err != nil {
+			fmt.Printf("testServer.run(): unixListener.Accept failed: %s\n", err)
+			return
+		}
+
+		signalConn := newSignalConn(clientConn)
+
+		if !server.clientConns.Add(signalConn) {
+			return
+		}
+
+		server.workers.Add(1)
+		go func() {
+			defer server.workers.Done()
+			defer signalConn.Close()
+
+			sessionID, err := common.MakeRandomStringHex(SESSION_ID_LENGTH)
+			if err != nil {
+				fmt.Printf("testServer.run(): common.MakeRandomStringHex failed: %s\n", err)
+				return
+			}
+
+			checkAllowedPortFunc := func(net.IP, int) bool { return true }
+
+			server.tunServer.ClientConnected(
+				sessionID,
+				signalConn,
+				checkAllowedPortFunc,
+				checkAllowedPortFunc)
+
+			signalConn.Wait()
+
+			server.tunServer.ClientDisconnected(
+				sessionID)
+		}()
+	}
+}
+
+func (server *testServer) stop() {
+	server.clientConns.CloseAll()
+	server.unixListener.Close()
+	server.workers.Wait()
+	server.tunServer.Stop()
+}
+
+type signalConn struct {
+	net.Conn
+	ioErrorSignal chan struct{}
+}
+
+func newSignalConn(baseConn net.Conn) *signalConn {
+	return &signalConn{
+		Conn:          baseConn,
+		ioErrorSignal: make(chan struct{}, 1),
+	}
+}
+
+func (conn *signalConn) Read(p []byte) (n int, err error) {
+	n, err = conn.Conn.Read(p)
+	if err != nil {
+		_ = conn.Conn.Close()
+		select {
+		case conn.ioErrorSignal <- *new(struct{}):
+		default:
+		}
+	}
+	return
+}
+
+func (conn *signalConn) Write(p []byte) (n int, err error) {
+	n, err = conn.Conn.Write(p)
+	if err != nil {
+		_ = conn.Conn.Close()
+		select {
+		case conn.ioErrorSignal <- *new(struct{}):
+		default:
+		}
+	}
+	return
+}
+
+func (conn *signalConn) Wait() {
+	<-conn.ioErrorSignal
+}
+
+type testClient struct {
+	logger    *testLogger
+	unixConn  net.Conn
+	tunClient *Client
+}
+
+func startTestClient(
+	MTU int,
+	routeDestinations []string) (*testClient, error) {
+
+	unixConn, err := net.Dial("unix", UNIX_DOMAIN_SOCKET_NAME)
+	if err != nil {
+		return nil, fmt.Errorf("startTestClient(): net.Dial failed: %s", err)
+	}
+
+	logger := newTestLogger(false)
+
+	// Assumes IP addresses are available on test host
+
+	config := &ClientConfig{
+		Logger:            logger,
+		IPv4AddressCIDR:   "172.16.0.1/24",
+		IPv6AddressCIDR:   "fd26:b6a6:4454:310a:0000:0000:0000:0001/64",
+		RouteDestinations: routeDestinations,
+		Transport:         unixConn,
+		MTU:               MTU,
+	}
+
+	tunClient, err := NewClient(config)
+	if err != nil {
+		return nil, fmt.Errorf("startTestClient(): NewClient failed: %s", err)
+	}
+
+	// Configure kernel to fix issue described in fixBindToDevice
+
+	err = fixBindToDevice(logger, config.SudoNetworkConfigCommands, tunClient.device.Name())
+	if err != nil {
+		return nil, fmt.Errorf("startTestClient(): fixBindToDevice failed: %s", err)
+	}
+
+	tunClient.Start()
+
+	return &testClient{
+		logger:    logger,
+		unixConn:  unixConn,
+		tunClient: tunClient,
+	}, nil
+}
+
+func (client *testClient) stop() {
+	client.unixConn.Close()
+	client.tunClient.Stop()
+}
+
+type testTCPServer struct {
+	listenerIPAddress string
+	tcpListener       net.Listener
+	clientConns       *common.Conns
+	workers           *sync.WaitGroup
+}
+
+var errLinkLocalAddress = errors.New("interface has link local address")
+
+func startTestTCPServer(useIPv6 bool) (*testTCPServer, error) {
+
+	interfaceName := DEFAULT_PUBLIC_INTERFACE_NAME
+
+	hostIPaddress := ""
+
+	IPv4Address, IPv6Address, err := common.GetInterfaceIPAddresses(interfaceName)
+	if err != nil {
+		return nil, fmt.Errorf("startTestTCPServer(): GetInterfaceIPAddresses failed: %s", err)
+	}
+
+	if useIPv6 {
+		if IPv6Address == nil {
+			return nil, fmt.Errorf("startTestTCPServer(): no IPv6 address")
+		}
+		if IPv6Address.IsLinkLocalUnicast() {
+			// Cannot route to link local address
+			return nil, errLinkLocalAddress
+		}
+		hostIPaddress = IPv6Address.String()
+	} else {
+		if IPv4Address == nil {
+			return nil, fmt.Errorf("startTestTCPServer(): no IPv4 address")
+		}
+		if IPv4Address.IsLinkLocalUnicast() {
+			// Cannot route to link local address
+			return nil, errLinkLocalAddress
+		}
+		hostIPaddress = IPv4Address.String()
+	}
+
+	tcpListener, err := net.Listen("tcp", net.JoinHostPort(hostIPaddress, strconv.Itoa(TCP_PORT)))
+	if err != nil {
+		return nil, fmt.Errorf("startTestTCPServer(): net.Listen failed: %s", err)
+	}
+
+	server := &testTCPServer{
+		listenerIPAddress: hostIPaddress,
+		tcpListener:       tcpListener,
+		clientConns:       new(common.Conns),
+		workers:           new(sync.WaitGroup),
+	}
+
+	server.workers.Add(1)
+	go server.run()
+
+	return server, nil
+}
+
+func (server *testTCPServer) getListenerIPAddress() string {
+	return server.listenerIPAddress
+}
+
+func (server *testTCPServer) run() {
+	defer server.workers.Done()
+
+	for {
+		clientConn, err := server.tcpListener.Accept()
+		if err != nil {
+			fmt.Printf("testTCPServer.run(): tcpListener.Accept failed: %s\n", err)
+			return
+		}
+
+		if !server.clientConns.Add(clientConn) {
+			return
+		}
+
+		server.workers.Add(1)
+		go func() {
+			defer server.workers.Done()
+			defer clientConn.Close()
+
+			buffer := make([]byte, TCP_RELAY_CHUNK_SIZE)
+
+			for {
+				_, err := io.ReadFull(clientConn, buffer)
+				if err != nil {
+					fmt.Printf("testTCPServer.run(): io.ReadFull failed: %s\n", err)
+					return
+				}
+				_, err = clientConn.Write(buffer)
+				if err != nil {
+					fmt.Printf("testTCPServer.run(): clientConn.Write failed: %s\n", err)
+					return
+				}
+			}
+		}()
+	}
+}
+
+func (server *testTCPServer) stop() {
+	server.clientConns.CloseAll()
+	server.tcpListener.Close()
+	server.workers.Wait()
+}
+
+type testTCPClient struct {
+	conn net.Conn
+}
+
+func startTestTCPClient(
+	tunDeviceName, serverIPAddress string) (*testTCPClient, error) {
+
+	// This is a simplified version of the low-level TCP dial
+	// code in psiphon/TCPConn, which supports bindToDevice.
+	// It does not resolve domain names and does not have an
+	// explicit timeout.
+
+	var ipv4 [4]byte
+	var ipv6 [16]byte
+	var domain int
+	var sockAddr syscall.Sockaddr
+
+	ipAddr := net.ParseIP(serverIPAddress)
+	if ipAddr == nil {
+		return nil, fmt.Errorf("net.ParseIP failed")
+	}
+
+	if ipAddr.To4() != nil {
+		copy(ipv4[:], ipAddr.To4())
+		domain = syscall.AF_INET
+		sockAddr = &syscall.SockaddrInet4{Addr: ipv4, Port: TCP_PORT}
+	} else {
+		copy(ipv6[:], ipAddr.To16())
+		domain = syscall.AF_INET6
+		sockAddr = &syscall.SockaddrInet6{Addr: ipv6, Port: TCP_PORT}
+	}
+
+	socketFd, err := syscall.Socket(domain, syscall.SOCK_STREAM, 0)
+	if err != nil {
+		return nil, fmt.Errorf("syscall.Socket failed: %s", err)
+	}
+
+	err = bindToDevice(socketFd, tunDeviceName)
+	if err != nil {
+		syscall.Close(socketFd)
+		return nil, fmt.Errorf("bindToDevice failed: %s", err)
+	}
+
+	err = syscall.Connect(socketFd, sockAddr)
+	if err != nil {
+		syscall.Close(socketFd)
+		return nil, fmt.Errorf("syscall.Connect failed: %s", err)
+	}
+
+	file := os.NewFile(uintptr(socketFd), "")
+	conn, err := net.FileConn(file)
+	file.Close()
+	if err != nil {
+		return nil, fmt.Errorf("net.FileConn failed: %s", err)
+	}
+
+	return &testTCPClient{
+		conn: conn,
+	}, nil
+}
+
+func (client *testTCPClient) Read(p []byte) (n int, err error) {
+	n, err = client.conn.Read(p)
+	return
+}
+
+func (client *testTCPClient) Write(p []byte) (n int, err error) {
+	n, err = client.conn.Write(p)
+	return
+}
+
+func (client *testTCPClient) stop() {
+	client.conn.Close()
+}
+
+type testLogger struct {
+	packetMetrics chan common.LogFields
+}
+
+func newTestLogger(wantLastPacketMetrics bool) *testLogger {
+
+	var packetMetrics chan common.LogFields
+	if wantLastPacketMetrics {
+		packetMetrics = make(chan common.LogFields, CONCURRENT_CLIENT_COUNT)
+	}
+
+	return &testLogger{
+		packetMetrics: packetMetrics,
+	}
+}
+
+func (logger *testLogger) WithContext() common.LogContext {
+	return &testLoggerContext{context: common.GetParentContext()}
+}
+
+func (logger *testLogger) WithContextFields(fields common.LogFields) common.LogContext {
+	return &testLoggerContext{
+		context: common.GetParentContext(),
+		fields:  fields,
+	}
+}
+
+func (logger *testLogger) LogMetric(metric string, fields common.LogFields) {
+
+	fmt.Printf("METRIC: %s: %+v\n", metric, fields)
+
+	if metric == "packet_metrics" && logger.packetMetrics != nil {
+		select {
+		case logger.packetMetrics <- fields:
+		default:
+		}
+	}
+}
+
+func (logger *testLogger) getLastPacketMetrics() common.LogFields {
+	if logger.packetMetrics == nil {
+		return nil
+	}
+
+	// Implicitly asserts that packet metrics will be emitted
+	// within PACKET_METRICS_TIMEOUT; if not, the test will fail.
+
+	select {
+	case fields := <-logger.packetMetrics:
+		return fields
+	case <-time.After(PACKET_METRICS_TIMEOUT):
+		return nil
+	}
+}
+
+type testLoggerContext struct {
+	context string
+	fields  common.LogFields
+}
+
+func (context *testLoggerContext) log(priority, message string) {
+	now := time.Now().UTC().Format(time.RFC3339)
+	if len(context.fields) == 0 {
+		fmt.Printf(
+			"[%s] %s: %s: %s\n",
+			now, priority, context.context, message)
+	} else {
+		fmt.Printf(
+			"[%s] %s: %s: %s %+v\n",
+			now, priority, context.context, message, context.fields)
+	}
+}
+
+func (context *testLoggerContext) Debug(args ...interface{}) {
+	context.log("DEBUG", fmt.Sprint(args...))
+}
+
+func (context *testLoggerContext) Info(args ...interface{}) {
+	context.log("INFO", fmt.Sprint(args...))
+}
+
+func (context *testLoggerContext) Warning(args ...interface{}) {
+	context.log("WARNING", fmt.Sprint(args...))
+}
+
+func (context *testLoggerContext) Error(args ...interface{}) {
+	context.log("ERROR", fmt.Sprint(args...))
+}

+ 46 - 0
psiphon/common/tun/tun_test_darwin.go

@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"net"
+	"syscall"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+func bindToDevice(fd int, deviceName string) error {
+
+	netInterface, err := net.InterfaceByName(deviceName)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// IP_BOUND_IF definition from <netinet/in.h>
+
+	const IP_BOUND_IF = 25
+
+	err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, IP_BOUND_IF, netInterface.Index)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}

+ 34 - 0
psiphon/common/tun/tun_test_linux.go

@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"syscall"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+func bindToDevice(fd int, deviceName string) error {
+	err := syscall.BindToDevice(fd, deviceName)
+	if err != nil {
+		return common.ContextError(err)
+	}
+	return nil
+}

+ 57 - 0
psiphon/common/tun/tun_unix.go

@@ -0,0 +1,57 @@
+// +build darwin linux
+
+/*
+ * Copyright (c) 2017, 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/>.
+ *
+ */
+
+// Copyright 2009 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.
+
+package tun
+
+import (
+	"os"
+	"syscall"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+// dupFD is essentially this function:
+// https://github.com/golang/go/blob/bf0f69220255941196c684f235727fd6dc747b5c/src/net/fd_unix.go#L306
+//
+// dupFD duplicates the file descriptor; sets O_CLOEXEC to avoid leaking
+// to child processes; and sets the mode to blocking for use with os.NewFile.
+func dupFD(fd int) (newfd int, err error) {
+
+	syscall.ForkLock.RLock()
+	defer syscall.ForkLock.RUnlock()
+
+	newfd, err = syscall.Dup(fd)
+	if err != nil {
+		return -1, common.ContextError(os.NewSyscallError("dup", err))
+	}
+
+	syscall.CloseOnExec(newfd)
+
+	if err = syscall.SetNonblock(newfd, false); err != nil {
+		return -1, common.ContextError(os.NewSyscallError("setnonblock", err))
+	}
+
+	return
+}

+ 76 - 0
psiphon/common/tun/tun_unsupported.go

@@ -0,0 +1,76 @@
+// +build !darwin,!linux
+
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"errors"
+	"net"
+	"os"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+var unsupportedError = errors.New("operation unsupported on this platform")
+
+func makeDeviceInboundBuffer(_ int) []byte {
+	return nil
+}
+
+func makeDeviceOutboundBuffer(_ int) []byte {
+	return nil
+}
+
+func configureServerInterface(_ *ServerConfig, _ string) error {
+	return common.ContextError(unsupportedError)
+}
+
+func configureClientInterface(_ *ClientConfig, _ string) error {
+	return common.ContextError(unsupportedError)
+}
+
+func createTunDevice() (*os.File, string, error) {
+	return nil, "", common.ContextError(unsupportedError)
+}
+
+func (device *Device) readTunPacket() (int, int, error) {
+	return 0, 0, common.ContextError(unsupportedError)
+}
+
+func (device *Device) writeTunPacket(_ []byte) error {
+	return common.ContextError(unsupportedError)
+}
+
+func configureNetworkConfigSubprocessCapabilities() error {
+	return common.ContextError(unsupportedError)
+}
+
+func resetNATTables(_ *ServerConfig, _ net.IP) error {
+	return common.ContextError(unsupportedError)
+}
+
+func routeServerInterface(_ string, _ int) error {
+	return common.ContextError(unsupportedError)
+}
+
+func dupCloseOnExec(_ int) (int, error) {
+	return -1, common.ContextError(unsupportedError)
+}

+ 114 - 0
psiphon/common/tun/utils.go

@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2017, 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 tun
+
+import (
+	"fmt"
+	"net"
+	"os/exec"
+	"strconv"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+// runNetworkConfigCommand execs a network config command, such as "ifconfig"
+// or "iptables". On platforms that support capabilities, the network config
+// capabilities of the current process is made available to the command
+// subprocess. Alternatively, "sudo" will be used when useSudo is true.
+func runNetworkConfigCommand(
+	logger common.Logger,
+	useSudo bool,
+	commandName string, commandArgs ...string) error {
+
+	// configureSubprocessCapabilities will set inheritable
+	// capabilities on platforms which support that (Linux).
+	// Specifically, CAP_NET_ADMIN will be transferred from
+	// this process to the child command.
+
+	err := configureNetworkConfigSubprocessCapabilities()
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	// TODO: use CommandContext to interrupt on server shutdown?
+	// (the commands currently being issued shouldn't block...)
+
+	if useSudo {
+		commandArgs = append([]string{commandName}, commandArgs...)
+		commandName = "sudo"
+	}
+
+	cmd := exec.Command(commandName, commandArgs...)
+	output, err := cmd.CombinedOutput()
+
+	logger.WithContextFields(common.LogFields{
+		"command": commandName,
+		"args":    commandArgs,
+		"output":  string(output),
+		"error":   err,
+	}).Debug("exec")
+
+	if err != nil {
+		err := fmt.Errorf(
+			"command %s %+v failed with %s", commandName, commandArgs, string(output))
+		return common.ContextError(err)
+	}
+	return nil
+}
+
+func splitIPMask(IPAddressCIDR string) (string, string, error) {
+
+	IP, IPNet, err := net.ParseCIDR(IPAddressCIDR)
+	if err != nil {
+		return "", "", common.ContextError(err)
+	}
+
+	var netmask string
+	IPv4Mask := net.IP(IPNet.Mask).To4()
+	if IPv4Mask != nil {
+		netmask = fmt.Sprintf(
+			"%d.%d.%d.%d", IPv4Mask[0], IPv4Mask[1], IPv4Mask[2], IPv4Mask[3])
+	} else {
+		netmask = IPNet.Mask.String()
+	}
+
+	return IP.String(), netmask, nil
+}
+
+func splitIPPrefixLen(IPAddressCIDR string) (string, string, error) {
+
+	IP, IPNet, err := net.ParseCIDR(IPAddressCIDR)
+	if err != nil {
+		return "", "", common.ContextError(err)
+	}
+
+	prefixLen, _ := IPNet.Mask.Size()
+
+	return IP.String(), strconv.Itoa(prefixLen), nil
+}
+
+func getMTU(configMTU int) int {
+	if configMTU <= 0 {
+		return DEFAULT_MTU
+	} else if configMTU > 65536 {
+		return 65536
+	}
+	return configMTU
+}

+ 35 - 0
psiphon/config.go

@@ -31,6 +31,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
 
 // TODO: allow all params to be configured
@@ -212,6 +213,9 @@ type Config struct {
 	// If there are multiple IP addresses on an interface use the first IPv4 address.
 	ListenInterface string
 
+	// DisableLocalSocksProxy disables running the local SOCKS proxy.
+	DisableLocalSocksProxy bool
+
 	// LocalSocksProxyPort specifies a port number for the local SOCKS proxy
 	// running at 127.0.0.1. For the default value, 0, the system selects a free
 	// port (a notice reporting the selected port is emitted).
@@ -222,6 +226,9 @@ type Config struct {
 	// port (a notice reporting the selected port is emitted).
 	LocalHttpProxyPort int
 
+	// DisableLocalHTTPProxy disables running the local HTTP proxy.
+	DisableLocalHTTPProxy bool
+
 	// ConnectionWorkerPoolSize specifies how many connection attempts to attempt
 	// in parallel. The default, 0, uses CONNECTION_WORKER_POOL_SIZE which is
 	// recommended.
@@ -461,6 +468,19 @@ type Config struct {
 	// could reveal user browsing activity, it's intended for debugging and testing
 	// only.
 	EmitSLOKs bool
+
+	// PacketTunnelTunDeviceFileDescriptor specifies a tun device file descriptor
+	// to use for running a packet tunnel. When this value is > 0, a packet tunnel
+	// is established through the server and packets are relayed via the tun device
+	// file descriptor. The file descriptor is duped in NewController.
+	// When PacketTunnelTunDeviceFileDescriptor is set, TunnelPoolSize must be 1.
+	PacketTunnelTunFileDescriptor int
+
+	// PacketTunnelDeviceBridge specifies a tun device bridge to use for running a
+	// packet tunnel. This is an alternate interface to a tun device when a file
+	// descriptor is not directly available.
+	// When PacketTunnelDeviceBridge is set, TunnelPoolSize must be 1.
+	PacketTunnelDeviceBridge *tun.DeviceBridge
 }
 
 // DownloadURL specifies a URL for downloading resources along with parameters
@@ -565,6 +585,11 @@ func LoadConfig(configJson []byte) (*Config, error) {
 			errors.New("DnsServerGetter interface must be set at runtime"))
 	}
 
+	if config.PacketTunnelDeviceBridge != nil {
+		return nil, common.ContextError(
+			errors.New("PacketTunnelDeviceBridge value must be set at runtime"))
+	}
+
 	if !common.Contains(
 		[]string{"", TRANSFORM_HOST_NAMES_ALWAYS, TRANSFORM_HOST_NAMES_NEVER},
 		config.TransformHostNames) {
@@ -643,6 +668,16 @@ func LoadConfig(configJson []byte) (*Config, error) {
 		}
 	}
 
+	if config.PacketTunnelTunFileDescriptor > 0 && config.PacketTunnelDeviceBridge != nil {
+		return nil, common.ContextError(errors.New("only one of PacketTunnelTunFileDescriptor and PacketTunnelDeviceBridge may be set"))
+	}
+
+	// This constraint is expected by logic in Controller.runTunnels()
+	if (config.PacketTunnelTunFileDescriptor > 0 || config.PacketTunnelDeviceBridge != nil) &&
+		config.TunnelPoolSize != 1 {
+		return nil, common.ContextError(errors.New("packet tunnel mode requires TunnelPoolSize to be 1"))
+	}
+
 	if config.TunnelConnectTimeoutSeconds == nil {
 		defaultTunnelConnectTimeoutSeconds := TUNNEL_CONNECT_TIMEOUT_SECONDS
 		config.TunnelConnectTimeoutSeconds = &defaultTunnelConnectTimeoutSeconds

+ 83 - 15
psiphon/controller.go

@@ -25,6 +25,7 @@ package psiphon
 
 import (
 	"errors"
+	"fmt"
 	"math/rand"
 	"net"
 	"sync"
@@ -33,6 +34,7 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
 
 // Controller is a tunnel lifecycle coordinator. It manages lists of servers to
@@ -66,6 +68,8 @@ type Controller struct {
 	signalReportConnected             chan struct{}
 	serverAffinityDoneBroadcast       chan struct{}
 	newClientVerificationPayload      chan string
+	packetTunnelClient                *tun.Client
+	packetTunnelTransport             *PacketTunnelTransport
 }
 
 type candidateServerEntry struct {
@@ -141,6 +145,31 @@ func NewController(config *Config) (controller *Controller, err error) {
 
 	controller.splitTunnelClassifier = NewSplitTunnelClassifier(config, controller)
 
+	if config.PacketTunnelTunFileDescriptor > 0 ||
+		config.PacketTunnelDeviceBridge != nil {
+
+		// Run a packet tunnel client. The lifetime of the tun.Client is the
+		// lifetime of the Controller, so it exists across tunnel establishments
+		// and reestablishments. The PacketTunnelTransport provides a layer
+		// that presents a continuosuly existing transport to the tun.Client;
+		// it's set to use new SSH channels after new SSH tunnel establishes.
+
+		packetTunnelTransport := NewPacketTunnelTransport()
+
+		packetTunnelClient, err := tun.NewClient(&tun.ClientConfig{
+			Logger:            NoticeCommonLogger(),
+			TunFileDescriptor: config.PacketTunnelTunFileDescriptor,
+			TunDeviceBridge:   config.PacketTunnelDeviceBridge,
+			Transport:         packetTunnelTransport,
+		})
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+
+		controller.packetTunnelClient = packetTunnelClient
+		controller.packetTunnelTransport = packetTunnelTransport
+	}
+
 	return controller, nil
 }
 
@@ -159,26 +188,42 @@ func (controller *Controller) Run(shutdownBroadcast <-chan struct{}) {
 
 	// Start components
 
-	listenIP, err := common.GetInterfaceIPAddress(controller.config.ListenInterface)
-	if err != nil {
-		NoticeError("error getting listener IP: %s", err)
-		return
+	// TODO: IPv6 support
+	var listenIP string
+	if controller.config.ListenInterface == "" {
+		listenIP = "127.0.0.1"
+	} else if controller.config.ListenInterface == "any" {
+		listenIP = "0.0.0.0"
+	} else {
+		IPv4Address, _, err := common.GetInterfaceIPAddresses(controller.config.ListenInterface)
+		if err == nil && IPv4Address == nil {
+			err = fmt.Errorf("no IPv4 address for interface %s", controller.config.ListenInterface)
+		}
+		if err != nil {
+			NoticeError("error getting listener IP: %s", err)
+			return
+		}
+		listenIP = IPv4Address.String()
 	}
 
-	socksProxy, err := NewSocksProxy(controller.config, controller, listenIP)
-	if err != nil {
-		NoticeAlert("error initializing local SOCKS proxy: %s", err)
-		return
+	if !controller.config.DisableLocalSocksProxy {
+		socksProxy, err := NewSocksProxy(controller.config, controller, listenIP)
+		if err != nil {
+			NoticeAlert("error initializing local SOCKS proxy: %s", err)
+			return
+		}
+		defer socksProxy.Close()
 	}
-	defer socksProxy.Close()
 
-	httpProxy, err := NewHttpProxy(
-		controller.config, controller.untunneledDialConfig, controller, listenIP)
-	if err != nil {
-		NoticeAlert("error initializing local HTTP proxy: %s", err)
-		return
+	if !controller.config.DisableLocalHTTPProxy {
+		httpProxy, err := NewHttpProxy(
+			controller.config, controller.untunneledDialConfig, controller, listenIP)
+		if err != nil {
+			NoticeAlert("error initializing local HTTP proxy: %s", err)
+			return
+		}
+		defer httpProxy.Close()
 	}
-	defer httpProxy.Close()
 
 	if !controller.config.DisableRemoteServerListFetcher {
 
@@ -222,6 +267,10 @@ func (controller *Controller) Run(shutdownBroadcast <-chan struct{}) {
 		go controller.establishTunnelWatcher()
 	}
 
+	if controller.packetTunnelClient != nil {
+		controller.packetTunnelClient.Start()
+	}
+
 	// Wait while running
 
 	select {
@@ -233,6 +282,10 @@ func (controller *Controller) Run(shutdownBroadcast <-chan struct{}) {
 
 	close(controller.shutdownBroadcast)
 
+	if controller.packetTunnelClient != nil {
+		controller.packetTunnelClient.Stop()
+	}
+
 	// Interrupts and stops establish workers blocking on
 	// tunnel establishment network operations.
 	controller.establishPendingConns.CloseAll()
@@ -639,6 +692,21 @@ loop:
 				}
 			}
 
+			// Set the new tunnel as the transport for the packet tunnel. The packet tunnel
+			// client remains up when reestablishing, but no packets are relayed while there
+			// is no connected tunnel. UseNewTunnel will establish a new packet tunnel SSH
+			// channel over the new SSH tunnel and configure the packet tunnel client to use
+			// the new SSH channel as its transport.
+			//
+			// Note: as is, this logic is suboptimal for TunnelPoolSize > 1, as this would
+			// continuously initialize new packet tunnel sessions for each established
+			// server. For now, config validation requires TunnelPoolSize == 1 when
+			// the packet tunnel is used.
+
+			if controller.packetTunnelTransport != nil {
+				controller.packetTunnelTransport.UseNewTunnel(establishedTunnel)
+			}
+
 			// TODO: design issue -- might not be enough server entries with region/caps to ever fill tunnel slots;
 			// possible solution is establish target MIN(CountServerEntries(region, protocol), TunnelPoolSize)
 			if controller.isFullyEstablished() {

+ 4 - 3
psiphon/meekConn.go

@@ -799,9 +799,10 @@ func makeMeekCookie(meekConfig *MeekConfig) (cookie *http.Cookie, err error) {
 	// Make the JSON data
 	serverAddress := meekConfig.PsiphonServerAddress
 	cookieData := &protocol.MeekCookieData{
-		ServerAddress:       serverAddress,
-		SessionID:           meekConfig.SessionID,
-		MeekProtocolVersion: MEEK_PROTOCOL_VERSION,
+		ServerAddress:        serverAddress,
+		SessionID:            meekConfig.SessionID,
+		MeekProtocolVersion:  MEEK_PROTOCOL_VERSION,
+		ClientTunnelProtocol: meekConfig.ClientTunnelProtocol,
 	}
 	serializedCookie, err := json.Marshal(cookieData)
 	if err != nil {

+ 78 - 0
psiphon/notice.go

@@ -550,3 +550,81 @@ func (writer *NoticeWriter) Write(p []byte) (n int, err error) {
 	outputNotice(writer.noticeType, noticeIsDiagnostic, "message", string(p))
 	return len(p), nil
 }
+
+// NoticeCommonLogger maps the common.Logger interface to the notice facility.
+// This is used to make the notice facility available to other packages that
+// don't import the "psiphon" package.
+func NoticeCommonLogger() common.Logger {
+	return &commonLogger{}
+}
+
+type commonLogger struct {
+}
+
+func (logger *commonLogger) WithContext() common.LogContext {
+	return &commonLogContext{
+		context: common.GetParentContext(),
+	}
+}
+
+func (logger *commonLogger) WithContextFields(fields common.LogFields) common.LogContext {
+	return &commonLogContext{
+		context: common.GetParentContext(),
+		fields:  fields,
+	}
+}
+
+func (logger *commonLogger) LogMetric(metric string, fields common.LogFields) {
+	outputNotice(
+		metric,
+		noticeIsDiagnostic,
+		listCommonFields(fields)...)
+}
+
+func listCommonFields(fields common.LogFields) []interface{} {
+	fieldList := make([]interface{}, 0)
+	for name, value := range fields {
+		var formattedValue string
+		if err, ok := value.(error); ok {
+			formattedValue = err.Error()
+		} else {
+			formattedValue = fmt.Sprintf("%#v", value)
+		}
+		fieldList = append(fieldList, name, formattedValue)
+	}
+	return fieldList
+}
+
+type commonLogContext struct {
+	context string
+	fields  common.LogFields
+}
+
+func (context *commonLogContext) outputNotice(
+	noticeType string, args ...interface{}) {
+
+	outputNotice(
+		noticeType,
+		noticeIsDiagnostic,
+		append(
+			[]interface{}{
+				"message", fmt.Sprint(args...),
+				"context", context.context},
+			listCommonFields(context.fields)...)...)
+}
+
+func (context *commonLogContext) Debug(args ...interface{}) {
+	// Ignored.
+}
+
+func (context *commonLogContext) Info(args ...interface{}) {
+	context.outputNotice("Info", args)
+}
+
+func (context *commonLogContext) Warning(args ...interface{}) {
+	context.outputNotice("Alert", args)
+}
+
+func (context *commonLogContext) Error(args ...interface{}) {
+	context.outputNotice("Error", args)
+}

+ 336 - 0
psiphon/packetTunnelTransport.go

@@ -0,0 +1,336 @@
+/*
+ * Copyright (c) 2017, 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 (
+	"context"
+	"errors"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+const (
+	PACKET_TUNNEL_PROBE_SLOW_READ  = 3 * time.Second
+	PACKET_TUNNEL_PROBE_SLOW_WRITE = 3 * time.Second
+)
+
+// PacketTunnelTransport is an integration layer that presents an io.ReadWriteCloser interface
+// to a tun.Client as the transport for relaying packets. The Psiphon client may periodically
+// disconnect from and reconnect to the same or different Psiphon servers. PacketTunnelTransport
+// allows the Psiphon client to substitute new transport channels on-the-fly.
+// PacketTunnelTransport implements transport monitoring, using heuristics to determine when
+// the channel tunnel should be probed as a failure check.
+type PacketTunnelTransport struct {
+	// Note: 64-bit ints used with atomic operations are placed
+	// at the start of struct to ensure 64-bit alignment.
+	// (https://golang.org/pkg/sync/atomic/#pkg-note-BUG)
+	lastReadComplete  int64
+	lastWriteStart    int64
+	lastWriteComplete int64
+
+	runContext    context.Context
+	stopRunning   context.CancelFunc
+	workers       *sync.WaitGroup
+	readMutex     sync.Mutex
+	writeMutex    sync.Mutex
+	channelReady  *sync.Cond
+	channelMutex  sync.Mutex
+	channelConn   net.Conn
+	channelTunnel *Tunnel
+}
+
+// NewPacketTunnelTransport initializes a PacketTunnelTransport.
+func NewPacketTunnelTransport() *PacketTunnelTransport {
+
+	runContext, stopRunning := context.WithCancel(context.Background())
+
+	p := &PacketTunnelTransport{
+		runContext:   runContext,
+		stopRunning:  stopRunning,
+		workers:      new(sync.WaitGroup),
+		channelReady: sync.NewCond(new(sync.Mutex)),
+	}
+
+	// The monitor worker will signal the tunnel channel when it
+	// suspects that the packet tunnel channel has failed.
+
+	p.workers.Add(1)
+	go p.monitor()
+
+	return p
+}
+
+// Read implements the io.Reader interface. It uses the current transport channel
+// to read packet data, or waits for a new transport channel to be established
+// after a failure.
+func (p *PacketTunnelTransport) Read(data []byte) (int, error) {
+
+	p.readMutex.Lock()
+	defer p.readMutex.Unlock()
+
+	// getChannel will block if there's no channel.
+
+	channelConn, channelTunnel, err := p.getChannel()
+	if err != nil {
+		return 0, common.ContextError(err)
+	}
+
+	n, err := channelConn.Read(data)
+
+	atomic.StoreInt64(&p.lastReadComplete, int64(monotime.Now()))
+
+	if err != nil {
+
+		// This assumes that any error means the channel has failed, which
+		// is the case for ssh.Channel reads. io.EOF is not ignored, since
+		// a single ssh.Channel may EOF and still get substituted with a new
+		// channel.
+
+		p.failedChannel(channelConn, channelTunnel)
+	}
+
+	return n, err
+}
+
+// Write implements the io.Writer interface. It uses the current transport channel
+// to write packet data, or waits for a new transport channel to be established
+// after a failure.
+func (p *PacketTunnelTransport) Write(data []byte) (int, error) {
+
+	p.writeMutex.Lock()
+	defer p.writeMutex.Unlock()
+
+	channelConn, channelTunnel, err := p.getChannel()
+	if err != nil {
+		return 0, common.ContextError(err)
+	}
+
+	// ssh.Channels are pseudo net.Conns and don't support timeouts/deadlines.
+	// Instead of spawning a goroutine per write, record time values that the
+	// monitor worker will use to detect possible failures, such as writes taking
+	// too long.
+
+	atomic.StoreInt64(&p.lastWriteStart, int64(monotime.Now()))
+
+	n, err := channelConn.Write(data)
+
+	atomic.StoreInt64(&p.lastWriteComplete, int64(monotime.Now()))
+
+	if err != nil {
+
+		// This assumes that any error means the channel has failed, which
+		// is the case for ssh.Channel writes.
+
+		p.failedChannel(channelConn, channelTunnel)
+	}
+
+	return n, err
+}
+
+// Close implements the io.Closer interface. Any underlying transport channel is
+// called, the monitor worker is stopped, and any blocking Read/Write calls will
+// be interrupted.
+func (p *PacketTunnelTransport) Close() error {
+
+	p.stopRunning()
+
+	p.workers.Wait()
+
+	// This broadcast is to wake up reads or writes blocking in getChannel; those
+	// getChannel calls should then abort on the p.runContext.Done() check.
+	p.channelReady.Broadcast()
+
+	p.channelMutex.Lock()
+	if p.channelConn != nil {
+		p.channelConn.Close()
+		p.channelConn = nil
+	}
+	p.channelMutex.Unlock()
+
+	return nil
+}
+
+// UseNewTunnel sets the PacketTunnelTransport to use a new transport channel within
+// the specified tunnel. UseNewTunnel does not block on the open channel call; it spawns
+// a worker that calls tunnel.DialPacketTunnelChannel and uses the resulting channel.
+func (p *PacketTunnelTransport) UseNewTunnel(tunnel *Tunnel) {
+
+	p.workers.Add(1)
+	go func(tunnel *Tunnel) {
+		defer p.workers.Done()
+
+		// channelConn is a net.Conn, since some layering has been applied
+		// (e.g., transferstats.Conn). PacketTunnelTransport assumes the
+		// channelConn is ultimately an ssh.Channel, which is not a fully
+		// functional net.Conn.
+
+		channelConn, err := tunnel.DialPacketTunnelChannel()
+		if err != nil {
+			// Note: DialPacketTunnelChannel will signal a probe on failure,
+			// so it's not necessary to do so here.
+
+			NoticeAlert("dial packet tunnel channel failed : %s", err)
+			// TODO: retry?
+			return
+		}
+
+		p.setChannel(channelConn, tunnel)
+
+	}(tunnel)
+}
+
+func (p *PacketTunnelTransport) setChannel(
+	channelConn net.Conn, channelTunnel *Tunnel) {
+
+	p.channelMutex.Lock()
+
+	// Concurrency note: this check is within the mutex to ensure that a
+	// UseNewTunnel call concurrent with a Close call doesn't leave a channel
+	// set.
+	select {
+	case <-p.runContext.Done():
+		p.channelMutex.Unlock()
+		return
+	default:
+	}
+
+	p.channelConn = channelConn
+	p.channelTunnel = channelTunnel
+
+	p.channelMutex.Unlock()
+
+	p.channelReady.Broadcast()
+}
+
+func (p *PacketTunnelTransport) getChannel() (net.Conn, *Tunnel, error) {
+
+	var channelConn net.Conn
+	var channelTunnel *Tunnel
+
+	p.channelReady.L.Lock()
+	defer p.channelReady.L.Unlock()
+	for {
+
+		select {
+		case <-p.runContext.Done():
+			return nil, nil, common.ContextError(errors.New("already closed"))
+		default:
+		}
+
+		p.channelMutex.Lock()
+		channelConn = p.channelConn
+		channelTunnel = p.channelTunnel
+		p.channelMutex.Unlock()
+		if channelConn != nil {
+			break
+		}
+
+		p.channelReady.Wait()
+	}
+
+	return channelConn, channelTunnel, nil
+}
+
+func (p *PacketTunnelTransport) failedChannel(
+	channelConn net.Conn, channelTunnel *Tunnel) {
+
+	// In case the channel read/write failed and the tunnel isn't
+	// yet in the failed state, trigger a probe.
+
+	select {
+	case channelTunnel.signalPortForwardFailure <- *new(struct{}):
+	default:
+	}
+
+	// Clear the current channel. This will cause subsequent Read/Write
+	// calls to block in getChannel until a new channel is provided.
+	// Concurrency note: must check, within the mutex, that the channelConn
+	// is still the one that failed before clearing, since both Read and
+	// Write could call failedChannel concurrently.
+
+	p.channelMutex.Lock()
+	if p.channelConn == channelConn {
+		p.channelConn.Close()
+		p.channelConn = nil
+		p.channelTunnel = nil
+	}
+	p.channelMutex.Unlock()
+}
+
+func (p *PacketTunnelTransport) monitor() {
+
+	defer p.workers.Done()
+
+	monitorTicker := time.NewTicker(1 * time.Second)
+	defer monitorTicker.Stop()
+
+	lastSignalTime := monotime.Time(0)
+
+	for {
+		select {
+		case <-p.runContext.Done():
+			return
+		case <-monitorTicker.C:
+			lastReadComplete := monotime.Time(atomic.LoadInt64(&p.lastReadComplete))
+			lastWriteStart := monotime.Time(atomic.LoadInt64(&p.lastWriteStart))
+			lastWriteComplete := monotime.Time(atomic.LoadInt64(&p.lastWriteComplete))
+
+			// Heuristics to determine if the tunnel channel may have failed:
+			// - a Write has blocked for too long
+			// - no Reads after recent Writes
+			//
+			// When a heuristic is hit, a signal is sent to the channel tunnel
+			// which will invoke and SSH keep alive probe of the tunnel. Nothing
+			// is torn down here. If the tunnel determines it has failed, it will
+			// close itself, which closes its channels, which will cause blocking
+			// PacketTunnelTransport Reads/Writes to fail and call failedChannel.
+
+			if (lastWriteStart != 0 &&
+				lastWriteStart.Sub(lastWriteComplete) > PACKET_TUNNEL_PROBE_SLOW_WRITE) ||
+				(lastWriteComplete.Sub(lastReadComplete) > PACKET_TUNNEL_PROBE_SLOW_READ) {
+
+				// Don't keep signalling due to an old condition
+				if lastWriteStart.Add(PACKET_TUNNEL_PROBE_SLOW_WRITE).Before(lastSignalTime) &&
+					lastWriteComplete.Add(PACKET_TUNNEL_PROBE_SLOW_READ).Before(lastSignalTime) {
+
+					break
+				}
+
+				p.channelMutex.Lock()
+				channelTunnel := p.channelTunnel
+				p.channelMutex.Unlock()
+
+				if channelTunnel != nil {
+					select {
+					case channelTunnel.signalPortForwardFailure <- *new(struct{}):
+					default:
+					}
+				}
+
+				lastSignalTime = monotime.Now()
+			}
+		}
+	}
+}

+ 11 - 5
psiphon/remoteServerList_test.go

@@ -57,10 +57,16 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 	// create a server
 	//
 
-	serverIPaddress := ""
+	serverIPAddress := ""
 	for _, interfaceName := range []string{"eth0", "en0"} {
-		serverIPaddress, err = common.GetInterfaceIPAddress(interfaceName)
+		var serverIPv4Address, serverIPv6Address net.IP
+		serverIPv4Address, serverIPv6Address, err = common.GetInterfaceIPAddresses(interfaceName)
 		if err == nil {
+			if serverIPv4Address != nil {
+				serverIPAddress = serverIPv4Address.String()
+			} else {
+				serverIPAddress = serverIPv6Address.String()
+			}
 			break
 		}
 	}
@@ -70,7 +76,7 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 
 	serverConfigJSON, _, encodedServerEntry, err := server.GenerateConfig(
 		&server.GenerateConfigParams{
-			ServerIPAddress:      serverIPaddress,
+			ServerIPAddress:      serverIPAddress,
 			EnableSSHAPIRequests: true,
 			WebServerPort:        8001,
 			TunnelProtocolPorts:  map[string]int{"OSSH": 4001},
@@ -205,8 +211,8 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 
 	// Exercise using multiple download URLs
 	remoteServerListHostAddresses := []string{
-		net.JoinHostPort(serverIPaddress, "8081"),
-		net.JoinHostPort(serverIPaddress, "8082"),
+		net.JoinHostPort(serverIPAddress, "8081"),
+		net.JoinHostPort(serverIPAddress, "8082"),
 	}
 
 	// The common remote server list fetches will 404

+ 18 - 0
psiphon/server/config.go

@@ -256,6 +256,24 @@ type Config struct {
 	// OSLConfigFilename is the path of a file containing a JSON-encoded
 	// OSL Config, the OSL schemes to apply to Psiphon client tunnels.
 	OSLConfigFilename string
+
+	// RunPacketTunnel specifies whether to run a packet tunnel.
+	RunPacketTunnel bool
+
+	// PacketTunnelEgressInterface specifies tun.ServerConfig.EgressInterface.
+	PacketTunnelEgressInterface string
+
+	// PacketTunnelDownStreamPacketQueueSize specifies
+	// tun.ServerConfig.DownStreamPacketQueueSize.
+	PacketTunnelDownStreamPacketQueueSize int
+
+	// PacketTunnelSessionIdleExpirySeconds specifies
+	// tun.ServerConfig.SessionIdleExpirySeconds.
+	PacketTunnelSessionIdleExpirySeconds int
+
+	// PacketTunnelSudoNetworkConfigCommands sets
+	// tun.ServerConfig.SudoNetworkConfigCommands.
+	PacketTunnelSudoNetworkConfigCommands bool
 }
 
 // RunWebServer indicates whether to run a web server component.

+ 36 - 1
psiphon/server/dns.go

@@ -126,6 +126,16 @@ func NewDNSResolver(defaultResolver string) (*DNSResolver, error) {
 // the resolvers becomes unavailable.
 func (dns *DNSResolver) Get() net.IP {
 
+	dns.reloadWhenStale()
+
+	dns.ReloadableFile.RLock()
+	defer dns.ReloadableFile.RUnlock()
+
+	return dns.resolvers[rand.Intn(len(dns.resolvers))]
+}
+
+func (dns *DNSResolver) reloadWhenStale() {
+
 	// Every UDP DNS port forward frequently calls Get(), so this code
 	// is intended to minimize blocking. Most callers will hit just the
 	// atomic.LoadInt64 reload time check and the RLock (an atomic.AddInt32
@@ -159,11 +169,36 @@ func (dns *DNSResolver) Get() net.IP {
 			atomic.StoreInt32(&dns.isReloading, 0)
 		}
 	}
+}
+
+// GetAllIPv4 returns a list of all IPv4 DNS resolver addresses.
+// Cached values are updated if they're stale. If reloading fails,
+// the previous values are used.
+func (dns *DNSResolver) GetAllIPv4() []net.IP {
+	return dns.getAll(false)
+}
+
+// GetAllIPv6 returns a list of all IPv6 DNS resolver addresses.
+// Cached values are updated if they're stale. If reloading fails,
+// the previous values are used.
+func (dns *DNSResolver) GetAllIPv6() []net.IP {
+	return dns.getAll(true)
+}
+
+func (dns *DNSResolver) getAll(wantIPv6 bool) []net.IP {
+
+	dns.reloadWhenStale()
 
 	dns.ReloadableFile.RLock()
 	defer dns.ReloadableFile.RUnlock()
 
-	return dns.resolvers[rand.Intn(len(dns.resolvers))]
+	resolvers := make([]net.IP, 0)
+	for _, resolver := range dns.resolvers {
+		if (resolver.To4() == nil) == wantIPv6 {
+			resolvers = append(resolvers, resolver)
+		}
+	}
+	return resolvers
 }
 
 func parseResolveConf(fileContent []byte) ([]net.IP, error) {

+ 28 - 0
psiphon/server/log.go

@@ -118,6 +118,34 @@ func (logger *ContextLogger) LogPanicRecover(recoverValue interface{}, stack []b
 		})
 }
 
+type commonLogger struct {
+	contextLogger *ContextLogger
+}
+
+func (logger *commonLogger) WithContext() common.LogContext {
+	// Patch context to be correct parent
+	return logger.contextLogger.WithContext().WithField("context", common.GetParentContext())
+}
+
+func (logger *commonLogger) WithContextFields(fields common.LogFields) common.LogContext {
+	// Patch context to be correct parent
+	return logger.contextLogger.WithContextFields(LogFields(fields)).WithField("context", common.GetParentContext())
+}
+
+func (logger *commonLogger) LogMetric(metric string, fields common.LogFields) {
+	fields["event_name"] = metric
+	logger.contextLogger.LogRawFieldsWithTimestamp(LogFields(fields))
+}
+
+// CommonLogger wraps a ContextLogger instance with an interface
+// that conforms to common.Logger. This is used to make the ContextLogger
+// available to other packages that don't import the "server" package.
+func CommonLogger(contextLogger *ContextLogger) *commonLogger {
+	return &commonLogger{
+		contextLogger: contextLogger,
+	}
+}
+
 // NewLogWriter returns an io.PipeWriter that can be used to write
 // to the global logger. Caller must Close() the writer.
 func NewLogWriter() *io.PipeWriter {

+ 7 - 1
psiphon/server/server_test.go

@@ -50,8 +50,14 @@ func TestMain(m *testing.M) {
 
 	var err error
 	for _, interfaceName := range []string{"eth0", "en0"} {
-		serverIPAddress, err = common.GetInterfaceIPAddress(interfaceName)
+		var serverIPv4Address, serverIPv6Address net.IP
+		serverIPv4Address, serverIPv6Address, err = common.GetInterfaceIPAddresses(interfaceName)
 		if err == nil {
+			if serverIPv4Address != nil {
+				serverIPAddress = serverIPv4Address.String()
+			} else {
+				serverIPAddress = serverIPv6Address.String()
+			}
 			break
 		}
 	}

+ 42 - 7
psiphon/server/services.go

@@ -36,6 +36,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
 )
 
@@ -78,6 +79,39 @@ func RunServices(configJSON []byte) error {
 
 	supportServices.TunnelServer = tunnelServer
 
+	if config.RunPacketTunnel {
+
+		packetTunnelServer, err := tun.NewServer(&tun.ServerConfig{
+			Logger: CommonLogger(log),
+			SudoNetworkConfigCommands:   config.PacketTunnelSudoNetworkConfigCommands,
+			GetDNSResolverIPv4Addresses: supportServices.DNSResolver.GetAllIPv4,
+			GetDNSResolverIPv6Addresses: supportServices.DNSResolver.GetAllIPv6,
+			EgressInterface:             config.PacketTunnelEgressInterface,
+			DownStreamPacketQueueSize:   config.PacketTunnelDownStreamPacketQueueSize,
+			SessionIdleExpirySeconds:    config.PacketTunnelSessionIdleExpirySeconds,
+		})
+		if err != nil {
+			log.WithContextFields(LogFields{"error": err}).Error("init packet tunnel failed")
+			return common.ContextError(err)
+		}
+
+		supportServices.PacketTunnelServer = packetTunnelServer
+	}
+
+	// After this point, errors should be delivered to the "errors" channel and
+	// orderly shutdown should flow through to the end of the function to ensure
+	// all workers are synchronously stopped.
+
+	if config.RunPacketTunnel {
+		supportServices.PacketTunnelServer.Start()
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			<-shutdownBroadcast
+			supportServices.PacketTunnelServer.Stop()
+		}()
+	}
+
 	if config.RunLoadMonitor() {
 		waitGroup.Add(1)
 		go func() {
@@ -330,13 +364,14 @@ func logServerLoad(server *TunnelServer) {
 // components, which allows these data components to be refreshed
 // without restarting the server process.
 type SupportServices struct {
-	Config          *Config
-	TrafficRulesSet *TrafficRulesSet
-	OSLConfig       *osl.Config
-	PsinetDatabase  *psinet.Database
-	GeoIPService    *GeoIPService
-	DNSResolver     *DNSResolver
-	TunnelServer    *TunnelServer
+	Config             *Config
+	TrafficRulesSet    *TrafficRulesSet
+	OSLConfig          *osl.Config
+	PsinetDatabase     *psinet.Database
+	GeoIPService       *GeoIPService
+	DNSResolver        *DNSResolver
+	TunnelServer       *TunnelServer
+	PacketTunnelServer *tun.Server
 }
 
 // NewSupportServices initializes a new SupportServices.

+ 69 - 1
psiphon/server/tunnelServer.go

@@ -749,6 +749,7 @@ type sshClient struct {
 	supportsServerRequests               bool
 	handshakeState                       handshakeState
 	udpChannel                           ssh.Channel
+	packetTunnelChannel                  ssh.Channel
 	trafficRules                         TrafficRules
 	tcpTrafficState                      trafficState
 	udpTrafficState                      trafficState
@@ -1283,6 +1284,9 @@ func (sshClient *sshClient) runTunnel(
 
 	// Handle new channel (port forward) requests from the client.
 	//
+	// packet tunnel channels are handled by the packet tunnel server
+	// component. Each client may have at most one packet tunnel channel.
+	//
 	// udpgw client connections are dispatched immediately (clients use this for
 	// DNS, so it's essential to not block; and only one udpgw connection is
 	// retained at a time).
@@ -1292,6 +1296,39 @@ func (sshClient *sshClient) runTunnel(
 
 	for newChannel := range channels {
 
+		if newChannel.ChannelType() == protocol.PACKET_TUNNEL_CHANNEL_TYPE {
+
+			// Accept this channel immediately. This channel will replace any
+			// previously existing packet tunnel channel for this client.
+
+			packetTunnelChannel, requests, err := newChannel.Accept()
+			if err != nil {
+				log.WithContextFields(LogFields{"error": err}).Warning("accept new channel failed")
+				continue
+			}
+			go ssh.DiscardRequests(requests)
+
+			sshClient.setPacketTunnelChannel(packetTunnelChannel)
+
+			// PacketTunnelServer will run the client's packet tunnel. ClientDisconnected will
+			// be called by setPacketTunnelChannel: either if the client starts a new packet
+			// tunnel channel, or on exit of this function.
+
+			checkAllowedTCPPortFunc := func(upstreamIPAddress net.IP, port int) bool {
+				return sshClient.isPortForwardPermitted(portForwardTypeTCP, false, upstreamIPAddress, port)
+			}
+
+			checkAllowedUDPPortFunc := func(upstreamIPAddress net.IP, port int) bool {
+				return sshClient.isPortForwardPermitted(portForwardTypeUDP, false, upstreamIPAddress, port)
+			}
+
+			sshClient.sshServer.support.PacketTunnelServer.ClientConnected(
+				sshClient.sessionID,
+				packetTunnelChannel,
+				checkAllowedTCPPortFunc,
+				checkAllowedUDPPortFunc)
+		}
+
 		if newChannel.ChannelType() != "direct-tcpip" {
 			sshClient.rejectNewChannel(newChannel, ssh.Prohibited, "unknown or unsupported channel type")
 			continue
@@ -1358,9 +1395,40 @@ func (sshClient *sshClient) runTunnel(
 	// Stop all other worker goroutines
 	sshClient.stopRunning()
 
+	// This calls PacketTunnelServer.ClientDisconnected,
+	// which stops packet tunnel workers.
+	sshClient.setPacketTunnelChannel(nil)
+
 	waitGroup.Wait()
 }
 
+// setPacketTunnelChannel sets the single packet tunnel channel
+// for this sshClient. Any existing packet tunnel channel is
+// closed and its underlying session idled.
+func (sshClient *sshClient) setPacketTunnelChannel(channel ssh.Channel) {
+	sshClient.Lock()
+	if sshClient.packetTunnelChannel != nil {
+		sshClient.packetTunnelChannel.Close()
+		sshClient.sshServer.support.PacketTunnelServer.ClientDisconnected(
+			sshClient.sessionID)
+	}
+	sshClient.packetTunnelChannel = channel
+	sshClient.Unlock()
+}
+
+// setUDPChannel sets the single UDP channel for this sshClient.
+// Each sshClient may have only one concurrent UDP channel. Each
+// UDP channel multiplexes many UDP port forwards via the udpgw
+// protocol. Any existing UDP channel is closed.
+func (sshClient *sshClient) setUDPChannel(channel ssh.Channel) {
+	sshClient.Lock()
+	if sshClient.udpChannel != nil {
+		sshClient.udpChannel.Close()
+	}
+	sshClient.udpChannel = channel
+	sshClient.Unlock()
+}
+
 func (sshClient *sshClient) logTunnel(additionalMetrics LogFields) {
 
 	// Note: reporting duration based on last confirmed data transfer, which
@@ -1704,7 +1772,7 @@ func (sshClient *sshClient) isPortForwardPermitted(
 
 	// Disallow connection to loopback. This is a failsafe. The server
 	// should be run on a host with correctly configured firewall rules.
-	// And exception is made in the case of tranparent DNS forwarding,
+	// An exception is made in the case of tranparent DNS forwarding,
 	// where the remoteIP has been rewritten.
 	if !isTransparentDNSForwarding && remoteIP.IsLoopback() {
 		return false

+ 0 - 13
psiphon/server/udp.go

@@ -34,19 +34,6 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
-// setUDPChannel sets the single UDP channel for this sshClient.
-// Each sshClient may have only one concurrent UDP channel. Each
-// UDP channel multiplexes many UDP port forwards via the udpgw
-// protocol. Any existing UDP channel is closed.
-func (sshClient *sshClient) setUDPChannel(channel ssh.Channel) {
-	sshClient.Lock()
-	if sshClient.udpChannel != nil {
-		sshClient.udpChannel.Close()
-	}
-	sshClient.udpChannel = channel
-	sshClient.Unlock()
-}
-
 // handleUDPChannel implements UDP port forwarding. A single UDP
 // SSH channel follows the udpgw protocol, which multiplexes many
 // UDP port forwards.

+ 36 - 2
psiphon/tunnel.go

@@ -308,6 +308,41 @@ func (tunnel *Tunnel) Dial(
 		tunnel:         tunnel,
 		downstreamConn: downstreamConn}
 
+	return tunnel.wrapWithTransferStats(conn), nil
+}
+
+func (tunnel *Tunnel) DialPacketTunnelChannel() (net.Conn, error) {
+
+	channel, requests, err := tunnel.sshClient.OpenChannel(
+		protocol.PACKET_TUNNEL_CHANNEL_TYPE, nil)
+	if err != nil {
+
+		// TODO: conditional on type of error or error message?
+		select {
+		case tunnel.signalPortForwardFailure <- *new(struct{}):
+		default:
+		}
+
+		return nil, common.ContextError(err)
+	}
+	go ssh.DiscardRequests(requests)
+
+	conn := newChannelConn(channel)
+
+	// wrapWithTransferStats will track bytes transferred for the
+	// packet tunnel. It will count packet overhead (TCP/UDP/IP headers).
+	//
+	// Since the data in the channel is not HTTP or TLS, no domain bytes
+	// counting is expected.
+	//
+	// transferstats are also used to determine that there's been recent
+	// activity and skip periodic SSH keep alives; see Tunnel.operateTunnel.
+
+	return tunnel.wrapWithTransferStats(conn), nil
+}
+
+func (tunnel *Tunnel) wrapWithTransferStats(conn net.Conn) net.Conn {
+
 	// Tunnel does not have a serverContext when DisableApi is set. We still use
 	// transferstats.Conn to count bytes transferred for monitoring tunnel
 	// quality.
@@ -315,9 +350,8 @@ func (tunnel *Tunnel) Dial(
 	if tunnel.serverContext != nil {
 		regexps = tunnel.serverContext.StatsRegexps()
 	}
-	conn = transferstats.NewConn(conn, tunnel.serverEntry.IpAddress, regexps)
 
-	return conn, nil
+	return transferstats.NewConn(conn, tunnel.serverEntry.IpAddress, regexps)
 }
 
 // SignalComponentFailure notifies the tunnel that an associated component has failed.

+ 8 - 1
psiphon/userAgent_test.go

@@ -21,6 +21,7 @@ package psiphon
 
 import (
 	"fmt"
+	"net"
 	"net/http"
 	"sync"
 	"testing"
@@ -157,8 +158,14 @@ func attemptConnectionsWithUserAgent(
 	var err error
 	serverIPaddress := ""
 	for _, interfaceName := range []string{"eth0", "en0"} {
-		serverIPaddress, err = common.GetInterfaceIPAddress(interfaceName)
+		var serverIPv4Address, serverIPv6Address net.IP
+		serverIPv4Address, serverIPv6Address, err = common.GetInterfaceIPAddresses(interfaceName)
 		if err == nil {
+			if serverIPv4Address != nil {
+				serverIPaddress = serverIPv4Address.String()
+			} else {
+				serverIPaddress = serverIPv6Address.String()
+			}
 			break
 		}
 	}

+ 49 - 0
psiphon/utils.go

@@ -28,7 +28,9 @@ import (
 	"net/url"
 	"os"
 	"syscall"
+	"time"
 
+	"github.com/Psiphon-Inc/crypto/ssh"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
@@ -136,3 +138,50 @@ func (writer *SyncFileWriter) Write(p []byte) (n int, err error) {
 	}
 	return
 }
+
+// emptyAddr implements the net.Addr interface. emptyAddr is intended to be
+// used as a stub, when a net.Addr is required but not used.
+type emptyAddr struct {
+}
+
+func (e *emptyAddr) String() string {
+	return ""
+}
+
+func (e *emptyAddr) Network() string {
+	return ""
+}
+
+// channelConn implements the net.Conn interface. channelConn allows use of
+// SSH.Channels in contexts where a net.Conn is expected. Only Read/Write/Close
+// are implemented and the remaining functions are stubs and expected to not
+// be used.
+type channelConn struct {
+	ssh.Channel
+}
+
+func newChannelConn(channel ssh.Channel) *channelConn {
+	return &channelConn{
+		Channel: channel,
+	}
+}
+
+func (conn *channelConn) LocalAddr() net.Addr {
+	return new(emptyAddr)
+}
+
+func (conn *channelConn) RemoteAddr() net.Addr {
+	return new(emptyAddr)
+}
+
+func (conn *channelConn) SetDeadline(_ time.Time) error {
+	return common.ContextError(errors.New("unsupported"))
+}
+
+func (conn *channelConn) SetReadDeadline(_ time.Time) error {
+	return common.ContextError(errors.New("unsupported"))
+}
+
+func (conn *channelConn) SetWriteDeadline(_ time.Time) error {
+	return common.ContextError(errors.New("unsupported"))
+}