Browse Source

Merge remote-tracking branch 'upstream/master'

Eugene Fryntov 9 years ago
parent
commit
19a7d2b101

+ 2 - 1
Server/.gitignore

@@ -1,4 +1,5 @@
 Server
 psiphond
 psiphond.config
-serverEntry.dat
+psiphond-traffic-rules.config
+server-entry.dat

+ 2 - 2
Server/Dockerfile

@@ -10,8 +10,8 @@ RUN apk add --update \
 
 RUN mkdir -p /opt/psiphon
 
-ADD ["psiphond", "psiphond.config", "/opt/psiphon/"]
+ADD ["psiphond", "psiphond.config", "psiphond-traffic-rules.config", "/opt/psiphon/"]
 
 WORKDIR /opt/psiphon
 
-ENTRYPOINT ["./psiphond", "--config", "psiphond.config", "run"]
+ENTRYPOINT ["./psiphond", "run"]

+ 15 - 2
Server/make.bash

@@ -23,9 +23,22 @@ build_for_linux () {
     echo "...'go build' failed, exiting"
     exit $?
   fi
-  chmod 777 psiphond
+  chmod 555 psiphond
+
+  if [ "$1" == "generate" ]; then
+    ./psiphond --ipaddress 0.0.0.0 --protocol SSH:22 --protocol OSSH:53 --web 80 generate
+    # Temporary:
+    #  - Disable syslog integration until final strategy is chosen
+    #  - Disable Fail2Ban integration until final strategy is chosen
+    sed -i 's/"SyslogFacility": "user"/"SyslogFacility": ""/' psiphond.config
+    sed -i 's/"Fail2BanFormat": "Authentication failure for psiphon-client from %s"/"Fail2BanFormat": ""/' psiphond.config
+
+    chmod 666 psiphond.config
+    chmod 666 psiphond-traffic-rules.config
+    chmod 666 server-entry.dat
+  fi
 
 }
 
-build_for_linux
+build_for_linux generate
 echo "Done"

+ 47 - 9
psiphon/server/api.go

@@ -33,7 +33,12 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 )
 
-const MAX_API_PARAMS_SIZE = 256 * 1024 // 256KB
+const (
+	MAX_API_PARAMS_SIZE = 256 * 1024 // 256KB
+
+	CLIENT_PLATFORM_ANDROID = "Android"
+	CLIENT_PLATFORM_WINDOWS = "Windows"
+)
 
 type requestJSONObject map[string]interface{}
 
@@ -60,7 +65,8 @@ func sshAPIRequestHandler(
 	var params requestJSONObject
 	err := json.Unmarshal(requestPayload, &params)
 	if err != nil {
-		return nil, psiphon.ContextError(err)
+		return nil, psiphon.ContextError(
+			fmt.Errorf("invalid payload for request name: %s: %s", name, err))
 	}
 
 	switch name {
@@ -115,7 +121,6 @@ func handshakeAPIRequestHandler(
 
 	// Ignoring errors as params are validated
 	sponsorID, _ := getStringRequestParam(params, "sponsor_id")
-	propagationChannelID, _ := getStringRequestParam(params, "propagation_channel_id")
 	clientVersion, _ := getStringRequestParam(params, "client_version")
 	clientPlatform, _ := getStringRequestParam(params, "client_platform")
 	clientRegion := geoIPData.Country
@@ -123,16 +128,16 @@ func handshakeAPIRequestHandler(
 	// Note: no guarantee that PsinetDatabase won't reload between calls
 
 	handshakeResponse.Homepages = support.PsinetDatabase.GetHomepages(
-		sponsorID, clientRegion, clientPlatform)
+		sponsorID, clientRegion, isMobileClientPlatform(clientPlatform))
 
 	handshakeResponse.UpgradeClientVersion = support.PsinetDatabase.GetUpgradeClientVersion(
-		clientVersion, clientPlatform)
+		clientVersion, normalizeClientPlatform(clientPlatform))
 
 	handshakeResponse.HttpsRequestRegexes = support.PsinetDatabase.GetHttpsRequestRegexes(
 		sponsorID)
 
 	handshakeResponse.EncodedServerList = support.PsinetDatabase.DiscoverServers(
-		propagationChannelID, geoIPData.DiscoveryValue)
+		geoIPData.DiscoveryValue)
 
 	handshakeResponse.ClientRegion = clientRegion
 
@@ -330,7 +335,23 @@ func clientVerificationAPIRequestHandler(
 		return nil, psiphon.ContextError(errors.New("invalid params"))
 	}
 
-	// TODO: implement
+	// Ignoring error as params are validated
+	clientPlatform, _ := getStringRequestParam(params, "client_platform")
+
+	verificationData, err := getJSONObjectRequestParam(params, "verificationData")
+	if err != nil {
+		return nil, psiphon.ContextError(err)
+	}
+
+	var verified bool
+	switch normalizeClientPlatform(clientPlatform) {
+	case CLIENT_PLATFORM_ANDROID:
+		verified = verifySafetyNetPayload(verificationData)
+	}
+
+	if verified {
+		// TODO: change throttling treatment
+	}
 
 	return make([]byte, 0), nil
 }
