Browse Source

Merge pull request #166 from rod-hynes/master

New tunnel-core server stack
Rod Hynes 10 years ago
parent
commit
12ebfbf366

+ 0 - 0
ConsoleClient/psiphonClient.go → ConsoleClient/main.go


+ 17 - 0
Server/README.md

@@ -0,0 +1,17 @@
+Psiphon Tunnel Core Server README
+================================================================================
+
+Overview
+--------------------------------------------------------------------------------
+
+The `Server` program and the `github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server` package contain an experimental Psiphon server stack.
+
+Functionality is based on the (production server stack)[https://bitbucket.org/psiphon/psiphon-circumvention-system/src/tip/Server/] but only a small subset is implemented. Currently, this stack supports the `SSH` protocol and has a minimal web server to support the API calls the tunnel-core client requires.
+
+Usage
+--------------------------------------------------------------------------------
+
+* Execute `Server generate` to generate a server configuration, including new key material and credentials. This will emit a config file and a server entry file.
+ * Note: `generate` does not yet take input parameters, so for now you must edit code if you must change the server IP address or ports.
+* Execute `Server run` to run the server stack using the generated configuration.
+* Copy the contents of the server entry file to the client (e.g., the `TargetServerEntry` config field in the tunnel-core client) to connect to the server.

+ 79 - 0
Server/main.go

@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2016, 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 main
+
+import (
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server"
+)
+
+func main() {
+
+	flag.Parse()
+
+	args := flag.Args()
+
+	// TODO: add working directory flag
+	configFilename := server.SERVER_CONFIG_FILENAME
+	serverEntryFilename := server.SERVER_ENTRY_FILENAME
+
+	if len(args) < 1 {
+		fmt.Errorf("usage: '%s generate' or '%s run'", os.Args[0])
+		os.Exit(1)
+	} else if args[0] == "generate" {
+
+		// TODO: flags to set generate params
+		configFileContents, serverEntryFileContents, err := server.GenerateConfig(
+			&server.GenerateConfigParams{})
+		if err != nil {
+			fmt.Errorf("generate failed: %s", err)
+			os.Exit(1)
+		}
+		err = ioutil.WriteFile(configFilename, configFileContents, 0600)
+		if err != nil {
+			fmt.Errorf("error writing configuration file: %s", err)
+			os.Exit(1)
+		}
+
+		err = ioutil.WriteFile(serverEntryFilename, serverEntryFileContents, 0600)
+		if err != nil {
+			fmt.Errorf("error writing server entry file: %s", err)
+			os.Exit(1)
+		}
+
+	} else if args[0] == "run" {
+
+		configFileContents, err := ioutil.ReadFile(configFilename)
+		if err != nil {
+			fmt.Errorf("error loading configuration file: %s", err)
+			os.Exit(1)
+		}
+
+		err = server.RunServices(configFileContents)
+		if err != nil {
+			fmt.Errorf("run failed: %s", err)
+			os.Exit(1)
+		}
+	}
+}

+ 3 - 6
psiphon/TCPConn.go

@@ -64,12 +64,9 @@ func makeTCPDialer(config *DialConfig) func(network, addr string) (net.Conn, err
 		// Note: when an upstream proxy is used, we don't know what IP address
 		// was resolved, by the proxy, for that destination.
 		if config.ResolvedIPCallback != nil && config.UpstreamProxyUrl == "" {
-			remoteAddr := conn.RemoteAddr()
-			if remoteAddr != nil {
-				host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
-				if err == nil {
-					config.ResolvedIPCallback(host)
-				}
+			ipAddress := IPAddressFromAddr(conn.RemoteAddr())
+			if ipAddress != "" {
+				config.ResolvedIPCallback(ipAddress)
 			}
 		}
 		return conn, nil

+ 2 - 2
psiphon/meekConn.go

@@ -680,12 +680,12 @@ func makeCookie(meekConfig *MeekConfig) (cookie *http.Cookie, err error) {
 	copy(encryptedCookie[32:], box)
 
 	// Obfuscate the encrypted data
-	obfuscator, err := NewObfuscator(
+	obfuscator, err := NewClientObfuscator(
 		&ObfuscatorConfig{Keyword: meekConfig.MeekObfuscatedKey, MaxPadding: MEEK_COOKIE_MAX_PADDING})
 	if err != nil {
 		return nil, ContextError(err)
 	}
-	obfuscatedCookie := obfuscator.ConsumeSeedMessage()
+	obfuscatedCookie := obfuscator.SendSeedMessage()
 	seedLen := len(obfuscatedCookie)
 	obfuscatedCookie = append(obfuscatedCookie, encryptedCookie...)
 	obfuscator.ObfuscateClientToServer(obfuscatedCookie[seedLen:])

+ 36 - 0
psiphon/net.go

@@ -377,3 +377,39 @@ func MakeTunneledHttpClient(
 		Timeout:   requestTimeout,
 	}, nil
 }
+
+// IPAddressFromAddr is a helper which extracts an IP address
+// from a net.Addr or returns "" if there is no IP address.
+func IPAddressFromAddr(addr net.Addr) string {
+	ipAddress := ""
+	if addr != nil {
+		host, _, err := net.SplitHostPort(addr.String())
+		if err == nil {
+			ipAddress = host
+		}
+	}
+	return ipAddress
+}
+
+// TimeoutTCPConn wraps a net.TCPConn and sets an initial ReadDeadline. The
+// deadline is reset whenever data is received from the connection.
+type TimeoutTCPConn struct {
+	*net.TCPConn
+	deadline time.Duration
+}
+
+func NewTimeoutTCPConn(tcpConn *net.TCPConn, deadline time.Duration) *TimeoutTCPConn {
+	tcpConn.SetReadDeadline(time.Now().Add(deadline))
+	return &TimeoutTCPConn{
+		TCPConn:  tcpConn,
+		deadline: deadline,
+	}
+}
+
+func (conn *TimeoutTCPConn) Read(buffer []byte) (int, error) {
+	n, err := conn.TCPConn.Read(buffer)
+	if err == nil {
+		conn.TCPConn.SetReadDeadline(time.Now().Add(conn.deadline))
+	}
+	return n, err
+}

+ 312 - 148
psiphon/obfuscatedSshConn.go

@@ -27,34 +27,26 @@ import (
 	"net"
 )
 
-type ObfuscatedSshReadState int
-
 const (
-	OBFUSCATION_READ_STATE_SERVER_IDENTIFICATION_LINE = iota
-	OBFUSCATION_READ_STATE_SERVER_KEX_PACKETS
-	OBFUSCATION_READ_STATE_FLUSH
-	OBFUSCATION_READ_STATE_FINISHED
-)
-
-type ObfuscatedSshWriteState int
-
-const (
-	OBFUSCATION_WRITE_STATE_SEND_CLIENT_SEED_MESSAGE = iota
-	OBFUSCATION_WRITE_STATE_CLIENT_IDENTIFICATION_LINE
-	OBFUSCATION_WRITE_STATE_CLIENT_KEX_PACKETS
-	OBFUSCATION_WRITE_STATE_FINISHED
+	SSH_MAX_SERVER_LINE_LENGTH = 1024
+	SSH_PACKET_PREFIX_LENGTH   = 5          // uint32 + byte
+	SSH_MAX_PACKET_LENGTH      = 256 * 1024 // OpenSSH max packet length
+	SSH_MSG_NEWKEYS            = 21
+	SSH_MAX_PADDING_LENGTH     = 255 // RFC 4253 sec. 6
+	SSH_PADDING_MULTIPLE       = 16  // Default cipher block size
 )
 
 // ObfuscatedSshConn wraps a Conn and applies the obfuscated SSH protocol
 // to the traffic on the connection:
 // https://github.com/brl/obfuscated-openssh/blob/master/README.obfuscation
-// ObfuscatedSshConn is used to add obfuscation to go's stock ssh client
-// without modification to that standard library code.
-// The underlying connection must be used for SSH client traffic. This code
+//
+// ObfuscatedSshConn is used to add obfuscation to golang's stock ssh
+// client and server without modification to that standard library code.
+// The underlying connection must be used for SSH traffic. This code
 // injects the obfuscated seed message, applies obfuscated stream cipher
 // transformations, and performs minimal parsing of the SSH protocol to
 // determine when to stop obfuscation (after the first SSH_MSG_NEWKEYS is
-// sent by the client and received from the server).
+// sent and received).
 //
 // WARNING: doesn't fully conform to net.Conn concurrency semantics: there's
 // no synchronization of access to the read/writeBuffers, so concurrent
@@ -62,39 +54,97 @@ const (
 //
 type ObfuscatedSshConn struct {
 	net.Conn
-	obfuscator  *Obfuscator
-	readState   ObfuscatedSshReadState
-	writeState  ObfuscatedSshWriteState
-	readBuffer  []byte
-	writeBuffer []byte
+	mode            ObfuscatedSshConnMode
+	obfuscator      *Obfuscator
+	readDeobfuscate func([]byte)
+	writeObfuscate  func([]byte)
+	readState       ObfuscatedSshReadState
+	writeState      ObfuscatedSshWriteState
+	readBuffer      []byte
+	writeBuffer     []byte
 }
 
+type ObfuscatedSshConnMode int
+
 const (
-	SSH_MAX_SERVER_LINE_LENGTH = 1024
-	SSH_PACKET_PREFIX_LENGTH   = 5          // uint32 + byte
-	SSH_MAX_PACKET_LENGTH      = 256 * 1024 // OpenSSH max packet length
-	SSH_MSG_NEWKEYS            = 21
-	SSH_MAX_PADDING_LENGTH     = 255 // RFC 4253 sec. 6
-	SSH_PADDING_MULTIPLE       = 16  // Default cipher block size
+	OBFUSCATION_CONN_MODE_CLIENT = iota
+	OBFUSCATION_CONN_MODE_SERVER
 )
 
-// NewObfuscatedSshConn creates a new ObfuscatedSshConn. The underlying
-// conn must be used for SSH client traffic and must have transferred
-// no traffic.
-func NewObfuscatedSshConn(conn net.Conn, obfuscationKeyword string) (*ObfuscatedSshConn, error) {
-	obfuscator, err := NewObfuscator(&ObfuscatorConfig{Keyword: obfuscationKeyword})
-	if err != nil {
-		return nil, ContextError(err)
+type ObfuscatedSshReadState int
+
+const (
+	OBFUSCATION_READ_STATE_IDENTIFICATION_LINES = iota
+	OBFUSCATION_READ_STATE_KEX_PACKETS
+	OBFUSCATION_READ_STATE_FLUSH
+	OBFUSCATION_READ_STATE_FINISHED
+)
+
+type ObfuscatedSshWriteState int
+
+const (
+	OBFUSCATION_WRITE_STATE_CLIENT_SEND_SEED_MESSAGE = iota
+	OBFUSCATION_WRITE_STATE_SERVER_SEND_IDENTIFICATION_LINE_PADDING
+	OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE
+	OBFUSCATION_WRITE_STATE_KEX_PACKETS
+	OBFUSCATION_WRITE_STATE_FINISHED
+)
+
+// NewObfuscatedSshConn creates a new ObfuscatedSshConn.
+// The underlying conn must be used for SSH traffic and must have
+// transferred no traffic.
+//
+// In client mode, NewObfuscatedSshConn does not block or initiate network
+// I/O. The obfuscation seed message is sent when Write() is first called.
+//
+// In server mode, NewObfuscatedSshConn cannot completely initialize itself
+// without the seed message from the client to derive obfuscation keys. So
+// NewObfuscatedSshConn blocks on reading the client seed message from the
+// underlying conn.
+//
+func NewObfuscatedSshConn(
+	mode ObfuscatedSshConnMode,
+	conn net.Conn,
+	obfuscationKeyword string) (*ObfuscatedSshConn, error) {
+
+	var err error
+	var obfuscator *Obfuscator
+	var readDeobfuscate, writeObfuscate func([]byte)
+	var writeState ObfuscatedSshWriteState
+
+	if mode == OBFUSCATION_CONN_MODE_CLIENT {
+		obfuscator, err = NewClientObfuscator(&ObfuscatorConfig{Keyword: obfuscationKeyword})
+		if err != nil {
+			return nil, ContextError(err)
+		}
+		readDeobfuscate = obfuscator.ObfuscateServerToClient
+		writeObfuscate = obfuscator.ObfuscateClientToServer
+		writeState = OBFUSCATION_WRITE_STATE_CLIENT_SEND_SEED_MESSAGE
+	} else {
+		// NewServerObfuscator reads a seed message from conn
+		obfuscator, err = NewServerObfuscator(
+			conn, &ObfuscatorConfig{Keyword: obfuscationKeyword})
+		if err != nil {
+			// TODO: readForver() equivilent
+			return nil, ContextError(err)
+		}
+		readDeobfuscate = obfuscator.ObfuscateClientToServer
+		writeObfuscate = obfuscator.ObfuscateServerToClient
+		writeState = OBFUSCATION_WRITE_STATE_SERVER_SEND_IDENTIFICATION_LINE_PADDING
 	}
+
 	return &ObfuscatedSshConn{
-		Conn:       conn,
-		obfuscator: obfuscator,
-		readState:  OBFUSCATION_READ_STATE_SERVER_IDENTIFICATION_LINE,
-		writeState: OBFUSCATION_WRITE_STATE_SEND_CLIENT_SEED_MESSAGE,
+		Conn:            conn,
+		mode:            mode,
+		obfuscator:      obfuscator,
+		readDeobfuscate: readDeobfuscate,
+		writeObfuscate:  writeObfuscate,
+		readState:       OBFUSCATION_READ_STATE_IDENTIFICATION_LINES,
+		writeState:      writeState,
 	}, nil
 }
 
-// Read wraps standard Read, transparently applying the obfusation
+// Read wraps standard Read, transparently applying the obfuscation
 // transformations.
 func (conn *ObfuscatedSshConn) Read(buffer []byte) (n int, err error) {
 	if conn.readState == OBFUSCATION_READ_STATE_FINISHED {
@@ -118,10 +168,12 @@ func (conn *ObfuscatedSshConn) Write(buffer []byte) (n int, err error) {
 	return len(buffer), nil
 }
 
-// readAndTransform reads and transforms the server->client bytes stream
+// readAndTransform reads and transforms the downstream bytes stream
 // while in an obfucation state. It parses the stream of bytes read
-// looking for the first SSH_MSG_NEWKEYS packet sent from the server,
-// after which obfuscation is turned off.
+// looking for the first SSH_MSG_NEWKEYS packet sent from the peer,
+// after which obfuscation is turned off. Since readAndTransform may
+// read in more bytes that the higher-level conn.Read() can consume,
+// read bytes are buffered and may be returned in subsequent calls.
 //
 // readAndTransform also implements a workaround for issues with
 // ssh/transport.go exchangeVersions/readVersion and Psiphon's openssh
@@ -132,7 +184,7 @@ func (conn *ObfuscatedSshConn) Write(buffer []byte) (n int, err error) {
 //   The server MAY send other lines of data before sending the
 //   version string. [...] Clients MUST be able to process such lines.
 //
-// A comment in exchangeVersions explains that the go code doesn't
+// A comment in exchangeVersions explains that the golang code doesn't
 // support this:
 //   Contrary to the RFC, we do not ignore lines that don't
 //   start with "SSH-2.0-" to make the library usable with
@@ -140,15 +192,15 @@ func (conn *ObfuscatedSshConn) Write(buffer []byte) (n int, err error) {
 //
 // In addition, Psiphon's server sends up to 512 characters per extra
 // line. It's not clear that the 255 max string size in sec 4.2 refers
-// to the extra lines as well, but in any case go's code only supports
-// a 255 character lines.
+// to the extra lines as well, but in any case golang's code only
+// supports 255 character lines.
 //
-// State OBFUSCATION_READ_STATE_SERVER_IDENTIFICATION_LINE: in this
-// state, extra lines are read and discarded. Once the server
+// State OBFUSCATION_READ_STATE_IDENTIFICATION_LINES: in this
+// state, extra lines are read and discarded. Once the peer's
 // identification string line is read, it is buffered and returned
 // as per the requested read buffer size.
 //
-// State OBFUSCATION_READ_STATE_SERVER_KEX_PACKETS: reads, deobfuscates,
+// State OBFUSCATION_READ_STATE_KEX_PACKETS: reads, deobfuscates,
 // and buffers full SSH packets, checking for SSH_MSG_NEWKEYS. Packet
 // data is returned as per the requested read buffer size.
 //
@@ -159,26 +211,14 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (n int, err error
 	nextState := conn.readState
 
 	switch conn.readState {
-	case OBFUSCATION_READ_STATE_SERVER_IDENTIFICATION_LINE:
+	case OBFUSCATION_READ_STATE_IDENTIFICATION_LINES:
+		// TODO: only client should accept multiple lines?
 		if len(conn.readBuffer) == 0 {
 			for {
-				// TODO: use bufio.BufferedReader? less redundant string searching?
-				var oneByte [1]byte
-				var validLine = false
-				for len(conn.readBuffer) < SSH_MAX_SERVER_LINE_LENGTH {
-					_, err := io.ReadFull(conn.Conn, oneByte[:])
-					if err != nil {
-						return 0, ContextError(err)
-					}
-					conn.obfuscator.ObfuscateServerToClient(oneByte[:])
-					conn.readBuffer = append(conn.readBuffer, oneByte[0])
-					if bytes.HasSuffix(conn.readBuffer, []byte("\r\n")) {
-						validLine = true
-						break
-					}
-				}
-				if !validLine {
-					return 0, ContextError(errors.New("ObfuscatedSshConn: invalid server line"))
+				conn.readBuffer, err = readSshIdentificationLine(
+					conn.Conn, conn.readDeobfuscate)
+				if err != nil {
+					return 0, ContextError(err)
 				}
 				if bytes.HasPrefix(conn.readBuffer, []byte("SSH-")) {
 					break
@@ -187,32 +227,19 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (n int, err error
 				conn.readBuffer = nil
 			}
 		}
-		nextState = OBFUSCATION_READ_STATE_SERVER_KEX_PACKETS
+		nextState = OBFUSCATION_READ_STATE_KEX_PACKETS
 
-	case OBFUSCATION_READ_STATE_SERVER_KEX_PACKETS:
+	case OBFUSCATION_READ_STATE_KEX_PACKETS:
 		if len(conn.readBuffer) == 0 {
-			prefix := make([]byte, SSH_PACKET_PREFIX_LENGTH)
-			_, err := io.ReadFull(conn.Conn, prefix)
+			var isMsgNewKeys bool
+			conn.readBuffer, isMsgNewKeys, err = readSshPacket(
+				conn.Conn, conn.readDeobfuscate)
 			if err != nil {
 				return 0, ContextError(err)
 			}
-			conn.obfuscator.ObfuscateServerToClient(prefix)
-			packetLength, _, payloadLength, messageLength := getSshPacketPrefix(prefix)
-			if packetLength > SSH_MAX_PACKET_LENGTH {
-				return 0, ContextError(errors.New("ObfuscatedSshConn: ssh packet length too large"))
-			}
-			conn.readBuffer = make([]byte, messageLength)
-			copy(conn.readBuffer, prefix)
-			_, err = io.ReadFull(conn.Conn, conn.readBuffer[len(prefix):])
-			if err != nil {
-				return 0, ContextError(err)
-			}
-			conn.obfuscator.ObfuscateServerToClient(conn.readBuffer[len(prefix):])
-			if payloadLength > 0 {
-				packetType := int(conn.readBuffer[SSH_PACKET_PREFIX_LENGTH])
-				if packetType == SSH_MSG_NEWKEYS {
-					nextState = OBFUSCATION_READ_STATE_FLUSH
-				}
+
+			if isMsgNewKeys {
+				nextState = OBFUSCATION_READ_STATE_FLUSH
 			}
 		}
 
@@ -220,7 +247,7 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (n int, err error
 		nextState = OBFUSCATION_READ_STATE_FINISHED
 
 	case OBFUSCATION_READ_STATE_FINISHED:
-		panic("ObfuscatedSshConn: invalid read state")
+		return 0, ContextError(errors.New("invalid read state"))
 	}
 
 	n = copy(buffer, conn.readBuffer)
@@ -232,22 +259,32 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (n int, err error
 	return n, nil
 }
 
-// transformAndWrite transforms the client->server bytes stream while in an
+// transformAndWrite transforms the upstream bytes stream while in an
 // obfucation state, buffers bytes as necessary for parsing, and writes
 // transformed bytes to the network connection. Bytes are obfuscated until
-// the first client SSH_MSG_NEWKEYS packet is sent.
+// after the first SSH_MSG_NEWKEYS packet is sent.
 //
-// State OBFUSCATION_WRITE_STATE_SEND_CLIENT_SEED_MESSAGE: the initial state,
-// when the client has not sent any data. In this state, the seed message is
-// injected into the client output stream.
+// There are two mode-specific states:
 //
-// State OBFUSCATION_WRITE_STATE_CLIENT_IDENTIFICATION_LINE: before packets are
-// sent, the client sends an identification line terminated by CRLF:
+// State OBFUSCATION_WRITE_STATE_CLIENT_SEND_SEED_MESSAGE: the initial
+// state, when the client has not sent any data. In this state, the seed message
+// is injected into the client output stream.
+//
+// State OBFUSCATION_WRITE_STATE_SERVER_SEND_IDENTIFICATION_LINE_PADDING: the
+// initial state, when the server has not sent any data. In this state, the
+// additional lines of padding are injected into the server output stream.
+// This padding is a partial defense against traffic analysis against the
+// otherwise-fixed size server version line. This makes use of the
+// "other lines of data" allowance, before the version line, which clients
+// will ignore (http://tools.ietf.org/html/rfc4253#section-4.2).
+//
+// State OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE: before
+// packets are sent, the ssh peer sends an identification line terminated by CRLF:
 // http://www.ietf.org/rfc/rfc4253.txt sec 4.2.
 // In this state, the CRLF terminator is used to parse message boundaries.
 //
-// State OBFUSCATION_WRITE_STATE_CLIENT_KEX_PACKETS: follows the binary packet
-// protocol, parsing each packet until the first SSH_MSG_NEWKEYS.
+// State OBFUSCATION_WRITE_STATE_KEX_PACKETS: follows the binary
+// packet protocol, parsing each packet until the first SSH_MSG_NEWKEYS.
 // http://www.ietf.org/rfc/rfc4253.txt sec 6:
 //     uint32    packet_length
 //     byte      padding_length
@@ -264,69 +301,54 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (n int, err error
 // these packets is authenticated in the "exchange hash").
 func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) (err error) {
 
-	if conn.writeState == OBFUSCATION_WRITE_STATE_SEND_CLIENT_SEED_MESSAGE {
-		_, err = conn.Conn.Write(conn.obfuscator.ConsumeSeedMessage())
+	// The seed message (client) and identification line padding (server)
+	// are injected before any standard SSH traffic.
+	if conn.writeState == OBFUSCATION_WRITE_STATE_CLIENT_SEND_SEED_MESSAGE {
+		_, err = conn.Conn.Write(conn.obfuscator.SendSeedMessage())
 		if err != nil {
 			return ContextError(err)
 		}
-		conn.writeState = OBFUSCATION_WRITE_STATE_CLIENT_IDENTIFICATION_LINE
+		conn.writeState = OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE
+	} else if conn.writeState == OBFUSCATION_WRITE_STATE_SERVER_SEND_IDENTIFICATION_LINE_PADDING {
+		padding, err := makeServerIdentificationLinePadding()
+		if err != nil {
+			return ContextError(err)
+		}
+		conn.writeObfuscate(padding)
+		_, err = conn.Conn.Write(padding)
+		if err != nil {
+			return ContextError(err)
+		}
+		conn.writeState = OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE
 	}
 
 	conn.writeBuffer = append(conn.writeBuffer, buffer...)
-	var messageBuffer []byte
+	var sendBuffer []byte
 
 	switch conn.writeState {
-	case OBFUSCATION_WRITE_STATE_CLIENT_IDENTIFICATION_LINE:
-		index := bytes.Index(conn.writeBuffer, []byte("\r\n"))
-		if index != -1 {
-			messageLength := index + 2 // + 2 for \r\n
-			messageBuffer = append([]byte(nil), conn.writeBuffer[:messageLength]...)
-			conn.writeBuffer = conn.writeBuffer[messageLength:]
-			conn.writeState = OBFUSCATION_WRITE_STATE_CLIENT_KEX_PACKETS
+	case OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE:
+		conn.writeBuffer, sendBuffer = extractSshIdentificationLine(conn.writeBuffer)
+		if sendBuffer != nil {
+			conn.writeState = OBFUSCATION_WRITE_STATE_KEX_PACKETS
 		}
 
-	case OBFUSCATION_WRITE_STATE_CLIENT_KEX_PACKETS:
-		for len(conn.writeBuffer) >= SSH_PACKET_PREFIX_LENGTH {
-			packetLength, paddingLength, payloadLength, messageLength := getSshPacketPrefix(conn.writeBuffer)
-			if len(conn.writeBuffer) < messageLength {
-				// We don't have the complete packet yet
-				break
-			}
-			messageBuffer = append([]byte(nil), conn.writeBuffer[:messageLength]...)
-			conn.writeBuffer = conn.writeBuffer[messageLength:]
-			if payloadLength > 0 {
-				packetType := int(messageBuffer[SSH_PACKET_PREFIX_LENGTH])
-				if packetType == SSH_MSG_NEWKEYS {
-					conn.writeState = OBFUSCATION_WRITE_STATE_FINISHED
-				}
-			}
-			// Padding transformation
-			// See RFC 4253 sec. 6 for constraints
-			possiblePaddings := (SSH_MAX_PADDING_LENGTH - paddingLength) / SSH_PADDING_MULTIPLE
-			if possiblePaddings > 0 {
-				// selectedPadding is integer in range [0, possiblePaddings)
-				selectedPadding, err := MakeSecureRandomInt(possiblePaddings)
-				if err != nil {
-					return ContextError(err)
-				}
-				extraPaddingLength := selectedPadding * SSH_PADDING_MULTIPLE
-				extraPadding, err := MakeSecureRandomBytes(extraPaddingLength)
-				if err != nil {
-					return ContextError(err)
-				}
-				setSshPacketPrefix(
-					messageBuffer, packetLength+extraPaddingLength, paddingLength+extraPaddingLength)
-				messageBuffer = append(messageBuffer, extraPadding...)
-			}
+	case OBFUSCATION_WRITE_STATE_KEX_PACKETS:
+		var hasMsgNewKeys bool
+		conn.writeBuffer, sendBuffer, hasMsgNewKeys, err = extractSshPackets(conn.writeBuffer)
+		if err != nil {
+			return ContextError(err)
+		}
+		if hasMsgNewKeys {
+			conn.writeState = OBFUSCATION_WRITE_STATE_FINISHED
 		}
 
 	case OBFUSCATION_WRITE_STATE_FINISHED:
-		panic("ObfuscatedSshConn: invalid write state")
+		return ContextError(errors.New("invalid write state"))
 	}
 
-	if messageBuffer != nil {
-		conn.obfuscator.ObfuscateClientToServer(messageBuffer)
-		_, err := conn.Conn.Write(messageBuffer)
+	if sendBuffer != nil {
+		conn.writeObfuscate(sendBuffer)
+		_, err := conn.Conn.Write(sendBuffer)
 		if err != nil {
 			return ContextError(err)
 		}
@@ -344,6 +366,148 @@ func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) (err error) {
 	return nil
 }
 
+func readSshIdentificationLine(
+	conn net.Conn, deobfuscate func([]byte)) ([]byte, error) {
+
+	// TODO: use bufio.BufferedReader? less redundant string searching?
+	var oneByte [1]byte
+	var validLine = false
+	readBuffer := make([]byte, 0)
+	for len(readBuffer) < SSH_MAX_SERVER_LINE_LENGTH {
+		_, err := io.ReadFull(conn, oneByte[:])
+		if err != nil {
+			return nil, ContextError(err)
+		}
+		deobfuscate(oneByte[:])
+		readBuffer = append(readBuffer, oneByte[0])
+		if bytes.HasSuffix(readBuffer, []byte("\r\n")) {
+			validLine = true
+			break
+		}
+	}
+	if !validLine {
+		return nil, ContextError(errors.New("invalid identification line"))
+	}
+	return readBuffer, nil
+}
+
+func readSshPacket(
+	conn net.Conn, deobfuscate func([]byte)) ([]byte, bool, error) {
+
+	prefix := make([]byte, SSH_PACKET_PREFIX_LENGTH)
+	_, err := io.ReadFull(conn, prefix)
+	if err != nil {
+		return nil, false, ContextError(err)
+	}
+	deobfuscate(prefix)
+	packetLength, _, payloadLength, messageLength := getSshPacketPrefix(prefix)
+	if packetLength > SSH_MAX_PACKET_LENGTH {
+		return nil, false, ContextError(errors.New("ssh packet length too large"))
+	}
+	readBuffer := make([]byte, messageLength)
+	copy(readBuffer, prefix)
+	_, err = io.ReadFull(conn, readBuffer[len(prefix):])
+	if err != nil {
+		return nil, false, ContextError(err)
+	}
+	deobfuscate(readBuffer[len(prefix):])
+	isMsgNewKeys := false
+	if payloadLength > 0 {
+		packetType := int(readBuffer[SSH_PACKET_PREFIX_LENGTH])
+		if packetType == SSH_MSG_NEWKEYS {
+			isMsgNewKeys = true
+		}
+	}
+	return readBuffer, isMsgNewKeys, nil
+}
+
+// From the original patch to sshd.c:
+// https://bitbucket.org/psiphon/psiphon-circumvention-system/commits/f40865ce624b680be840dc2432283c8137bd896d
+func makeServerIdentificationLinePadding() ([]byte, error) {
+	paddingLength, err := MakeSecureRandomInt(OBFUSCATE_MAX_PADDING - 2) // 2 = CRLF
+	if err != nil {
+		return nil, ContextError(err)
+	}
+	paddingLength += 2
+	padding := make([]byte, paddingLength)
+
+	// For backwards compatibility with some clients, send no more than 512 characters
+	// per line (including CRLF). To keep the padding distribution between 0 and OBFUSCATE_MAX_PADDING
+	// characters, we send lines that add up to padding_length characters including all CRLFs.
+
+	minLineLength := 2
+	maxLineLength := 512
+	lineStartIndex := 0
+	for paddingLength > 0 {
+		lineLength := paddingLength
+		if lineLength > maxLineLength {
+			lineLength = maxLineLength
+		}
+		// Leave enough padding allowance to send a full CRLF on the last line
+		if paddingLength-lineLength > 0 &&
+			paddingLength-lineLength < minLineLength {
+			lineLength -= minLineLength - (paddingLength - lineLength)
+		}
+		padding[lineStartIndex+lineLength-2] = '\r'
+		padding[lineStartIndex+lineLength-1] = '\n'
+		lineStartIndex += lineLength
+		paddingLength -= lineLength
+	}
+
+	return padding, nil
+}
+
+func extractSshIdentificationLine(writeBuffer []byte) ([]byte, []byte) {
+	var lineBuffer []byte
+	index := bytes.Index(writeBuffer, []byte("\r\n"))
+	if index != -1 {
+		messageLength := index + 2 // + 2 for \r\n
+		lineBuffer = append([]byte(nil), writeBuffer[:messageLength]...)
+		writeBuffer = writeBuffer[messageLength:]
+	}
+	return writeBuffer, lineBuffer
+}
+
+func extractSshPackets(writeBuffer []byte) ([]byte, []byte, bool, error) {
+	var packetBuffer, packetsBuffer []byte
+	hasMsgNewKeys := false
+	for len(writeBuffer) >= SSH_PACKET_PREFIX_LENGTH {
+		packetLength, paddingLength, payloadLength, messageLength := getSshPacketPrefix(writeBuffer)
+		if len(writeBuffer) < messageLength {
+			// We don't have the complete packet yet
+			break
+		}
+		packetBuffer = append([]byte(nil), writeBuffer[:messageLength]...)
+		writeBuffer = writeBuffer[messageLength:]
+		if payloadLength > 0 {
+			packetType := int(packetBuffer[SSH_PACKET_PREFIX_LENGTH])
+			if packetType == SSH_MSG_NEWKEYS {
+				hasMsgNewKeys = true
+			}
+		}
+		// Padding transformation
+		// See RFC 4253 sec. 6 for constraints
+		possiblePaddings := (SSH_MAX_PADDING_LENGTH - paddingLength) / SSH_PADDING_MULTIPLE
+		if possiblePaddings > 0 {
+			// selectedPadding is integer in range [0, possiblePaddings)
+			selectedPadding, err := MakeSecureRandomInt(possiblePaddings)
+			if err != nil {
+				return nil, nil, false, ContextError(err)
+			}
+			extraPaddingLength := selectedPadding * SSH_PADDING_MULTIPLE
+			extraPadding, err := MakeSecureRandomBytes(extraPaddingLength)
+			if err != nil {
+				return nil, nil, false, ContextError(err)
+			}
+			setSshPacketPrefix(
+				packetBuffer, packetLength+extraPaddingLength, paddingLength+extraPaddingLength)
+			packetBuffer = append(packetBuffer, extraPadding...)
+		}
+		packetsBuffer = append(packetsBuffer, packetBuffer...)
+	}
+	return writeBuffer, packetsBuffer, hasMsgNewKeys, nil
+}
+
 func getSshPacketPrefix(buffer []byte) (packetLength, paddingLength, payloadLength, messageLength int) {
 	// TODO: handle malformed packet [lengths]
 	packetLength = int(binary.BigEndian.Uint32(buffer[0 : SSH_PACKET_PREFIX_LENGTH-1]))

+ 110 - 19
psiphon/obfuscator.go

@@ -25,6 +25,8 @@ import (
 	"crypto/sha1"
 	"encoding/binary"
 	"errors"
+	"io"
+	"net"
 )
 
 const (
@@ -51,47 +53,57 @@ type ObfuscatorConfig struct {
 	MaxPadding int
 }
 
-// NewObfuscator creates a new Obfuscator, initializes it with
-// a seed message, derives client and server keys, and creates
-// RC4 stream ciphers to obfuscate data.
-func NewObfuscator(config *ObfuscatorConfig) (obfuscator *Obfuscator, err error) {
+// NewClientObfuscator creates a new Obfuscator, staging a seed message to be
+// sent to the server (by the caller) and initializing stream ciphers to
+// obfuscate data.
+func NewClientObfuscator(
+	config *ObfuscatorConfig) (obfuscator *Obfuscator, err error) {
+
 	seed, err := MakeSecureRandomBytes(OBFUSCATE_SEED_LENGTH)
 	if err != nil {
 		return nil, ContextError(err)
 	}
-	clientToServerKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_CLIENT_TO_SERVER_IV))
-	if err != nil {
-		return nil, ContextError(err)
-	}
-	serverToClientKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_SERVER_TO_CLIENT_IV))
-	if err != nil {
-		return nil, ContextError(err)
-	}
-	clientToServerCipher, err := rc4.NewCipher(clientToServerKey)
-	if err != nil {
-		return nil, ContextError(err)
-	}
-	serverToClientCipher, err := rc4.NewCipher(serverToClientKey)
+
+	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(seed, config)
 	if err != nil {
 		return nil, ContextError(err)
 	}
+
 	maxPadding := OBFUSCATE_MAX_PADDING
 	if config.MaxPadding > 0 {
 		maxPadding = config.MaxPadding
 	}
+
 	seedMessage, err := makeSeedMessage(maxPadding, seed, clientToServerCipher)
 	if err != nil {
 		return nil, ContextError(err)
 	}
+
 	return &Obfuscator{
 		seedMessage:          seedMessage,
 		clientToServerCipher: clientToServerCipher,
 		serverToClientCipher: serverToClientCipher}, nil
 }
 
-// ConsumeSeedMessage returns the seed message created in NewObfuscator,
+// NewServerObfuscator creates a new Obfuscator, reading a seed message directly
+// from the clientConn and initializing stream ciphers to obfuscate data.
+func NewServerObfuscator(
+	clientConn net.Conn, config *ObfuscatorConfig) (obfuscator *Obfuscator, err error) {
+
+	clientToServerCipher, serverToClientCipher, err := readSeedMessage(
+		clientConn, config)
+	if err != nil {
+		return nil, ContextError(err)
+	}
+
+	return &Obfuscator{
+		clientToServerCipher: clientToServerCipher,
+		serverToClientCipher: serverToClientCipher}, nil
+}
+
+// SendSeedMessage returns the seed message created in NewObfuscatorClient,
 // removing the reference so that it may be garbage collected.
-func (obfuscator *Obfuscator) ConsumeSeedMessage() []byte {
+func (obfuscator *Obfuscator) SendSeedMessage() []byte {
 	seedMessage := obfuscator.seedMessage
 	obfuscator.seedMessage = nil
 	return seedMessage
@@ -107,6 +119,32 @@ func (obfuscator *Obfuscator) ObfuscateServerToClient(buffer []byte) {
 	obfuscator.serverToClientCipher.XORKeyStream(buffer, buffer)
 }
 
+func initObfuscatorCiphers(
+	seed []byte, config *ObfuscatorConfig) (*rc4.Cipher, *rc4.Cipher, error) {
+
+	clientToServerKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_CLIENT_TO_SERVER_IV))
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	serverToClientKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_SERVER_TO_CLIENT_IV))
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	clientToServerCipher, err := rc4.NewCipher(clientToServerKey)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	serverToClientCipher, err := rc4.NewCipher(serverToClientKey)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	return clientToServerCipher, serverToClientCipher, nil
+}
+
 func deriveKey(seed, keyword, iv []byte) ([]byte, error) {
 	h := sha1.New()
 	h.Write(seed)
@@ -155,3 +193,56 @@ func makeSeedMessage(maxPadding int, seed []byte, clientToServerCipher *rc4.Ciph
 	clientToServerCipher.XORKeyStream(seedMessage[len(seed):], seedMessage[len(seed):])
 	return seedMessage, nil
 }
+
+func readSeedMessage(
+	clientConn net.Conn, config *ObfuscatorConfig) (*rc4.Cipher, *rc4.Cipher, error) {
+
+	seed := make([]byte, OBFUSCATE_SEED_LENGTH)
+	_, err := io.ReadFull(clientConn, seed)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(seed, config)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	fixedLengthFields := make([]byte, 8) // 4 bytes each for magic value and padding length
+	_, err = io.ReadFull(clientConn, fixedLengthFields)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	clientToServerCipher.XORKeyStream(fixedLengthFields, fixedLengthFields)
+
+	buffer := bytes.NewReader(fixedLengthFields)
+
+	var magicValue, paddingLength int32
+	err = binary.Read(buffer, binary.BigEndian, &magicValue)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+	err = binary.Read(buffer, binary.BigEndian, &paddingLength)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	if magicValue != OBFUSCATE_MAGIC_VALUE {
+		return nil, nil, ContextError(errors.New("invalid magic value"))
+	}
+
+	if paddingLength < 0 || paddingLength > OBFUSCATE_MAX_PADDING {
+		return nil, nil, ContextError(errors.New("invalid padding length"))
+	}
+
+	padding := make([]byte, paddingLength)
+	_, err = io.ReadFull(clientConn, padding)
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+
+	clientToServerCipher.XORKeyStream(padding, padding)
+
+	return clientToServerCipher, serverToClientCipher, nil
+}

+ 328 - 0
psiphon/server/config.go

@@ -0,0 +1,328 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"strings"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"golang.org/x/crypto/ssh"
+)
+
+const (
+	SERVER_CONFIG_FILENAME                 = "psiphon-server.config"
+	SERVER_ENTRY_FILENAME                  = "serverEntry.dat"
+	DEFAULT_LOG_LEVEL                      = "Info"
+	DEFAULT_GEO_IP_DATABASE_FILENAME       = "GeoLite2-City.mmdb"
+	DEFAULT_SERVER_IP_ADDRESS              = "127.0.0.1"
+	WEB_SERVER_SECRET_BYTE_LENGTH          = 32
+	WEB_SERVER_CERTIFICATE_RSA_KEY_BITS    = 2048
+	WEB_SERVER_CERTIFICATE_VALIDITY_PERIOD = 10 * 365 * 24 * time.Hour // approx. 10 years
+	DEFAULT_WEB_SERVER_PORT                = 8000
+	WEB_SERVER_READ_TIMEOUT                = 10 * time.Second
+	WEB_SERVER_WRITE_TIMEOUT               = 10 * time.Second
+	SSH_USERNAME_SUFFIX_BYTE_LENGTH        = 8
+	SSH_PASSWORD_BYTE_LENGTH               = 32
+	SSH_RSA_HOST_KEY_BITS                  = 2048
+	DEFAULT_SSH_SERVER_PORT                = 2222
+	SSH_HANDSHAKE_TIMEOUT                  = 30 * time.Second
+	SSH_CONNECTION_READ_DEADLINE           = 5 * time.Minute
+	SSH_OBFUSCATED_KEY_BYTE_LENGTH         = 32
+	DEFAULT_OBFUSCATED_SSH_SERVER_PORT     = 3333
+	REDIS_POOL_MAX_IDLE                    = 5
+	REDIS_POOL_MAX_ACTIVE                  = 1000
+	REDIS_POOL_IDLE_TIMEOUT                = 5 * time.Minute
+)
+
+// TODO: break config into sections (sub-structs)
+
+type Config struct {
+	LogLevel                string
+	SyslogAddress           string
+	SyslogFacility          string
+	SyslogTag               string
+	DiscoveryValueHMACKey   string
+	GeoIPDatabaseFilename   string
+	ServerIPAddress         string
+	WebServerPort           int
+	WebServerSecret         string
+	WebServerCertificate    string
+	WebServerPrivateKey     string
+	SSHPrivateKey           string
+	SSHServerVersion        string
+	SSHUserName             string
+	SSHPassword             string
+	SSHServerPort           int
+	ObfuscatedSSHKey        string
+	ObfuscatedSSHServerPort int
+	RedisServerAddress      string
+}
+
+func (config *Config) RunWebServer() bool {
+	return config.WebServerPort > 0
+}
+
+func (config *Config) RunSSHServer() bool {
+	return config.SSHServerPort > 0
+}
+
+func (config *Config) RunObfuscatedSSHServer() bool {
+	return config.ObfuscatedSSHServerPort > 0
+}
+
+func (config *Config) UseRedis() bool {
+	return config.RedisServerAddress != ""
+}
+
+func LoadConfig(configJson []byte) (*Config, error) {
+
+	var config Config
+	err := json.Unmarshal(configJson, &config)
+	if err != nil {
+		return nil, psiphon.ContextError(err)
+	}
+
+	// TODO: config field validation
+	// TODO: validation case: OSSH requires extra fields
+
+	return &config, nil
+}
+
+type GenerateConfigParams struct {
+	ServerIPAddress         string
+	WebServerPort           int
+	SSHServerPort           int
+	ObfuscatedSSHServerPort int
+}
+
+func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, error) {
+
+	// TODO: support disabling web server or a subset of protocols
+
+	serverIPaddress := params.ServerIPAddress
+	if serverIPaddress == "" {
+		serverIPaddress = DEFAULT_SERVER_IP_ADDRESS
+	}
+
+	// Web server config
+
+	webServerPort := params.WebServerPort
+	if webServerPort == 0 {
+		webServerPort = DEFAULT_WEB_SERVER_PORT
+	}
+
+	webServerSecret, err := psiphon.MakeRandomString(WEB_SERVER_SECRET_BYTE_LENGTH)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	webServerCertificate, webServerPrivateKey, err := generateWebServerCertificate()
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	// SSH config
+
+	sshServerPort := params.SSHServerPort
+	if sshServerPort == 0 {
+		sshServerPort = DEFAULT_SSH_SERVER_PORT
+	}
+
+	// TODO: use other key types: anti-fingerprint by varying params
+
+	rsaKey, err := rsa.GenerateKey(rand.Reader, SSH_RSA_HOST_KEY_BITS)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	sshPrivateKey := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "RSA PRIVATE KEY",
+			Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
+		},
+	)
+
+	signer, err := ssh.NewSignerFromKey(rsaKey)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	sshPublicKey := signer.PublicKey()
+
+	sshUserNameSuffix, err := psiphon.MakeRandomString(SSH_USERNAME_SUFFIX_BYTE_LENGTH)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	sshUserName := "psiphon_" + sshUserNameSuffix
+
+	sshPassword, err := psiphon.MakeRandomString(SSH_PASSWORD_BYTE_LENGTH)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	// TODO: vary version string for anti-fingerprint
+	sshServerVersion := "SSH-2.0-Psiphon"
+
+	// Obfuscated SSH config
+
+	obfuscatedSSHServerPort := params.ObfuscatedSSHServerPort
+	if obfuscatedSSHServerPort == 0 {
+		obfuscatedSSHServerPort = DEFAULT_OBFUSCATED_SSH_SERVER_PORT
+	}
+
+	obfuscatedSSHKey, err := psiphon.MakeRandomString(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	// Assemble config and server entry
+
+	config := &Config{
+		LogLevel:                DEFAULT_LOG_LEVEL,
+		SyslogAddress:           "",
+		SyslogFacility:          "",
+		SyslogTag:               "",
+		DiscoveryValueHMACKey:   "",
+		GeoIPDatabaseFilename:   DEFAULT_GEO_IP_DATABASE_FILENAME,
+		ServerIPAddress:         serverIPaddress,
+		WebServerPort:           webServerPort,
+		WebServerSecret:         webServerSecret,
+		WebServerCertificate:    webServerCertificate,
+		WebServerPrivateKey:     webServerPrivateKey,
+		SSHPrivateKey:           string(sshPrivateKey),
+		SSHServerVersion:        sshServerVersion,
+		SSHUserName:             sshUserName,
+		SSHPassword:             sshPassword,
+		SSHServerPort:           sshServerPort,
+		ObfuscatedSSHKey:        obfuscatedSSHKey,
+		ObfuscatedSSHServerPort: obfuscatedSSHServerPort,
+		RedisServerAddress:      "",
+	}
+
+	encodedConfig, err := json.MarshalIndent(config, "\n", "    ")
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	// Server entry format omits the BEGIN/END lines and newlines
+	lines := strings.Split(webServerCertificate, "\n")
+	strippedWebServerCertificate := strings.Join(lines[1:len(lines)-2], "")
+
+	capabilities := []string{
+		psiphon.GetCapability(psiphon.TUNNEL_PROTOCOL_SSH),
+		psiphon.GetCapability(psiphon.TUNNEL_PROTOCOL_OBFUSCATED_SSH),
+	}
+
+	serverEntry := &psiphon.ServerEntry{
+		IpAddress:            serverIPaddress,
+		WebServerPort:        fmt.Sprintf("%d", webServerPort),
+		WebServerSecret:      webServerSecret,
+		WebServerCertificate: strippedWebServerCertificate,
+		SshPort:              sshServerPort,
+		SshUsername:          sshUserName,
+		SshPassword:          sshPassword,
+		SshHostKey:           base64.RawStdEncoding.EncodeToString(sshPublicKey.Marshal()),
+		SshObfuscatedPort:    obfuscatedSSHServerPort,
+		SshObfuscatedKey:     obfuscatedSSHKey,
+		Capabilities:         capabilities,
+		Region:               "US",
+	}
+
+	encodedServerEntry, err := psiphon.EncodeServerEntry(serverEntry)
+	if err != nil {
+		return nil, nil, psiphon.ContextError(err)
+	}
+
+	return encodedConfig, []byte(encodedServerEntry), nil
+}
+
+func generateWebServerCertificate() (string, string, error) {
+
+	// Based on https://golang.org/src/crypto/tls/generate_cert.go
+
+	// TODO: use other key types: anti-fingerprint by varying params
+
+	rsaKey, err := rsa.GenerateKey(rand.Reader, WEB_SERVER_CERTIFICATE_RSA_KEY_BITS)
+	if err != nil {
+		return "", "", psiphon.ContextError(err)
+	}
+
+	notBefore := time.Now()
+	notAfter := notBefore.Add(WEB_SERVER_CERTIFICATE_VALIDITY_PERIOD)
+
+	// TODO: psi_ops_install sets serial number to 0?
+	// TOSO: psi_ops_install sets RSA exponent to 3, digest type to 'sha1', and version to 2?
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return "", "", psiphon.ContextError(err)
+	}
+
+	template := x509.Certificate{
+
+		// TODO: psi_ops_install leaves subject blank?
+		/*
+			Subject: pkix.Name{
+				Organization: []string{""},
+			},
+			IPAddresses: ...
+		*/
+
+		SerialNumber:          serialNumber,
+		NotBefore:             notBefore,
+		NotAfter:              notAfter,
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		IsCA: true,
+	}
+
+	derCert, err := x509.CreateCertificate(rand.Reader, &template, &template, rsaKey.Public(), rsaKey)
+	if err != nil {
+		return "", "", psiphon.ContextError(err)
+	}
+
+	webServerCertificate := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "CERTIFICATE",
+			Bytes: derCert,
+		},
+	)
+
+	webServerPrivateKey := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "RSA PRIVATE KEY",
+			Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
+		},
+	)
+
+	return string(webServerCertificate), string(webServerPrivateKey), nil
+}

+ 119 - 0
psiphon/server/geoip.go

@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"net"
+
+	maxminddb "github.com/Psiphon-Inc/maxminddb-golang"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+const UNKNOWN_GEOIP_VALUE = "None"
+
+type GeoIPData struct {
+	Country        string
+	City           string
+	ISP            string
+	DiscoveryValue int
+}
+
+func NewGeoIPData() GeoIPData {
+	return GeoIPData{
+		Country: UNKNOWN_GEOIP_VALUE,
+		City:    UNKNOWN_GEOIP_VALUE,
+		ISP:     UNKNOWN_GEOIP_VALUE,
+	}
+}
+
+func GeoIPLookup(ipAddress string) GeoIPData {
+
+	result := NewGeoIPData()
+
+	ip := net.ParseIP(ipAddress)
+
+	if ip == nil || geoIPReader == nil {
+		return result
+	}
+
+	var geoIPFields struct {
+		Country struct {
+			ISOCode string `maxminddb:"iso_code"`
+		} `maxminddb:"country"`
+		City struct {
+			Names map[string]string `maxminddb:"names"`
+		} `maxminddb:"city"`
+		ISP string `maxminddb:"isp"`
+	}
+
+	err := geoIPReader.Lookup(ip, &geoIPFields)
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Warning("GeoIP lookup failed")
+	}
+
+	if geoIPFields.Country.ISOCode != "" {
+		result.Country = geoIPFields.Country.ISOCode
+	}
+
+	name, ok := geoIPFields.City.Names["en"]
+	if ok && name != "" {
+		result.City = name
+	}
+
+	if geoIPFields.ISP != "" {
+		result.ISP = geoIPFields.ISP
+	}
+
+	result.DiscoveryValue = calculateDiscoveryValue(ipAddress)
+
+	return result
+}
+
+func calculateDiscoveryValue(ipAddress string) int {
+	// From: psi_ops_discovery.calculate_ip_address_strategy_value:
+	//     # Mix bits from all octets of the client IP address to determine the
+	//     # bucket. An HMAC is used to prevent pre-calculation of buckets for IPs.
+	//     return ord(hmac.new(HMAC_KEY, ip_address, hashlib.sha256).digest()[0])
+	// TODO: use 3-octet algorithm?
+	hash := hmac.New(sha256.New, []byte(discoveryValueHMACKey))
+	hash.Write([]byte(ipAddress))
+	return int(hash.Sum(nil)[0])
+}
+
+var geoIPReader *maxminddb.Reader
+var discoveryValueHMACKey string
+
+func InitGeoIP(config *Config) error {
+
+	discoveryValueHMACKey = config.DiscoveryValueHMACKey
+
+	if config.GeoIPDatabaseFilename != "" {
+		var err error
+		geoIPReader, err = maxminddb.Open(config.GeoIPDatabaseFilename)
+		if err != nil {
+			return psiphon.ContextError(err)
+		}
+		log.WithContext().Info("GeoIP initialized")
+	}
+
+	return nil
+}

+ 168 - 0
psiphon/server/log.go

@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"io"
+	"log/syslog"
+	"os"
+
+	"github.com/Psiphon-Inc/logrus"
+	logrus_syslog "github.com/Psiphon-Inc/logrus/hooks/syslog"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+// ContextLogger adds context logging functionality to the
+// underlying logging packages.
+type ContextLogger struct {
+	*logrus.Logger
+}
+
+// LogFields is an alias for the field struct in the
+// underlying logging package.
+type LogFields logrus.Fields
+
+// WithContext adds a "context" field containing the caller's
+// function name and source file line number. Use this function
+// when the log has no fields.
+func (logger *ContextLogger) WithContext() *logrus.Entry {
+	return logrus.WithFields(
+		logrus.Fields{
+			"context": psiphon.GetParentContext(),
+		})
+}
+
+// WithContextFields adds a "context" field containing the caller's
+// function name and source file line number. Use this function
+// when the log has fields. Note that any existing "context" field
+// will be renamed to "field.context".
+func (logger *ContextLogger) WithContextFields(fields LogFields) *logrus.Entry {
+	_, ok := fields["context"]
+	if ok {
+		fields["fields.context"] = fields["context"]
+	}
+	fields["context"] = psiphon.GetParentContext()
+	return log.WithFields(logrus.Fields(fields))
+}
+
+// NewLogWriter returns an io.PipeWriter that can be used to write
+// to the global logger. Caller must Close() the writer.
+func NewLogWriter() *io.PipeWriter {
+	return log.Writer()
+}
+
+var log *ContextLogger
+
+// InitLogging configures a logger according to the specified
+// config params. If not called, the default logger set by the
+// package init() is used.
+// Concurrenty note: should only be called from the main
+// goroutine.
+func InitLogging(config *Config) error {
+
+	logLevel := DEFAULT_LOG_LEVEL
+	if config.LogLevel != "" {
+		logLevel = config.LogLevel
+	}
+
+	level, err := logrus.ParseLevel(logLevel)
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	hooks := make(logrus.LevelHooks)
+
+	var syslogHook *logrus_syslog.SyslogHook
+
+	if config.SyslogAddress != "" {
+
+		syslogHook, err = logrus_syslog.NewSyslogHook(
+			"udp",
+			config.SyslogAddress,
+			getSyslogPriority(config),
+			config.SyslogTag)
+
+		if err != nil {
+			return psiphon.ContextError(err)
+		}
+
+		hooks.Add(syslogHook)
+	}
+
+	log = &ContextLogger{
+		&logrus.Logger{
+			Out:       os.Stderr,
+			Formatter: new(logrus.TextFormatter),
+			Hooks:     hooks,
+			Level:     level,
+		},
+	}
+
+	return nil
+}
+
+// getSyslogPriority determines golang's syslog "priority" value
+// based on the provided config.
+func getSyslogPriority(config *Config) syslog.Priority {
+
+	// TODO: assumes log.Level filter applies?
+	severity := syslog.LOG_DEBUG
+
+	facilityCodes := map[string]syslog.Priority{
+		"KERN":     syslog.LOG_KERN,
+		"USER":     syslog.LOG_USER,
+		"MAIL":     syslog.LOG_MAIL,
+		"DAEMON":   syslog.LOG_DAEMON,
+		"AUTH":     syslog.LOG_AUTH,
+		"SYSLOG":   syslog.LOG_SYSLOG,
+		"LPR":      syslog.LOG_LPR,
+		"NEWS":     syslog.LOG_NEWS,
+		"UUCP":     syslog.LOG_UUCP,
+		"CRON":     syslog.LOG_CRON,
+		"AUTHPRIV": syslog.LOG_AUTHPRIV,
+		"FTP":      syslog.LOG_FTP,
+		"LOCAL0":   syslog.LOG_LOCAL0,
+		"LOCAL1":   syslog.LOG_LOCAL1,
+		"LOCAL2":   syslog.LOG_LOCAL2,
+		"LOCAL3":   syslog.LOG_LOCAL3,
+		"LOCAL4":   syslog.LOG_LOCAL4,
+		"LOCAL5":   syslog.LOG_LOCAL5,
+		"LOCAL6":   syslog.LOG_LOCAL6,
+		"LOCAL7":   syslog.LOG_LOCAL7,
+	}
+
+	facility, ok := facilityCodes[config.SyslogFacility]
+	if !ok {
+		facility = syslog.LOG_USER
+	}
+
+	return severity | facility
+}
+
+func init() {
+	log = &ContextLogger{
+		&logrus.Logger{
+			Out:       os.Stderr,
+			Formatter: new(logrus.TextFormatter),
+			Hooks:     make(logrus.LevelHooks),
+			Level:     logrus.DebugLevel,
+		},
+	}
+}

+ 105 - 0
psiphon/server/redis.go

@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"encoding/json"
+
+	"github.com/Psiphon-Inc/redigo/redis"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+// UpdateRedisForLegacyPsiWeb sets the Psiphon session and discovery records for
+// a new SSH connection following the conventions of the legacy psi_web component.
+// This facility is used so psi_web can use the GeoIP values the SSH server has
+// resolved for the user connection.
+// The redis database indexes, expiry values, and record schemas all match the
+// legacy psi_web configuration.
+func UpdateRedisForLegacyPsiWeb(psiphonSessionID string, geoIPData GeoIPData) error {
+
+	redisSessionDBIndex := 0
+
+	//  Discard sessions older than 60 minutes
+	sessionExpireSeconds := 60 * 60
+
+	sessionRecord, err := json.Marshal(
+		struct {
+			Country string `json:"region"`
+			City    string `json:"city"`
+			ISP     string `json:"isp"`
+		}{geoIPData.Country, geoIPData.City, geoIPData.ISP})
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	redisDiscoveryDBIndex := 1
+
+	// Discard discovery records older than 5 minutes
+	discoveryExpireSeconds := 60 * 5
+
+	discoveryRecord, err := json.Marshal(
+		struct {
+			DiscoveryValue int `json:"client_ip_address_strategy_value"`
+		}{geoIPData.DiscoveryValue})
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	conn := redisPool.Get()
+
+	// Note: using SET with NX (set if not exists) so as to not clobber
+	// any existing records set by an upstream connection server (i.e.,
+	// meek server). We allow expiry deadline extension unconditionally.
+
+	conn.Send("MULTI")
+	conn.Send("SELECT", redisSessionDBIndex)
+	conn.Send("SET", psiphonSessionID, string(sessionRecord), "NX", "EX", sessionExpireSeconds)
+	conn.Send("SELECT", redisDiscoveryDBIndex)
+	conn.Send("SET", psiphonSessionID, string(discoveryRecord), "NX", "EX", discoveryExpireSeconds)
+	_, err = conn.Do("EXEC")
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	return nil
+}
+
+var redisPool *redis.Pool
+
+func InitRedis(config *Config) error {
+	redisPool = &redis.Pool{
+		Dial: func() (redis.Conn, error) {
+			return redis.Dial("tcp", config.RedisServerAddress)
+		},
+		MaxIdle:     REDIS_POOL_MAX_IDLE,
+		MaxActive:   REDIS_POOL_MAX_ACTIVE,
+		Wait:        false,
+		IdleTimeout: REDIS_POOL_IDLE_TIMEOUT,
+	}
+
+	// Exercise a connection to the configured redis server so
+	// that Init fails if the configuration is incorrect or the
+	// server is not responding.
+	conn := redisPool.Get()
+	_, err := conn.Do("PING")
+	conn.Close()
+
+	return err
+}

+ 115 - 0
psiphon/server/services.go

@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"os"
+	"os/signal"
+	"sync"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+func RunServices(encodedConfig []byte) error {
+
+	config, err := LoadConfig(encodedConfig)
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Error("load config failed")
+		return psiphon.ContextError(err)
+	}
+
+	err = InitLogging(config)
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Error("init logging failed")
+		return psiphon.ContextError(err)
+	}
+
+	err = InitGeoIP(config)
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Error("init GeoIP failed")
+		return psiphon.ContextError(err)
+	}
+
+	if config.UseRedis() {
+		err = InitRedis(config)
+		if err != nil {
+			log.WithContextFields(LogFields{"error": err}).Error("init redis failed")
+			return psiphon.ContextError(err)
+		}
+	}
+
+	waitGroup := new(sync.WaitGroup)
+	shutdownBroadcast := make(chan struct{})
+	errors := make(chan error)
+
+	if config.RunWebServer() {
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			err := RunWebServer(config, shutdownBroadcast)
+			select {
+			case errors <- err:
+			default:
+			}
+		}()
+	}
+
+	if config.RunSSHServer() {
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			err := RunSSHServer(config, shutdownBroadcast)
+			select {
+			case errors <- err:
+			default:
+			}
+		}()
+	}
+
+	if config.RunObfuscatedSSHServer() {
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			err := RunObfuscatedSSHServer(config, shutdownBroadcast)
+			select {
+			case errors <- err:
+			default:
+			}
+		}()
+	}
+
+	// An OS signal triggers an orderly shutdown
+	systemStopSignal := make(chan os.Signal, 1)
+	signal.Notify(systemStopSignal, os.Interrupt, os.Kill)
+
+	err = nil
+
+	select {
+	case <-systemStopSignal:
+		log.WithContext().Info("shutdown by system")
+	case err = <-errors:
+		log.WithContextFields(LogFields{"error": err}).Error("service failed")
+	}
+
+	close(shutdownBroadcast)
+	waitGroup.Wait()
+
+	return err
+}

+ 486 - 0
psiphon/server/sshService.go

@@ -0,0 +1,486 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"crypto/subtle"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"golang.org/x/crypto/ssh"
+)
+
+func RunSSHServer(config *Config, shutdownBroadcast <-chan struct{}) error {
+	return runSSHServer(config, false, shutdownBroadcast)
+}
+
+func RunObfuscatedSSHServer(config *Config, shutdownBroadcast <-chan struct{}) error {
+	return runSSHServer(config, true, shutdownBroadcast)
+}
+
+func runSSHServer(
+	config *Config, useObfuscation bool, shutdownBroadcast <-chan struct{}) error {
+
+	privateKey, err := ssh.ParseRawPrivateKey([]byte(config.SSHPrivateKey))
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	// TODO: use cert (ssh.NewCertSigner) for anti-fingerprint?
+	signer, err := ssh.NewSignerFromKey(privateKey)
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	sshServer := &sshServer{
+		config:            config,
+		useObfuscation:    useObfuscation,
+		shutdownBroadcast: shutdownBroadcast,
+		sshHostKey:        signer,
+		nextClientID:      1,
+		clients:           make(map[sshClientID]*sshClient),
+	}
+
+	var serverPort int
+	if useObfuscation {
+		serverPort = config.ObfuscatedSSHServerPort
+	} else {
+		serverPort = config.SSHServerPort
+	}
+
+	listener, err := net.Listen(
+		"tcp", fmt.Sprintf("%s:%d", config.ServerIPAddress, serverPort))
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	log.WithContextFields(
+		LogFields{
+			"useObfuscation": useObfuscation,
+			"port":           serverPort,
+		}).Info("starting")
+
+	err = nil
+	errors := make(chan error)
+	waitGroup := new(sync.WaitGroup)
+
+	waitGroup.Add(1)
+	go func() {
+		defer waitGroup.Done()
+
+	loop:
+		for {
+			conn, err := listener.Accept()
+
+			select {
+			case <-shutdownBroadcast:
+				if err == nil {
+					conn.Close()
+				}
+				break loop
+			default:
+			}
+
+			if err != nil {
+				if e, ok := err.(net.Error); ok && e.Temporary() {
+					log.WithContextFields(LogFields{"error": err}).Error("accept failed")
+					// Temporary error, keep running
+					continue
+				}
+
+				select {
+				case errors <- psiphon.ContextError(err):
+				default:
+				}
+
+				break loop
+			}
+
+			// process each client connection concurrently
+			go sshServer.handleClient(conn.(*net.TCPConn))
+		}
+
+		sshServer.stopClients()
+
+		log.WithContextFields(
+			LogFields{"useObfuscation": useObfuscation}).Info("stopped")
+	}()
+
+	select {
+	case <-shutdownBroadcast:
+	case err = <-errors:
+	}
+
+	listener.Close()
+
+	waitGroup.Wait()
+
+	log.WithContextFields(
+		LogFields{"useObfuscation": useObfuscation}).Info("exiting")
+
+	return err
+}
+
+type sshClientID uint64
+
+type sshServer struct {
+	config            *Config
+	useObfuscation    bool
+	shutdownBroadcast <-chan struct{}
+	sshHostKey        ssh.Signer
+	nextClientID      sshClientID
+	clientsMutex      sync.Mutex
+	stoppingClients   bool
+	clients           map[sshClientID]*sshClient
+}
+
+func (sshServer *sshServer) registerClient(client *sshClient) (sshClientID, bool) {
+
+	sshServer.clientsMutex.Lock()
+	defer sshServer.clientsMutex.Unlock()
+
+	if sshServer.stoppingClients {
+		return 0, false
+	}
+
+	clientID := sshServer.nextClientID
+	sshServer.nextClientID += 1
+
+	sshServer.clients[clientID] = client
+
+	return clientID, true
+}
+
+func (sshServer *sshServer) unregisterClient(clientID sshClientID) {
+
+	sshServer.clientsMutex.Lock()
+	client := sshServer.clients[clientID]
+	delete(sshServer.clients, clientID)
+	sshServer.clientsMutex.Unlock()
+
+	if client != nil {
+		sshServer.stopClient(client)
+	}
+}
+
+func (sshServer *sshServer) stopClient(client *sshClient) {
+
+	client.sshConn.Close()
+	client.sshConn.Wait()
+
+	client.Lock()
+	log.WithContextFields(
+		LogFields{
+			"startTime":                     client.startTime,
+			"duration":                      time.Now().Sub(client.startTime),
+			"psiphonSessionID":              client.psiphonSessionID,
+			"country":                       client.geoIPData.Country,
+			"city":                          client.geoIPData.City,
+			"ISP":                           client.geoIPData.ISP,
+			"bytesUp":                       client.bytesUp,
+			"bytesDown":                     client.bytesDown,
+			"portForwardCount":              client.portForwardCount,
+			"maxConcurrentPortForwardCount": client.maxConcurrentPortForwardCount,
+		}).Info("tunnel closed")
+	client.Unlock()
+}
+
+func (sshServer *sshServer) stopClients() {
+
+	sshServer.clientsMutex.Lock()
+	sshServer.stoppingClients = true
+	sshServer.clients = make(map[sshClientID]*sshClient)
+	sshServer.clientsMutex.Unlock()
+
+	for _, client := range sshServer.clients {
+		sshServer.stopClient(client)
+	}
+}
+
+func (sshServer *sshServer) handleClient(tcpConn *net.TCPConn) {
+
+	sshClient := &sshClient{
+		sshServer: sshServer,
+		startTime: time.Now(),
+		geoIPData: GeoIPLookup(psiphon.IPAddressFromAddr(tcpConn.RemoteAddr())),
+	}
+
+	// Wrap the base TCP connection in a TimeoutTCPConn which will terminate
+	// the connection if it's idle for too long. This timeout is in effect for
+	// the entire duration of the SSH connection. Clients must actively use
+	// the connection or send SSH keep alive requests to keep the connection
+	// active.
+
+	conn := psiphon.NewTimeoutTCPConn(tcpConn, SSH_CONNECTION_READ_DEADLINE)
+
+	// Run the initial [obfuscated] SSH handshake in a goroutine so we can both
+	// respect shutdownBroadcast and implement a specific handshake timeout.
+	// The timeout is to reclaim network resources in case the handshake takes
+	// too long.
+
+	type sshNewServerConnResult struct {
+		conn     net.Conn
+		sshConn  *ssh.ServerConn
+		channels <-chan ssh.NewChannel
+		requests <-chan *ssh.Request
+		err      error
+	}
+
+	resultChannel := make(chan *sshNewServerConnResult, 2)
+
+	if SSH_HANDSHAKE_TIMEOUT > 0 {
+		time.AfterFunc(time.Duration(SSH_HANDSHAKE_TIMEOUT), func() {
+			resultChannel <- &sshNewServerConnResult{err: errors.New("ssh handshake timeout")}
+		})
+	}
+
+	go func() {
+
+		result := &sshNewServerConnResult{}
+		if sshServer.useObfuscation {
+			result.conn, result.err = psiphon.NewObfuscatedSshConn(
+				psiphon.OBFUSCATION_CONN_MODE_SERVER, conn, sshServer.config.ObfuscatedSSHKey)
+		} else {
+			result.conn = conn
+		}
+		if result.err == nil {
+
+			sshServerConfig := &ssh.ServerConfig{
+				PasswordCallback: sshClient.passwordCallback,
+				AuthLogCallback:  sshClient.authLogCallback,
+				ServerVersion:    sshServer.config.SSHServerVersion,
+			}
+			sshServerConfig.AddHostKey(sshServer.sshHostKey)
+
+			result.sshConn, result.channels, result.requests, result.err =
+				ssh.NewServerConn(result.conn, sshServerConfig)
+		}
+		resultChannel <- result
+	}()
+
+	var result *sshNewServerConnResult
+	select {
+	case result = <-resultChannel:
+	case <-sshServer.shutdownBroadcast:
+		// Close() will interrupt an ongoing handshake
+		// TODO: wait for goroutine to exit before returning?
+		conn.Close()
+		return
+	}
+
+	if result.err != nil {
+		conn.Close()
+		log.WithContextFields(LogFields{"error": result.err}).Warning("handshake failed")
+		return
+	}
+
+	sshClient.Lock()
+	sshClient.sshConn = result.sshConn
+	sshClient.Unlock()
+
+	clientID, ok := sshServer.registerClient(sshClient)
+	if !ok {
+		tcpConn.Close()
+		log.WithContext().Warning("register failed")
+		return
+	}
+	defer sshServer.unregisterClient(clientID)
+
+	go ssh.DiscardRequests(result.requests)
+
+	sshClient.handleChannels(result.channels)
+}
+
+type sshClient struct {
+	sync.Mutex
+	sshServer                     *sshServer
+	sshConn                       ssh.Conn
+	startTime                     time.Time
+	geoIPData                     GeoIPData
+	psiphonSessionID              string
+	bytesUp                       int64
+	bytesDown                     int64
+	portForwardCount              int64
+	concurrentPortForwardCount    int64
+	maxConcurrentPortForwardCount int64
+}
+
+func (sshClient *sshClient) handleChannels(channels <-chan ssh.NewChannel) {
+	for newChannel := range channels {
+
+		if newChannel.ChannelType() != "direct-tcpip" {
+			sshClient.rejectNewChannel(newChannel, ssh.Prohibited, "unknown or unsupported channel type")
+			return
+		}
+
+		// process each port forward concurrently
+		go sshClient.handleNewDirectTcpipChannel(newChannel)
+	}
+}
+
+func (sshClient *sshClient) rejectNewChannel(newChannel ssh.NewChannel, reason ssh.RejectionReason, message string) {
+	// TODO: log more details?
+	log.WithContextFields(
+		LogFields{
+			"channelType":   newChannel.ChannelType(),
+			"rejectMessage": message,
+			"rejectReason":  reason,
+		}).Warning("reject new channel")
+	newChannel.Reject(reason, message)
+}
+
+func (sshClient *sshClient) handleNewDirectTcpipChannel(newChannel ssh.NewChannel) {
+
+	// http://tools.ietf.org/html/rfc4254#section-7.2
+	var directTcpipExtraData struct {
+		HostToConnect       string
+		PortToConnect       uint32
+		OriginatorIPAddress string
+		OriginatorPort      uint32
+	}
+
+	err := ssh.Unmarshal(newChannel.ExtraData(), &directTcpipExtraData)
+	if err != nil {
+		sshClient.rejectNewChannel(newChannel, ssh.Prohibited, "invalid extra data")
+		return
+	}
+
+	targetAddr := fmt.Sprintf("%s:%d",
+		directTcpipExtraData.HostToConnect,
+		directTcpipExtraData.PortToConnect)
+
+	log.WithContextFields(LogFields{"target": targetAddr}).Debug("dialing")
+
+	// TODO: port forward dial timeout
+	// TODO: report ssh.ResourceShortage when appropriate
+	// TODO: IPv6 support
+	fwdConn, err := net.Dial("tcp4", targetAddr)
+	if err != nil {
+		sshClient.rejectNewChannel(newChannel, ssh.ConnectionFailed, err.Error())
+		return
+	}
+	defer fwdConn.Close()
+
+	fwdChannel, requests, err := newChannel.Accept()
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Warning("accept new channel failed")
+		return
+	}
+
+	sshClient.Lock()
+	sshClient.portForwardCount += 1
+	sshClient.concurrentPortForwardCount += 1
+	if sshClient.concurrentPortForwardCount > sshClient.maxConcurrentPortForwardCount {
+		sshClient.maxConcurrentPortForwardCount = sshClient.concurrentPortForwardCount
+	}
+	sshClient.Unlock()
+
+	log.WithContextFields(LogFields{"target": targetAddr}).Debug("relaying")
+
+	go ssh.DiscardRequests(requests)
+
+	defer fwdChannel.Close()
+
+	// relay channel to forwarded connection
+
+	// TODO: use a low-memory io.Copy?
+	// TODO: relay errors to fwdChannel.Stderr()?
+
+	var bytesUp, bytesDown int64
+
+	relayWaitGroup := new(sync.WaitGroup)
+	relayWaitGroup.Add(1)
+	go func() {
+		defer relayWaitGroup.Done()
+		var err error
+		bytesUp, err = io.Copy(fwdConn, fwdChannel)
+		if err != nil {
+			log.WithContextFields(LogFields{"error": err}).Warning("upstream relay failed")
+		}
+	}()
+	bytesDown, err = io.Copy(fwdChannel, fwdConn)
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err}).Warning("downstream relay failed")
+	}
+	fwdChannel.CloseWrite()
+	relayWaitGroup.Wait()
+
+	sshClient.Lock()
+	sshClient.concurrentPortForwardCount -= 1
+	sshClient.bytesUp += bytesUp
+	sshClient.bytesDown += bytesDown
+	sshClient.Unlock()
+
+	log.WithContextFields(LogFields{"target": targetAddr}).Debug("exiting")
+}
+
+func (sshClient *sshClient) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
+	var sshPasswordPayload struct {
+		SessionId   string `json:"SessionId"`
+		SshPassword string `json:"SshPassword"`
+	}
+	err := json.Unmarshal(password, &sshPasswordPayload)
+	if err != nil {
+		return nil, psiphon.ContextError(fmt.Errorf("invalid password payload for %q", conn.User()))
+	}
+
+	userOk := (subtle.ConstantTimeCompare(
+		[]byte(conn.User()), []byte(sshClient.sshServer.config.SSHUserName)) == 1)
+
+	passwordOk := (subtle.ConstantTimeCompare(
+		[]byte(sshPasswordPayload.SshPassword), []byte(sshClient.sshServer.config.SSHPassword)) == 1)
+
+	if !userOk || !passwordOk {
+		return nil, psiphon.ContextError(fmt.Errorf("invalid password for %q", conn.User()))
+	}
+
+	psiphonSessionID := sshPasswordPayload.SessionId
+
+	sshClient.Lock()
+	sshClient.psiphonSessionID = psiphonSessionID
+	geoIPData := sshClient.geoIPData
+	sshClient.Unlock()
+
+	if sshClient.sshServer.config.UseRedis() {
+		err = UpdateRedisForLegacyPsiWeb(psiphonSessionID, geoIPData)
+		if err != nil {
+			log.WithContextFields(LogFields{
+				"psiphonSessionID": psiphonSessionID,
+				"error":            err}).Warning("UpdateRedisForLegacyPsiWeb failed")
+			// Allow the connection to proceed; legacy psi_web will not get accurate GeoIP values.
+		}
+	}
+
+	return nil, nil
+}
+
+func (sshClient *sshClient) authLogCallback(conn ssh.ConnMetadata, method string, err error) {
+	if err != nil {
+		log.WithContextFields(LogFields{"error": err, "method": method}).Warning("authentication failed")
+	} else {
+		log.WithContextFields(LogFields{"error": err, "method": method}).Info("authentication success")
+	}
+}

+ 241 - 0
psiphon/server/webService.go

@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 2016, 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 server
+
+import (
+	"crypto/subtle"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	golanglog "log"
+	"net"
+	"net/http"
+	"sync"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+type webServer struct {
+	serveMux *http.ServeMux
+	config   *Config
+}
+
+func RunWebServer(config *Config, shutdownBroadcast <-chan struct{}) error {
+
+	webServer := &webServer{
+		config: config,
+	}
+
+	serveMux := http.NewServeMux()
+	serveMux.HandleFunc("/handshake", webServer.handshakeHandler)
+	serveMux.HandleFunc("/connected", webServer.connectedHandler)
+	serveMux.HandleFunc("/status", webServer.statusHandler)
+
+	certificate, err := tls.X509KeyPair(
+		[]byte(config.WebServerCertificate),
+		[]byte(config.WebServerPrivateKey))
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	tlsConfig := &tls.Config{
+		Certificates: []tls.Certificate{certificate},
+	}
+
+	// TODO: inherits global log config?
+	logWriter := NewLogWriter()
+	defer logWriter.Close()
+
+	server := &psiphon.HTTPSServer{
+		http.Server{
+			Handler:      serveMux,
+			TLSConfig:    tlsConfig,
+			ReadTimeout:  WEB_SERVER_READ_TIMEOUT,
+			WriteTimeout: WEB_SERVER_WRITE_TIMEOUT,
+			ErrorLog:     golanglog.New(logWriter, "", 0),
+		},
+	}
+
+	listener, err := net.Listen(
+		"tcp", fmt.Sprintf("%s:%d", config.ServerIPAddress, config.WebServerPort))
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	log.WithContext().Info("starting")
+
+	err = nil
+	errors := make(chan error)
+	waitGroup := new(sync.WaitGroup)
+
+	waitGroup.Add(1)
+	go func() {
+		defer waitGroup.Done()
+
+		// Note: will be interrupted by listener.Close()
+		err := server.ServeTLS(listener)
+
+		// Can't check for the exact error that Close() will cause in Accept(),
+		// (see: https://code.google.com/p/go/issues/detail?id=4373). So using an
+		// explicit stop signal to stop gracefully.
+		select {
+		case <-shutdownBroadcast:
+		default:
+			if err != nil {
+				select {
+				case errors <- psiphon.ContextError(err):
+				default:
+				}
+			}
+		}
+
+		log.WithContext().Info("stopped")
+	}()
+
+	select {
+	case <-shutdownBroadcast:
+	case err = <-errors:
+	}
+
+	listener.Close()
+
+	waitGroup.Wait()
+
+	log.WithContext().Info("exiting")
+
+	return err
+}
+
+func (webServer *webServer) checkWebServerSecret(r *http.Request) bool {
+	return subtle.ConstantTimeCompare(
+		[]byte(r.URL.Query().Get("server_secret")),
+		[]byte(webServer.config.WebServerSecret)) == 1
+}
+
+func (webServer *webServer) handshakeHandler(w http.ResponseWriter, r *http.Request) {
+
+	if !webServer.checkWebServerSecret(r) {
+		// TODO: log more details?
+		log.WithContext().Warning("checkWebServerSecret failed")
+		// TODO: psi_web returns NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.WithContextFields(LogFields{"queryParams": r.URL.Query()}).Info("handshake")
+
+	// TODO: necessary, in case client sends bogus request body?
+	_, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// TODO: backwards compatibility cases (only sending the new JSON format response line)
+	// TODO: share struct definition with psiphon/serverApi.go?
+	// TODO: populate more response data
+
+	var handshakeConfig struct {
+		Homepages            []string            `json:"homepages"`
+		UpgradeClientVersion string              `json:"upgrade_client_version"`
+		PageViewRegexes      []map[string]string `json:"page_view_regexes"`
+		HttpsRequestRegexes  []map[string]string `json:"https_request_regexes"`
+		EncodedServerList    []string            `json:"encoded_server_list"`
+		ClientRegion         string              `json:"client_region"`
+		ServerTimestamp      string              `json:"server_timestamp"`
+	}
+
+	handshakeConfig.ServerTimestamp = psiphon.GetCurrentTimestamp()
+
+	jsonPayload, err := json.Marshal(handshakeConfig)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	responseBody := append([]byte("Config: "), jsonPayload...)
+
+	w.WriteHeader(http.StatusOK)
+	w.Write(responseBody)
+}
+
+func (webServer *webServer) connectedHandler(w http.ResponseWriter, r *http.Request) {
+
+	if !webServer.checkWebServerSecret(r) {
+		// TODO: log more details?
+		log.WithContext().Warning("checkWebServerSecret failed")
+		// TODO: psi_web does NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.WithContextFields(LogFields{"queryParams": r.URL.Query()}).Info("connected")
+
+	// TODO: necessary, in case client sends bogus request body?
+	_, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	var connectedResponse struct {
+		ConnectedTimestamp string `json:"connected_timestamp"`
+	}
+
+	connectedResponse.ConnectedTimestamp =
+		psiphon.TruncateTimestampToHour(psiphon.GetCurrentTimestamp())
+
+	responseBody, err := json.Marshal(connectedResponse)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	w.Write(responseBody)
+}
+
+func (webServer *webServer) statusHandler(w http.ResponseWriter, r *http.Request) {
+
+	if !webServer.checkWebServerSecret(r) {
+		// TODO: log more details?
+		log.WithContext().Warning("checkWebServerSecret failed")
+		// TODO: psi_web does NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.WithContextFields(LogFields{"queryParams": r.URL.Query()}).Info("status")
+
+	// TODO: use json.NewDecoder(r.Body)? But will that handle bogus extra data in request body?
+	requestBody, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// TODO: parse payload; validate; proper logs
+	log.WithContextFields(LogFields{"payload": string(requestBody)}).Info("status payload")
+
+	w.WriteHeader(http.StatusOK)
+}

+ 25 - 2
psiphon/serverEntry.go

@@ -90,10 +90,16 @@ const (
 	SERVER_ENTRY_SOURCE_TARGET    ServerEntrySource = "TARGET"
 )
 
+// GetCapability returns the server capability corresponding
+// to the protocol.
+func GetCapability(protocol string) string {
+	return strings.TrimSuffix(protocol, "-OSSH")
+}
+
 // SupportsProtocol returns true if and only if the ServerEntry has
 // the necessary capability to support the specified tunnel protocol.
 func (serverEntry *ServerEntry) SupportsProtocol(protocol string) bool {
-	requiredCapability := strings.TrimSuffix(protocol, "-OSSH")
+	requiredCapability := GetCapability(protocol)
 	return Contains(serverEntry.Capabilities, requiredCapability)
 }
 
@@ -117,7 +123,7 @@ func (serverEntry *ServerEntry) DisableImpairedProtocols(impairedProtocols []str
 	for _, capability := range serverEntry.Capabilities {
 		omit := false
 		for _, protocol := range impairedProtocols {
-			requiredCapability := strings.TrimSuffix(protocol, "-OSSH")
+			requiredCapability := GetCapability(protocol)
 			if capability == requiredCapability {
 				omit = true
 				break
@@ -144,6 +150,23 @@ func (serverEntry *ServerEntry) GetDirectWebRequestPorts() []string {
 	return ports
 }
 
+// EncodeServerEntry returns a string containing the encoding of
+// a ServerEntry following Psiphon conventions.
+func EncodeServerEntry(serverEntry *ServerEntry) (string, error) {
+	serverEntryContents, err := json.Marshal(serverEntry)
+	if err != nil {
+		return "", ContextError(err)
+	}
+
+	return hex.EncodeToString([]byte(fmt.Sprintf(
+		"%s %s %s %s %s",
+		serverEntry.IpAddress,
+		serverEntry.WebServerPort,
+		serverEntry.WebServerSecret,
+		serverEntry.WebServerCertificate,
+		serverEntryContents))), nil
+}
+
 // DecodeServerEntry extracts server entries from the encoding
 // used by remote server lists and Psiphon server handshake requests.
 //

+ 3 - 2
psiphon/tunnel.go

@@ -548,7 +548,8 @@ func dialSsh(
 	var sshConn net.Conn
 	sshConn = conn
 	if useObfuscatedSsh {
-		sshConn, err = NewObfuscatedSshConn(conn, serverEntry.SshObfuscatedKey)
+		sshConn, err = NewObfuscatedSshConn(
+			OBFUSCATION_CONN_MODE_CLIENT, conn, serverEntry.SshObfuscatedKey)
 		if err != nil {
 			return nil, nil, nil, ContextError(err)
 		}
@@ -606,7 +607,7 @@ func dialSsh(
 	}
 
 	go func() {
-		// The folowing is adapted from ssh.Dial(), here using a custom conn
+		// The following is adapted from ssh.Dial(), here using a custom conn
 		// The sshAddress is passed through to host key verification callbacks; we don't use it.
 		sshAddress := ""
 		sshClientConn, sshChans, sshReqs, err := ssh.NewClientConn(sshConn, sshAddress, sshClientConfig)

+ 98 - 7
psiphon/utils.go

@@ -17,16 +17,49 @@
  *
  */
 
+/*
+Copyright (c) 2012 The Go Authors. 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 Google Inc. 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
+OWNER 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 psiphon
 
 import (
 	"crypto/rand"
+	"crypto/tls"
 	"crypto/x509"
 	"encoding/base64"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"math/big"
 	"net"
+	"net/http"
 	"net/url"
 	"os"
 	"runtime"
@@ -118,6 +151,16 @@ func MakeRandomPeriod(min, max time.Duration) (duration time.Duration) {
 	return
 }
 
+// MakeRandomString returns a hex encoded random string. byteLength
+// specifies the pre-encoded data length.
+func MakeRandomString(byteLength int) (string, error) {
+	bytes, err := MakeSecureRandomBytes(byteLength)
+	if err != nil {
+		return "", ContextError(err)
+	}
+	return hex.EncodeToString(bytes), nil
+}
+
 func DecodeCertificate(encodedCertificate string) (certificate *x509.Certificate, err error) {
 	derEncodedCertificate, err := base64.StdEncoding.DecodeString(encodedCertificate)
 	if err != nil {
@@ -160,18 +203,33 @@ func TrimError(err error) error {
 	return err
 }
 
-// ContextError prefixes an error message with the current function name
-func ContextError(err error) error {
-	if err == nil {
-		return nil
-	}
-	pc, _, line, _ := runtime.Caller(1)
+// getFunctionName is a helper that extracts a simple function name from
+// full name returned byruntime.Func.Name(). This is used to declutter
+// log messages containing function names.
+func getFunctionName(pc uintptr) string {
 	funcName := runtime.FuncForPC(pc).Name()
 	index := strings.LastIndex(funcName, "/")
 	if index != -1 {
 		funcName = funcName[index+1:]
 	}
-	return fmt.Errorf("%s#%d: %s", funcName, line, err)
+	return funcName
+}
+
+// GetParentContext returns the parent function name and source file
+// line number.
+func GetParentContext() string {
+	pc, _, line, _ := runtime.Caller(2)
+	return fmt.Sprintf("%s#%d", getFunctionName(pc), line)
+}
+
+// ContextError prefixes an error message with the current function
+// name and source file line number.
+func ContextError(err error) error {
+	if err == nil {
+		return nil
+	}
+	pc, _, line, _ := runtime.Caller(1)
+	return fmt.Errorf("%s#%d: %s", getFunctionName(pc), line, err)
 }
 
 // IsAddressInUseError returns true when the err is due to EADDRINUSE/WSAEADDRINUSE.
@@ -239,3 +297,36 @@ func TruncateTimestampToHour(timestamp string) string {
 	}
 	return t.Truncate(1 * time.Hour).Format(time.RFC3339)
 }
+
+// HTTPSServer is a wrapper around http.Server which adds the
+// ServeTLS function.
+type HTTPSServer struct {
+	http.Server
+}
+
+// ServeTLS is a offers the equivalent interface as http.Serve.
+// The http package has both ListenAndServe and ListenAndServeTLS higher-
+// level interfaces, but only Serve (not TLS) offers a lower-level interface that
+// allows the caller to keep a refererence to the Listener, allowing for external
+// shutdown. ListenAndServeTLS also requires the TLS cert and key to be in files
+// and we avoid that here.
+// tcpKeepAliveListener is used in http.ListenAndServeTLS but not exported,
+// so we use a copy from https://golang.org/src/net/http/server.go.
+func (server *HTTPSServer) ServeTLS(listener net.Listener) error {
+	tlsListener := tls.NewListener(tcpKeepAliveListener{listener.(*net.TCPListener)}, server.TLSConfig)
+	return server.Serve(tlsListener)
+}
+
+type tcpKeepAliveListener struct {
+	*net.TCPListener
+}
+
+func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
+	tc, err := ln.AcceptTCP()
+	if err != nil {
+		return
+	}
+	tc.SetKeepAlive(true)
+	tc.SetKeepAlivePeriod(3 * time.Minute)
+	return tc, nil
+}