@@ -496,11 +517,12 @@ func getJSONObjectRequestParam(params requestJSONObject, name string) (requestJS
 	if params[name] == nil {
 		return nil, psiphon.ContextError(fmt.Errorf("missing param: %s", name))
 	}
-	value, ok := params[name].(requestJSONObject)
+	// TODO: can't use requestJSONObject type?
+	value, ok := params[name].(map[string]interface{})
 	if !ok {
 		return nil, psiphon.ContextError(fmt.Errorf("invalid param: %s", name))
 	}
-	return value, nil
+	return requestJSONObject(value), nil
 }
 
 func getJSONObjectArrayRequestParam(params requestJSONObject, name string) ([]requestJSONObject, error) {
@@ -547,6 +569,22 @@ func getMapStringInt64RequestParam(params requestJSONObject, name string) (map[s
 	return result, nil
 }
 
+// Normalize reported client platform. Android clients, for example, report
+// OS version, rooted status, and Google Play build status in the clientPlatform
+// string along with "Android".
+func normalizeClientPlatform(clientPlatform string) string {
+
+	if strings.Contains(strings.ToLower(clientPlatform), strings.ToLower(CLIENT_PLATFORM_ANDROID)) {
+		return CLIENT_PLATFORM_ANDROID
+	}
+
+	return CLIENT_PLATFORM_WINDOWS
+}
+
+func isMobileClientPlatform(clientPlatform string) bool {
+	return normalizeClientPlatform(clientPlatform) == CLIENT_PLATFORM_ANDROID
+}
+
 // Input validators follow the legacy validations rules in psi_web.
 
 func isServerSecret(support *SupportServices, value string) bool {

+ 9 - 13
psiphon/server/config.go

@@ -66,16 +66,9 @@ type Config struct {
 	// panic, fatal, error, warn, info, debug
 	LogLevel string
 
-	// SyslogFacility specifies the syslog facility to log to.
-	// When set, the local syslog service is used for message
-	// logging.
-	// Valid values include: "user", "local0", "local1", etc.
-	SyslogFacility string
-
-	// SyslogTag specifies an optional tag for syslog log
-	// messages. The default tag is "psiphon-server". The
-	// fail2ban logs, if enabled, also use this tag.
-	SyslogTag string
+	// LogFilename specifies the path of the file to log
+	// to. When blank, logs are written to stderr.
+	LogFilename string
 
 	// Fail2BanFormat is a string format specifier for the
 	// log message format to use for fail2ban integration for
@@ -88,6 +81,11 @@ type Config struct {
 	// "Authentication failure for psiphon-client from %s".
 	Fail2BanFormat string
 
+	// LogFilename specifies the path of the file to log
+	// fail2ban messages to. When blank, logs are written to
+	// stderr.
+	Fail2BanLogFilename string
+
 	// DiscoveryValueHMACKey is the network-wide secret value
 	// used to determine a unique discovery strategy.
 	DiscoveryValueHMACKey string
@@ -224,7 +222,7 @@ func (config *Config) RunLoadMonitor() bool {
 }
 
 // UseFail2Ban indicates whether to log client IP addresses, in authentication
-// failure cases, to the local syslog service AUTH facility for use by fail2ban.
+// failure cases, for use by fail2ban.
 func (config *Config) UseFail2Ban() bool {
 	return config.Fail2BanFormat != ""
 }
@@ -461,8 +459,6 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 
 	config := &Config{
 		LogLevel:                       "info",
-		SyslogFacility:                 "user",
-		SyslogTag:                      "psiphon-server",
 		Fail2BanFormat:                 "Authentication failure for psiphon-client from %s",
 		GeoIPDatabaseFilename:          "",
 		HostID:                         "example-host-id",

+ 40 - 50
psiphon/server/log.go

@@ -20,12 +20,11 @@
 package server
 
 import (
+	"fmt"
 	"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"
 )
 
@@ -68,7 +67,18 @@ func NewLogWriter() *io.PipeWriter {
 	return log.Writer()
 }
 
+// LogFail2Ban logs a message using the format specified by
+// config.Fail2BanFormat and the given client IP address. This
+// is for integration with fail2ban for blocking abusive
+// clients by source IP address.
+func LogFail2Ban(clientIPAddress string) {
+	fail2BanLogger.Info(
+		fmt.Sprintf(fail2BanFormat, clientIPAddress))
+}
+
 var log *ContextLogger
+var fail2BanFormat string
+var fail2BanLogger *logrus.Logger
 
 // InitLogging configures a logger according to the specified
 // config params. If not called, the default logger set by the
@@ -84,76 +94,56 @@ func InitLogging(config *Config) error {
 		return psiphon.ContextError(err)
 	}
 
-	hooks := make(logrus.LevelHooks)
-
-	var syslogHook *logrus_syslog.SyslogHook
-
-	if config.SyslogFacility != "" {
+	logWriter := os.Stderr
 
-		syslogHook, err = logrus_syslog.NewSyslogHook(
-			"", "", getSyslogPriority(config), config.SyslogTag)
+	if config.LogFilename != "" {
+		logWriter, err = os.OpenFile(
+			config.LogFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
 		if err != nil {
 			return psiphon.ContextError(err)
 		}
-
-		hooks.Add(syslogHook)
 	}
 
 	log = &ContextLogger{
 		&logrus.Logger{
-			Out:       os.Stderr,
-			Formatter: new(logrus.JSONFormatter),
-			Hooks:     hooks,
+			Out:       logWriter,
+			Formatter: &logrus.JSONFormatter{},
 			Level:     level,
 		},
 	}
 
-	return nil
-}
+	if config.Fail2BanFormat != "" {
 
-// 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,
-	}
+		fail2BanFormat = config.Fail2BanFormat
 
-	facility, ok := facilityCodes[config.SyslogFacility]
-	if !ok {
-		facility = syslog.LOG_USER
+		fail2BanLogWriter := os.Stderr
+
+		if config.Fail2BanLogFilename != "" {
+			logWriter, err = os.OpenFile(
+				config.Fail2BanLogFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
+			if err != nil {
+				return psiphon.ContextError(err)
+			}
+		}
+
+		fail2BanLogger = &logrus.Logger{
+			Out: fail2BanLogWriter,
+			Formatter: &logrus.TextFormatter{
+				DisableColors: true,
+				FullTimestamp: true,
+			},
+			Level: level,
+		}
 	}
 
-	return severity | facility
+	return nil
 }
 
 func init() {
 	log = &ContextLogger{
 		&logrus.Logger{
 			Out:       os.Stderr,
-			Formatter: new(logrus.JSONFormatter),
+			Formatter: &logrus.JSONFormatter{},
 			Hooks:     make(logrus.LevelHooks),
 			Level:     logrus.DebugLevel,
 		},

+ 434 - 19
psiphon/server/psinet/psinet.go

@@ -24,7 +24,16 @@
 package psinet
 
 import (
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"math"
+	"math/rand"
+	"strconv"
+	"strings"
 	"sync"
+	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 )
@@ -35,10 +44,88 @@ import (
 type Database struct {
 	sync.RWMutex
 
-	// TODO: implement
+	AlternateMeekFrontingAddresses      map[string][]string        `json:"alternate_meek_fronting_addresses"`
+	AlternateMeekFrontingAddressesRegex map[string]string          `json:"alternate_meek_fronting_addresses_regex"`
+	Hosts                               map[string]Host            `json:"hosts"`
+	MeekFrontingDisableSNI              map[string]bool            `json:"meek_fronting_disable_SNI"`
+	Servers                             []Server                   `json:"servers"`
+	Sponsors                            map[string]Sponsor         `json:"sponsors"`
+	Versions                            map[string][]ClientVersion `json:"client_versions"`
 }
 
-// NewDatabase initializes a Database, calling Load on the specified
+type Host struct {
+	AlternateMeekServerFrontingHosts []string `json:"alternate_meek_server_fronting_hosts"`
+	DatacenterName                   string   `json:"datacenter_name"`
+	Id                               string   `json:"id"`
+	IpAddress                        string   `json:"ip_address"`
+	MeekCookieEncryptionPublicKey    string   `json:"meek_cookie_encryption_public_key"`
+	MeekServerFrontingDomain         string   `json:"meek_server_fronting_domain"`
+	MeekServerFrontingHost           string   `json:"meek_server_fronting_host"`
+	MeekServerObfuscatedKey          string   `json:"meek_server_obfuscated_key"`
+	MeekServerPort                   int      `json:"meek_server_port"`
+	Region                           string   `json:"region"`
+}
+
+type Server struct {
+	AlternateSshObfuscatedPorts []string        `json:"alternate_ssh_obfuscated_ports"`
+	Capabilities                map[string]bool `json:"capabilities"`
+	DiscoveryDateRange          []string        `json:"discovery_date_range"`
+	EgressIpAddress             string          `json:"egress_ip_address"`
+	HostId                      string          `json:"host_id"`
+	Id                          string          `json:"id"`
+	InternalIpAddress           string          `json:"internal_ip_address"`
+	IpAddress                   string          `json:"ip_address"`
+	IsEmbedded                  bool            `json:"is_embedded"`
+	IsPermanent                 bool            `json:"is_permanent"`
+	PropogationChannelId        string          `json:"propagation_channel_id"`
+	SshHostKey                  string          `json:"ssh_host_key"`
+	SshObfuscatedKey            string          `json:"ssh_obfuscated_key"`
+	SshObfuscatedPort           int             `json:"ssh_obfuscated_port"`
+	SshPassword                 string          `json:"ssh_password"`
+	SshPort                     string          `json:"ssh_port"`
+	SshUsername                 string          `json:"ssh_username"`
+	WebServerCertificate        string          `json:"web_server_certificate"`
+	WebServerPort               string          `json:"web_server_port"`
+	WebServerSecret             string          `json:"web_server_secret"`
+}
+
+type Sponsor struct {
+	Banner              string
+	HomePages           map[string][]HomePage `json:"home_pages"`
+	HttpsRequestRegexes []HttpsRequestRegex   `json:"https_request_regexes"`
+	Id                  string                `json:"id"`
+	MobileHomePages     map[string][]HomePage `json:"mobile_home_pages"`
+	Name                string                `json:"name"`
+	PageViewRegexes     []PageViewRegex       `json:"page_view_regexes"`
+	WebsiteBanner       string                `json:"website_banner"`
+	WebsiteBannerLink   string                `json:"website_banner_link"`
+}
+
+type ClientVersion struct {
+	Version string `json:"version"`
+}
+
+type HomePage struct {
+	Region string `json:"region"`
+	Url    string `json:"url"`
+}
+
+type HttpsRequestRegex struct {
+	Regex   string `json:"regex"`
+	Replace string `json:"replace"`
+}
+
+type MobileHomePage struct {
+	Region string `json:"region"`
+	Url    string `json:"url"`
+}
+
+type PageViewRegex struct {
+	Regex   string `json:"regex"`
+	Replace string `json:"replace"`
+}
+
+// NewDatabase initializes a Database, calling Reload on the specified
 // filename.
 func NewDatabase(filename string) (*Database, error) {
 
@@ -52,60 +139,388 @@ func NewDatabase(filename string) (*Database, error) {
 	return database, nil
 }
 
-// Reload [re]initializes the Database with the Psiphon network data
+// Load [re]initializes the Database with the Psiphon network data
 // in the specified file. This function obtains a write lock on
 // the database, blocking all readers.
 // The input "" is valid and initializes a functional Database
-// with no data. When Reload fails, the previous Database state is
-// retained.
+// with no data.
+// The previously loaded data will persist if an error occurs
+// while reinitializing the database.
 func (db *Database) Reload(filename string) error {
-	db.Lock()
-	defer db.Unlock()
+	if filename == "" {
+		return nil
+	}
 
-	// TODO: implement
+	configJSON, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
 
-	return nil
+	// Unmarshal first validates the provided JSON and then
+	// populates the interface. The previously loaded data
+	// persists if the new JSON is malformed.
+	err = json.Unmarshal(configJSON, &db)
+
+	return psiphon.ContextError(err)
 }
 
 // GetHomepages returns a list of  home pages for the specified sponsor,
 // region, and platform.
-func (db *Database) GetHomepages(sponsorID, clientRegion, clientPlatform string) []string {
+func (db *Database) GetHomepages(sponsorID, clientRegion string, isMobilePlatform bool) []string {
 	db.RLock()
 	defer db.RUnlock()
 
-	// TODO: implement
+	sponsorHomePages := make([]string, 0)
+
+	// Sponsor id does not exist: fail gracefully
+	sponsor, ok := db.Sponsors[sponsorID]
+	if !ok {
+		return nil
+	}
+
+	homePages := sponsor.HomePages
+
+	if isMobilePlatform {
+		if sponsor.MobileHomePages != nil {
+			homePages = sponsor.MobileHomePages
+		}
+	}
+
+	// Case: lookup succeeded and corresponding homepages found for region
+	homePagesByRegion, ok := homePages[clientRegion]
+	if ok {
+		for _, homePage := range homePagesByRegion {
+			sponsorHomePages = append(sponsorHomePages, strings.Replace(homePage.Url, "client_region=XX", "client_region="+clientRegion, 1))
+		}
+	}
+
+	// Case: lookup failed or no corresponding homepages found for region --> use default
+	if sponsorHomePages == nil {
+		defaultHomePages, ok := homePages["None"]
+		if ok {
+			for _, homePage := range defaultHomePages {
+				// client_region query parameter substitution
+				sponsorHomePages = append(sponsorHomePages, strings.Replace(homePage.Url, "client_region=XX", "client_region="+clientRegion, 1))
+			}
+		}
+	}
 
-	return make([]string, 0)
+	return sponsorHomePages
 }
 
 // GetUpgradeClientVersion returns a new client version when an upgrade is
 // indicated for the specified client current version. The result is "" when
-// no upgrade is available.
+// no upgrade is available. Caller should normalize clientPlatform.
 func (db *Database) GetUpgradeClientVersion(clientVersion, clientPlatform string) string {
 	db.RLock()
 	defer db.RUnlock()
 
-	// TODO: implement
+	// Check lastest version number against client version number
+
+	clientVersions, ok := db.Versions[clientPlatform]
+	if !ok {
+		return ""
+	}
+
+	if len(clientVersions) == 0 {
+		return ""
+	}
+
+	// NOTE: Assumes versions list is in ascending version order
+	lastVersion := clientVersions[len(clientVersions)-1].Version
+
+	lastVersionInt, err := strconv.Atoi(lastVersion)
+	if err != nil {
+		return ""
+	}
+	clientVersionInt, err := strconv.Atoi(clientVersion)
+	if err != nil {
+		return ""
+	}
+
+	// Return latest version if upgrade needed
+	if lastVersionInt > clientVersionInt {
+		return lastVersion
+	}
 
 	return ""
 }
 
 // GetHttpsRequestRegexes returns bytes transferred stats regexes for the
-// specified sponsor.
+// specified sponsor. The result is nil when an unknown sponsorID is provided.
 func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string {
 	db.RLock()
 	defer db.RUnlock()
 
-	return make([]map[string]string, 0)
+	regexes := make([]map[string]string, 0)
+
+	for i := range db.Sponsors[sponsorID].HttpsRequestRegexes {
+		regex := make(map[string]string)
+		regex["replace"] = db.Sponsors[sponsorID].HttpsRequestRegexes[i].Replace
+		regex["regex"] = db.Sponsors[sponsorID].HttpsRequestRegexes[i].Regex
+		regexes = append(regexes, regex)
+	}
+
+	return regexes
 }
 
 // DiscoverServers selects new encoded server entries to be "discovered" by
 // the client, using the discoveryValue as the input into the discovery algorithm.
-func (db *Database) DiscoverServers(propagationChannelID string, discoveryValue int) []string {
+// The server list (db.Servers) loaded from JSON is stored as an array instead of
+// a map to ensure servers are discovered deterministically. Each iteration over a
+// map in go is seeded with a random value which causes non-deterministic ordering.
+func (db *Database) DiscoverServers(discoveryValue int) []string {
 	db.RLock()
 	defer db.RUnlock()
 
-	// TODO: implement
+	var servers []Server
+
+	discoveryDate := time.Now().UTC()
+	candidateServers := make([]Server, 0)
+
+	for _, server := range db.Servers {
+		var start time.Time
+		var end time.Time
+		var err error
+
+		// All servers that are discoverable on this day are eligable for discovery
+		if len(server.DiscoveryDateRange) != 0 {
+			start, err = time.Parse("2006-01-02T15:04:05", server.DiscoveryDateRange[0])
+			if err != nil {
+				continue
+			}
+			end, err = time.Parse("2006-01-02T15:04:05", server.DiscoveryDateRange[1])
+			if err != nil {
+				continue
+			}
+			if discoveryDate.After(start) && discoveryDate.Before(end) {
+				candidateServers = append(candidateServers, server)
+			}
+		}
+	}
+	servers = selectServers(candidateServers, discoveryValue)
+
+	encodedServerEntries := make([]string, 0)
+
+	for _, server := range servers {
+		encodedServerEntries = append(encodedServerEntries, db.getEncodedServerEntry(server))
+	}
+
+	return encodedServerEntries
+}
+
+// Combine client IP address and time-of-day strategies to give out different
+// discovery servers to different clients. The aim is to achieve defense against
+// enumerability. We also want to achieve a degree of load balancing clients
+// and these strategies are expected to have reasonably random distribution,
+// even for a cluster of users coming from the same network.
+//
+// We only select one server: multiple results makes enumeration easier; the
+// strategies have a built-in load balancing effect; and date range discoverability
+// means a client will actually learn more servers later even if they happen to
+// always pick the same result at this point.
+//
+// This is a blended strategy: as long as there are enough servers to pick from,
+// both aspects determine which server is selected. IP address is given the
+// priority: if there are only a couple of servers, for example, IP address alone
+// determines the outcome.
+func selectServers(servers []Server, discoveryValue int) []Server {
+	TIME_GRANULARITY := 3600
+
+	if len(servers) == 0 {
+		return nil
+	}
+
+	// Current time truncated to an hour
+	timeInSeconds := int(time.Now().Unix())
+	timeStrategyValue := timeInSeconds / TIME_GRANULARITY
+
+	// Divide servers into buckets. The bucket count is chosen such that the number
+	// of buckets and the number of items in each bucket are close (using sqrt).
+	// IP address selects the bucket, time selects the item in the bucket.
+
+	// NOTE: this code assumes that the range of possible timeStrategyValues
+	// and discoveryValues are sufficient to index to all bucket items.
+	bucketCount := calculateBucketCount(len(servers))
+
+	buckets := bucketizeServerList(servers, bucketCount)
+	bucket := buckets[discoveryValue%len(buckets)]
+	server := bucket[timeStrategyValue%len(bucket)]
+
+	serverList := make([]Server, 1)
+	serverList[0] = server
+
+	return serverList
+}
+
+// Number of buckets such that first strategy picks among about the same number
+// of choices as the second strategy. Gives an edge to the "outer" strategy.
+func calculateBucketCount(length int) int {
+	return int(math.Ceil(math.Sqrt(float64(length))))
+}
+
+// Create bucketCount buckets.
+// Each bucket will be of size division or divison-1.
+func bucketizeServerList(servers []Server, bucketCount int) [][]Server {
+	division := float64(len(servers)) / float64(bucketCount)
+
+	buckets := make([][]Server, bucketCount)
+
+	var currentBucketIndex int = 0
+	var serverIndex int = 0
+	for _, server := range servers {
+		bucketEndIndex := int(math.Floor(division * (float64(currentBucketIndex) + 1)))
+
+		buckets[currentBucketIndex] = append(buckets[currentBucketIndex], server)
+
+		serverIndex++
+		if serverIndex > bucketEndIndex {
+			currentBucketIndex++
+		}
+	}
+
+	return buckets
+}
+
+// Return hex encoded server entry string for comsumption by client.
+// Newer clients ignore the legacy fields and only utilize the extended (new) config.
+func (db *Database) getEncodedServerEntry(server Server) string {
+
+	// Double-check that we're not giving our blank server credentials
+	if len(server.IpAddress) <= 1 || len(server.WebServerPort) <= 1 || len(server.WebServerSecret) <= 1 || len(server.WebServerCertificate) <= 1 {
+		return ""
+	}
+
+	// Extended (new) entry fields are in a JSON string
+	var extendedConfig struct {
+		IpAddress                     string
+		WebServerPort                 string
+		WebServerSecret               string
+		WebServerCertificate          string
+		SshPort                       int
+		SshUsername                   string
+		SshPassword                   string
+		SshHostKey                    string
+		SshObfuscatedPort             int
+		SshObfuscatedKey              string
+		Region                        string
+		MeekServerPort                int
+		MeekObfuscatedKey             string
+		MeekFrontingDomain            string
+		MeekFrontingHost              string
+		MeekCookieEncryptionPublicKey string
+		meekFrontingAddresses         []string
+		meekFrontingAddressesRegex    string
+		meekFrontingDisableSNI        bool
+		meekFrontingHosts             []string
+		capabilities                  []string
+	}
+
+	// NOTE: also putting original values in extended config for easier parsing by new clients
+	extendedConfig.IpAddress = server.IpAddress
+	extendedConfig.WebServerPort = server.WebServerPort
+	extendedConfig.WebServerSecret = server.WebServerSecret
+	extendedConfig.WebServerCertificate = server.WebServerCertificate
+
+	sshPort, err := strconv.Atoi(server.SshPort)
+	if err != nil {
+		extendedConfig.SshPort = 0
+	} else {
+		extendedConfig.SshPort = sshPort
+	}
+
+	extendedConfig.SshUsername = server.SshUsername
+	extendedConfig.SshPassword = server.SshPassword
+
+	sshHostKeyType, sshHostKey := parseSshKeyString(server.SshHostKey)
+
+	if strings.Compare(sshHostKeyType, "ssh-rsa") == 0 {
+		extendedConfig.SshHostKey = sshHostKey
+	} else {
+		extendedConfig.SshHostKey = ""
+	}
+
+	extendedConfig.SshObfuscatedPort = server.SshObfuscatedPort
+	// Use the latest alternate port unless tunneling through meek
+	if len(server.AlternateSshObfuscatedPorts) > 0 && !(server.Capabilities["FRONTED-MEEK"] || server.Capabilities["UNFRONTED-MEEK"]) {
+		port, err := strconv.Atoi(server.AlternateSshObfuscatedPorts[len(server.AlternateSshObfuscatedPorts)-1])
+		if err == nil {
+			extendedConfig.SshObfuscatedPort = port
+		}
+	}
+
+	extendedConfig.SshObfuscatedKey = server.SshObfuscatedKey
+
+	host := db.Hosts[server.HostId]
+
+	extendedConfig.Region = host.Region
+	extendedConfig.MeekServerPort = host.MeekServerPort
+	extendedConfig.MeekObfuscatedKey = host.MeekServerObfuscatedKey
+	extendedConfig.MeekFrontingDomain = host.MeekServerFrontingDomain
+	extendedConfig.MeekFrontingHost = host.MeekServerFrontingHost
+	extendedConfig.MeekCookieEncryptionPublicKey = host.MeekCookieEncryptionPublicKey
+
+	serverCapabilities := make(map[string]bool, 0)
+	for capability, enabled := range server.Capabilities {
+		serverCapabilities[capability] = enabled
+	}
+
+	if serverCapabilities["UNFRONTED-MEEK"] && host.MeekServerPort == 443 {
+		serverCapabilities["UNFRONTED-MEEK"] = false
+		serverCapabilities["UNFRONTED-MEEK-HTTPS"] = true
+	}
+
+	if host.MeekServerFrontingDomain != "" {
+		alternateMeekFrontingAddresses := db.AlternateMeekFrontingAddresses[host.MeekServerFrontingDomain]
+		if len(alternateMeekFrontingAddresses) > 0 {
+			// Choose 3 addresses randomly
+			perm := rand.Perm(len(alternateMeekFrontingAddresses))[:int(math.Min(float64(len(alternateMeekFrontingAddresses)), float64(3)))]
+
+			for i := range perm {
+				extendedConfig.meekFrontingAddresses = append(extendedConfig.meekFrontingAddresses, alternateMeekFrontingAddresses[perm[i]])
+			}
+		}
+
+		extendedConfig.meekFrontingAddressesRegex = db.AlternateMeekFrontingAddressesRegex[host.MeekServerFrontingDomain]
+		extendedConfig.meekFrontingDisableSNI = db.MeekFrontingDisableSNI[host.MeekServerFrontingDomain]
+	}
+
+	if host.AlternateMeekServerFrontingHosts != nil {
+		// Choose 3 addresses randomly
+		perm := rand.Perm(len(host.AlternateMeekServerFrontingHosts))[:int(math.Min(float64(len(host.AlternateMeekServerFrontingHosts)), float64(3)))]
+
+		for i := range perm {
+			extendedConfig.meekFrontingHosts = append(extendedConfig.meekFrontingHosts, host.AlternateMeekServerFrontingHosts[i])
+		}
+
+		if serverCapabilities["FRONTED-MEEK"] == true {
+			serverCapabilities["FRONTED-MEEK-HTTP"] = true
+		}
+	}
+
+	for capability, enabled := range serverCapabilities {
+		if enabled == true {
+			extendedConfig.capabilities = append(extendedConfig.capabilities, capability)
+		}
+	}
+
+	jsonDump, err := json.Marshal(extendedConfig)
+	if err != nil {
+		return ""
+	}
+
+	// Legacy format + extended (new) config
+	prefixString := fmt.Sprintf("%s %s %s %s ", server.IpAddress, server.WebServerPort, server.WebServerSecret, server.WebServerCertificate)
+
+	return hex.EncodeToString(append([]byte(prefixString)[:], []byte(jsonDump)[:]...))
+}
+
+// Parse string of format "ssh-key-type ssh-key".
+func parseSshKeyString(sshKeyString string) (keyType string, key string) {
+	sshKeyArr := strings.Split(sshKeyString, " ")
+	if len(sshKeyArr) != 2 {
+		return "", ""
+	}
 
-	return make([]string, 0)
+	return sshKeyArr[0], sshKeyArr[1]
 }

+ 27 - 0
psiphon/server/safetyNet.go

@@ -0,0 +1,27 @@
+/*
+ * 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
+
+func verifySafetyNetPayload(params requestJSONObject) bool {
+
+	// TODO: implement
+
+	return true
+}

+ 100 - 6
psiphon/server/server_test.go

@@ -28,6 +28,7 @@ import (
 	"net/url"
 	"os"
 	"sync"
+	"syscall"
 	"testing"
 	"time"
 
@@ -49,6 +50,7 @@ func TestSSH(t *testing.T) {
 		&runServerConfig{
 			tunnelProtocol:       "SSH",
 			enableSSHAPIRequests: true,
+			doHotReload:          false,
 		})
 }
 
@@ -57,6 +59,7 @@ func TestOSSH(t *testing.T) {
 		&runServerConfig{
 			tunnelProtocol:       "OSSH",
 			enableSSHAPIRequests: true,
+			doHotReload:          false,
 		})
 }
 
@@ -65,6 +68,7 @@ func TestUnfrontedMeek(t *testing.T) {
 		&runServerConfig{
 			tunnelProtocol:       "UNFRONTED-MEEK-OSSH",
 			enableSSHAPIRequests: true,
+			doHotReload:          false,
 		})
 }
 
@@ -73,6 +77,7 @@ func TestUnfrontedMeekHTTPS(t *testing.T) {
 		&runServerConfig{
 			tunnelProtocol:       "UNFRONTED-MEEK-HTTPS-OSSH",
 			enableSSHAPIRequests: true,
+			doHotReload:          false,
 		})
 }
 
@@ -81,12 +86,23 @@ func TestWebTransportAPIRequests(t *testing.T) {
 		&runServerConfig{
 			tunnelProtocol:       "OSSH",
 			enableSSHAPIRequests: false,
+			doHotReload:          false,
+		})
+}
+
+func TestHotReload(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "OSSH",
+			enableSSHAPIRequests: true,
+			doHotReload:          true,
 		})
 }
 
 type runServerConfig struct {
 	tunnelProtocol       string
 	enableSSHAPIRequests bool
+	doHotReload          bool
 }
 
 func runServer(t *testing.T, runConfig *runServerConfig) {
@@ -106,9 +122,14 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	// customize server config
 
+	// Pave psinet with random values to test handshake homepages.
+	psinetFilename := "psinet.json"
+	sponsorID, expectedHomepageURL := pavePsinetDatabaseFile(t, psinetFilename)
+
 	var serverConfig interface{}
 	json.Unmarshal(serverConfigJSON, &serverConfig)
 	serverConfig.(map[string]interface{})["GeoIPDatabaseFilename"] = ""
+	serverConfig.(map[string]interface{})["PsinetDatabaseFilename"] = psinetFilename
 	serverConfig.(map[string]interface{})["TrafficRulesFilename"] = ""
 	serverConfigJSON, _ = json.Marshal(serverConfig)
 
@@ -146,6 +167,26 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}()
 
+	// Test: hot reload (of psinet)
+
+	if runConfig.doHotReload {
+		// TODO: monitor logs for more robust wait-until-loaded
+		time.Sleep(1 * time.Second)
+
+		// Pave a new psinet with different random values.
+		sponsorID, expectedHomepageURL = pavePsinetDatabaseFile(t, psinetFilename)
+
+		p, _ := os.FindProcess(os.Getpid())
+		p.Signal(syscall.SIGUSR1)
+
+		// TODO: monitor logs for more robust wait-until-reloaded
+		time.Sleep(1 * time.Second)
+
+		// After reloading psinet, the new sponsorID/expectedHomepageURL
+		// should be active, as tested in the client "Homepage" notice
+		// handler below.
+	}
+
 	// connect to server with client
 
 	// TODO: currently, TargetServerEntry only works with one tunnel
@@ -155,13 +196,14 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	// Note: calling LoadConfig ensures all *int config fields are initialized
 	clientConfigJSON := `
-	{
-	"ClientVersion":                     "0",
-	"PropagationChannelId":              "0",
-	"SponsorId":                         "0"
-	}`
+    {
+        "ClientVersion" : "0",
+        "SponsorId" : "0",
+        "PropagationChannelId" : "0"
+    }`
 	clientConfig, _ := psiphon.LoadConfig([]byte(clientConfigJSON))
 
+	clientConfig.SponsorId = sponsorID
 	clientConfig.ConnectionWorkerPoolSize = numTunnels
 	clientConfig.TunnelPoolSize = numTunnels
 	clientConfig.DisableRemoteServerListFetcher = true
@@ -181,6 +223,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	}
 
 	tunnelsEstablished := make(chan struct{}, 1)
+	homepageReceived := make(chan struct{}, 1)
 
 	psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
@@ -201,6 +244,16 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 					default:
 					}
 				}
+			case "Homepage":
+				homepageURL := payload["url"].(string)
+				if homepageURL != expectedHomepageURL {
+					// TODO: wrong goroutine for t.FatalNow()
+					t.Fatalf("unexpected homepage: %s", homepageURL)
+				}
+				select {
+				case homepageReceived <- *new(struct{}):
+				default:
+				}
 			}
 		}))
 
@@ -229,7 +282,8 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}()
 
-	// Test: tunnels must be established within 30 seconds
+	// Test: tunnels must be established, and correct homepage
+	// must be received, within 30 seconds
 
 	establishTimeout := time.NewTimer(30 * time.Second)
 	select {
@@ -238,6 +292,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		t.Fatalf("tunnel establish timeout exceeded")
 	}
 
+	select {
+	case <-homepageReceived:
+	case <-establishTimeout.C:
+		t.Fatalf("homepage received timeout exceeded")
+	}
+
 	// Test: tunneled web site fetch
 
 	testUrl := "https://psiphon.ca"
@@ -266,3 +326,37 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	}
 	response.Body.Close()
 }
+
+func pavePsinetDatabaseFile(t *testing.T, psinetFilename string) (string, string) {
+
+	sponsorID, _ := psiphon.MakeRandomStringHex(8)
+
+	fakeDomain, _ := psiphon.MakeRandomStringHex(4)
+	fakePath, _ := psiphon.MakeRandomStringHex(4)
+	expectedHomepageURL := fmt.Sprintf("https://%s.com/%s", fakeDomain, fakePath)
+
+	psinetJSONFormat := `
+    {
+        "sponsors": {
+            "%s": {
+                "home_pages": {
+                    "None": [
+                        {
+                            "region": null,
+                            "url": "%s"
+                        }
+                    ]
+                }
+            }
+        }
+    }
+	`
+	psinetJSON := fmt.Sprintf(psinetJSONFormat, sponsorID, expectedHomepageURL)
+
+	err := ioutil.WriteFile(psinetFilename, []byte(psinetJSON), 0600)
+	if err != nil {
+		t.Fatalf("error paving psinet database: %s", err)
+	}
+
+	return sponsorID, expectedHomepageURL
+}

+ 93 - 32
psiphon/server/tunnelServer.go

@@ -181,13 +181,14 @@ func (server *TunnelServer) Run() error {
 type sshClientID uint64
 
 type sshServer struct {
-	support           *SupportServices
-	shutdownBroadcast <-chan struct{}
-	sshHostKey        ssh.Signer
-	nextClientID      sshClientID
-	clientsMutex      sync.Mutex
-	stoppingClients   bool
-	clients           map[sshClientID]*sshClient
+	support              *SupportServices
+	shutdownBroadcast    <-chan struct{}
+	sshHostKey           ssh.Signer
+	nextClientID         sshClientID
+	clientsMutex         sync.Mutex
+	stoppingClients      bool
+	acceptedClientCounts map[string]int64
+	clients              map[sshClientID]*sshClient
 }
 
 func newSSHServer(
@@ -206,11 +207,12 @@ func newSSHServer(
 	}
 
 	return &sshServer{
-		support:           support,
-		shutdownBroadcast: shutdownBroadcast,
-		sshHostKey:        signer,
-		nextClientID:      1,
-		clients:           make(map[sshClientID]*sshClient),
+		support:              support,
+		shutdownBroadcast:    shutdownBroadcast,
+		sshHostKey:           signer,
+		nextClientID:         1,
+		acceptedClientCounts: make(map[string]int64),
+		clients:              make(map[sshClientID]*sshClient),
 	}, nil
 }
 
@@ -284,7 +286,28 @@ func (sshServer *sshServer) runListener(
 	}
 }
 
-func (sshServer *sshServer) registerClient(client *sshClient) (sshClientID, bool) {
+// An accepted client has completed a direct TCP or meek connection and has a net.Conn. Registration
+// is for tracking the number of connections.
+func (sshServer *sshServer) registerAcceptedClient(tunnelProtocol string) {
+
+	sshServer.clientsMutex.Lock()
+	defer sshServer.clientsMutex.Unlock()
+
+	sshServer.acceptedClientCounts[tunnelProtocol] += 1
+}
+
+func (sshServer *sshServer) unregisterAcceptedClient(tunnelProtocol string) {
+
+	sshServer.clientsMutex.Lock()
+	defer sshServer.clientsMutex.Unlock()
+
+	sshServer.acceptedClientCounts[tunnelProtocol] -= 1
+}
+
+// An established client has completed its SSH handshake and has a ssh.Conn. Registration is
+// for tracking the number of fully established clients and for maintaining a list of running
+// clients (for stopping at shutdown time).
+func (sshServer *sshServer) registerEstablishedClient(client *sshClient) (sshClientID, bool) {
 
 	sshServer.clientsMutex.Lock()
 	defer sshServer.clientsMutex.Unlock()
@@ -301,7 +324,7 @@ func (sshServer *sshServer) registerClient(client *sshClient) (sshClientID, bool
 	return clientID, true
 }
 
-func (sshServer *sshServer) unregisterClient(clientID sshClientID) {
+func (sshServer *sshServer) unregisterEstablishedClient(clientID sshClientID) {
 
 	sshServer.clientsMutex.Lock()
 	client := sshServer.clients[clientID]
@@ -319,19 +342,36 @@ func (sshServer *sshServer) getLoadStats() map[string]map[string]int64 {
 	defer sshServer.clientsMutex.Unlock()
 
 	loadStats := make(map[string]map[string]int64)
+
+	// Explicitly populate with zeros to get 0 counts in log messages derived from getLoadStats()
+
+	for tunnelProtocol, _ := range sshServer.support.Config.TunnelProtocolPorts {
+		loadStats[tunnelProtocol] = make(map[string]int64)
+		loadStats[tunnelProtocol]["AcceptedClients"] = 0
+		loadStats[tunnelProtocol]["EstablishedClients"] = 0
+		loadStats[tunnelProtocol]["TCPPortForwards"] = 0
+		loadStats[tunnelProtocol]["TotalTCPPortForwards"] = 0
+		loadStats[tunnelProtocol]["UDPPortForwards"] = 0
+		loadStats[tunnelProtocol]["TotalUDPPortForwards"] = 0
+	}
+
+	// Note: as currently tracked/counted, each established client is also an accepted client
+
+	for tunnelProtocol, acceptedClientCount := range sshServer.acceptedClientCounts {
+		loadStats[tunnelProtocol]["AcceptedClients"] = acceptedClientCount
+	}
+
 	for _, client := range sshServer.clients {
-		if loadStats[client.tunnelProtocol] == nil {
-			loadStats[client.tunnelProtocol] = make(map[string]int64)
-		}
 		// Note: can't sum trafficState.peakConcurrentPortForwardCount to get a global peak
-		loadStats[client.tunnelProtocol]["CurrentClients"] += 1
+		loadStats[client.tunnelProtocol]["EstablishedClients"] += 1
 		client.Lock()
-		loadStats[client.tunnelProtocol]["CurrentTCPPortForwards"] += client.tcpTrafficState.concurrentPortForwardCount
+		loadStats[client.tunnelProtocol]["TCPPortForwards"] += client.tcpTrafficState.concurrentPortForwardCount
 		loadStats[client.tunnelProtocol]["TotalTCPPortForwards"] += client.tcpTrafficState.totalPortForwardCount
-		loadStats[client.tunnelProtocol]["CurrentUDPPortForwards"] += client.udpTrafficState.concurrentPortForwardCount
+		loadStats[client.tunnelProtocol]["UDPPortForwards"] += client.udpTrafficState.concurrentPortForwardCount
 		loadStats[client.tunnelProtocol]["TotalUDPPortForwards"] += client.udpTrafficState.totalPortForwardCount
 		client.Unlock()
 	}
+
 	return loadStats
 }
 
@@ -350,6 +390,9 @@ func (sshServer *sshServer) stopClients() {
 
 func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.Conn) {
 
+	sshServer.registerAcceptedClient(tunnelProtocol)
+	defer sshServer.unregisterAcceptedClient(tunnelProtocol)
+
 	geoIPData := sshServer.support.GeoIPService.Lookup(
 		psiphon.IPAddressFromAddr(clientConn.RemoteAddr()))
 
@@ -462,17 +505,18 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 	sshClient.activityConn = activityConn
 	sshClient.Unlock()
 
-	clientID, ok := sshServer.registerClient(sshClient)
+	clientID, ok := sshServer.registerEstablishedClient(sshClient)
 	if !ok {
 		clientConn.Close()
 		log.WithContext().Warning("register failed")
 		return
 	}
-	defer sshServer.unregisterClient(clientID)
+	defer sshServer.unregisterEstablishedClient(clientID)
 
 	sshClient.runClient(result.channels, result.requests)
 
-	// TODO: clientConn.Close()?
+	// Note: sshServer.unregisterClient calls sshClient.Close(),
+	// which also closes underlying transport Conn.
 }
 
 type sshClient struct {
@@ -566,17 +610,25 @@ func (sshClient *sshClient) passwordCallback(conn ssh.ConnMetadata, password []b
 }
 
 func (sshClient *sshClient) authLogCallback(conn ssh.ConnMetadata, method string, err error) {
+
 	if err != nil {
-		logFields := LogFields{"error": err, "method": method}
+
+		if method == "none" && err.Error() == "no auth passed yet" {
+			// In this case, the callback invocation is noise from auth negotiation
+			return
+		}
+
 		if sshClient.sshServer.support.Config.UseFail2Ban() {
 			clientIPAddress := psiphon.IPAddressFromAddr(conn.RemoteAddr())
 			if clientIPAddress != "" {
-				logFields["fail2ban"] = fmt.Sprintf(
-					sshClient.sshServer.support.Config.Fail2BanFormat, clientIPAddress)
+				LogFail2Ban(clientIPAddress)
 			}
 		}
+
 		log.WithContextFields(LogFields{"error": err, "method": method}).Error("authentication failed")
+
 	} else {
+
 		log.WithContextFields(LogFields{"error": err, "method": method}).Debug("authentication success")
 	}
 }
@@ -632,12 +684,21 @@ func (sshClient *sshClient) runClient(
 
 		for request := range requests {
 
-			// requests are processed serially; responses must be sent in request order.
-			responsePayload, err := sshAPIRequestHandler(
-				sshClient.sshServer.support,
-				sshClient.geoIPData,
-				request.Type,
-				request.Payload)
+			// Requests are processed serially; API responses must be sent in request order.
+
+			var responsePayload []byte
+			var err error
+
+			if request.Type == "[email protected]" {
+				// Keepalive requests have an empty response.
+			} else {
+				// All other requests are assumed to be API requests.
+				responsePayload, err = sshAPIRequestHandler(
+					sshClient.sshServer.support,
+					sshClient.geoIPData,
+					request.Type,
+					request.Payload)
+			}
 
 			if err == nil {
 				err = request.Reply(true, responsePayload)

+ 4 - 2
psiphon/serverApi.go

@@ -345,7 +345,8 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 
 	if serverContext.psiphonHttpsClient == nil {
 
-		params["statusData"] = json.RawMessage(statusPayload)
+		rawMessage := json.RawMessage(statusPayload)
+		params["statusData"] = &rawMessage
 
 		var request []byte
 		request, err = makeSSHAPIRequestPayload(params)
@@ -655,7 +656,8 @@ func (serverContext *ServerContext) DoClientVerificationRequest(
 
 	if serverContext.psiphonHttpsClient == nil {
 
-		params["verificationData"] = json.RawMessage(verificationPayload)
+		rawMessage := json.RawMessage(verificationPayload)
+		params["verificationData"] = &rawMessage
 
 		request, err := makeSSHAPIRequestPayload(params)
 		if err != nil {