Quellcode durchsuchen

Experimental Psiphon server stack - initial commit
* new server stack for testing, experimental development, and simplified deployment
* functionality modeled on production Psiphon server
stack (https://bitbucket.org/psiphon/psiphon-circumvention-system/src/tip/Server/)
* /psiphon/server package contains services
* /Server directory contains main program
* generates server config and Psiphon server entry
* initially supports SSH protocol
* minimal server API sufficient to respond to client as
expected

Rod Hynes vor 10 Jahren
Ursprung
Commit
5041cc4394

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


+ 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)
+		}
+	}
+}

+ 14 - 0
psiphon/server/README.md

@@ -0,0 +1,14 @@
+Psiphon Tunnel Core Server README
+================================================================================
+
+Overview
+--------------------------------------------------------------------------------
+
+
+Status
+--------------------------------------------------------------------------------
+
+
+Setup
+--------------------------------------------------------------------------------
+

+ 260 - 0
psiphon/server/config.go

@@ -0,0 +1,260 @@
+/*
+ * 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_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
+)
+
+type Config struct {
+	ServerIPAddress      string
+	WebServerPort        int
+	WebServerSecret      string
+	WebServerCertificate string
+	WebServerPrivateKey  string
+	SSHPrivateKey        string
+	SSHServerVersion     string
+	SSHUserName          string
+	SSHPassword          string
+	SSHPort              int
+}
+
+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
+
+	return &config, nil
+}
+
+type GenerateConfigParams struct {
+	ServerIPAddress string
+	WebServerPort   int
+	SSHServerPort   int
+}
+
+func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, error) {
+
+	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"
+
+	config := &Config{
+		ServerIPAddress:      serverIPaddress,
+		WebServerPort:        webServerPort,
+		WebServerSecret:      webServerSecret,
+		WebServerCertificate: webServerCertificate,
+		WebServerPrivateKey:  webServerPrivateKey,
+		SSHPrivateKey:        string(sshPrivateKey),
+		SSHServerVersion:     sshServerVersion,
+		SSHUserName:          sshUserName,
+		SSHPassword:          sshPassword,
+		SSHPort:              sshServerPort,
+	}
+
+	encodedConfig, err := json.Marshal(config)
+	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], "")
+
+	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:    0,
+		SshObfuscatedKey:     "",
+		Capabilities:         []string{"SSH"},
+		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
+}

+ 84 - 0
psiphon/server/services.go

@@ -0,0 +1,84 @@
+/*
+ * 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"
+
+	log "github.com/Psiphon-Inc/logrus"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+)
+
+func RunServices(encodedConfig []byte) error {
+
+	config, err := LoadConfig(encodedConfig)
+	if err != nil {
+		log.Error("RunServices failed: %s", err)
+		return psiphon.ContextError(err)
+	}
+
+	// TODO: init logging
+
+	waitGroup := new(sync.WaitGroup)
+	shutdownBroadcast := make(chan struct{})
+	errors := make(chan error)
+
+	// TODO: optional services (e.g., run SSH only)
+
+	waitGroup.Add(1)
+	go func() {
+		defer waitGroup.Done()
+		err := RunWebServer(config, shutdownBroadcast)
+		select {
+		case errors <- err:
+		default:
+		}
+	}()
+
+	waitGroup.Add(1)
+	go func() {
+		defer waitGroup.Done()
+		err := RunSSH(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.Info("RunServices shutdown by system")
+	case err = <-errors:
+		log.Error("RunServices failed: %s", err)
+	}
+
+	close(shutdownBroadcast)
+	waitGroup.Wait()
+
+	return err
+}

+ 298 - 0
psiphon/server/sshService.go

@@ -0,0 +1,298 @@
+/*
+ * 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"
+	"fmt"
+	"io"
+	"net"
+	"sync"
+
+	log "github.com/Psiphon-Inc/logrus"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"golang.org/x/crypto/ssh"
+)
+
+type sshServer struct {
+	config          *Config
+	sshConfig       *ssh.ServerConfig
+	clientMutex     sync.Mutex
+	stoppingClients bool
+	clients         map[string]ssh.Conn
+}
+
+func RunSSH(config *Config, shutdownBroadcast <-chan struct{}) error {
+
+	sshServer := &sshServer{
+		config: config,
+	}
+
+	sshServer.sshConfig = &ssh.ServerConfig{
+		PasswordCallback: sshServer.passwordCallback,
+		AuthLogCallback:  sshServer.authLogCallback,
+		ServerVersion:    config.SSHServerVersion,
+	}
+
+	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.sshConfig.AddHostKey(signer)
+
+	listener, err := net.Listen(
+		"tcp", fmt.Sprintf("%s:%d", config.ServerIPAddress, config.SSHPort))
+	if err != nil {
+		return psiphon.ContextError(err)
+	}
+
+	log.Info("RunSSH: starting server")
+
+	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:
+				break loop
+			default:
+			}
+
+			if err != nil {
+				if e, ok := err.(net.Error); ok && e.Temporary() {
+					log.Warning("RunSSH accept error: %s", err)
+					// Temporary error, keep running
+					continue
+				}
+
+				select {
+				case errors <- psiphon.ContextError(err):
+				default:
+				}
+
+				break loop
+			}
+
+			// process each client connection concurrently
+			go sshServer.handleClient(conn)
+		}
+
+		sshServer.stopClients()
+
+		log.Info("RunSSH: server stopped")
+	}()
+
+	select {
+	case <-shutdownBroadcast:
+	case err = <-errors:
+	}
+
+	listener.Close()
+
+	waitGroup.Wait()
+
+	log.Info("RunSSH: exiting")
+
+	return err
+}
+
+func (sshServer *sshServer) 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()))
+	}
+
+	if conn.User() == sshServer.config.SSHUserName &&
+		sshPasswordPayload.SshPassword == sshServer.config.SSHPassword {
+		return nil, nil
+	}
+
+	return nil, psiphon.ContextError(fmt.Errorf("invalid password for %q", conn.User()))
+}
+
+func (sshServer *sshServer) authLogCallback(conn ssh.ConnMetadata, method string, err error) {
+	errMsg := "success"
+	if err != nil {
+		errMsg = err.Error()
+	}
+	log.Warning("ssh: %s authentication attempt %s", method, errMsg)
+}
+
+func (sshServer *sshServer) registerClient(sshConn ssh.Conn) bool {
+	sshServer.clientMutex.Lock()
+	defer sshServer.clientMutex.Unlock()
+	if sshServer.stoppingClients {
+		return false
+	}
+	existingSshConn := sshServer.clients[string(sshConn.SessionID())]
+	if existingSshConn != nil {
+		log.Warning("sshServer.registerClient: unexpected existing connection")
+		existingSshConn.Close()
+		existingSshConn.Wait()
+	}
+	sshServer.clients[string(sshConn.SessionID())] = sshConn
+	return true
+}
+
+func (sshServer *sshServer) unregisterClient(sshConn ssh.Conn) {
+	sshServer.clientMutex.Lock()
+	if sshServer.stoppingClients {
+		return
+	}
+	delete(sshServer.clients, string(sshConn.SessionID()))
+	sshServer.clientMutex.Unlock()
+	sshConn.Close()
+}
+
+func (sshServer *sshServer) stopClients() {
+	sshServer.clientMutex.Lock()
+	sshServer.stoppingClients = true
+	sshServer.clientMutex.Unlock()
+	for _, sshConn := range sshServer.clients {
+		sshConn.Close()
+		sshConn.Wait()
+	}
+}
+
+func (sshServer *sshServer) handleClient(conn net.Conn) {
+
+	// TODO: does this block on SSH handshake (so should be in goroutine)?
+	sshConn, channels, requests, err := ssh.NewServerConn(conn, sshServer.sshConfig)
+	if err != nil {
+		conn.Close()
+		log.Error("sshServer.handleClient: ssh establish connection failed: %s", err)
+		return
+	}
+
+	if !sshServer.registerClient(sshConn) {
+		sshConn.Close()
+		log.Error("sshServer.handleClient: failed to register client")
+		return
+	}
+	defer sshServer.unregisterClient(sshConn)
+
+	// TODO: don't record IP; do GeoIP
+	log.Info("connection from %s", sshConn.RemoteAddr())
+
+	go ssh.DiscardRequests(requests)
+
+	for newChannel := range channels {
+
+		if newChannel.ChannelType() != "direct-tcpip" {
+			sshServer.rejectNewChannel(newChannel, ssh.Prohibited, "unknown or unsupported channel type")
+			return
+		}
+
+		// process each port forward concurrently
+		go sshServer.handleNewDirectTcpipChannel(newChannel)
+	}
+}
+
+func (sshServer *sshServer) rejectNewChannel(newChannel ssh.NewChannel, reason ssh.RejectionReason, message string) {
+	// TODO: log more details?
+	log.Warning("ssh reject new channel: %s: %d: %s", newChannel.ChannelType(), reason, message)
+	newChannel.Reject(reason, message)
+}
+
+func (sshServer *sshServer) 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 {
+		sshServer.rejectNewChannel(newChannel, ssh.Prohibited, "invalid extra data")
+		return
+	}
+
+	targetAddr := fmt.Sprintf("%s:%d",
+		directTcpipExtraData.HostToConnect,
+		directTcpipExtraData.PortToConnect)
+
+	log.Debug("sshServer.handleNewDirectTcpipChannel: dialing %s", targetAddr)
+
+	// TODO: port forward dial timeout
+	// TODO: report ssh.ResourceShortage when appropriate
+	fwdConn, err := net.Dial("tcp", targetAddr)
+	if err != nil {
+		sshServer.rejectNewChannel(newChannel, ssh.ConnectionFailed, err.Error())
+		return
+	}
+	defer fwdConn.Close()
+
+	fwdChannel, requests, err := newChannel.Accept()
+	if err != nil {
+		log.Warning("sshServer.handleNewDirectTcpipChannel: accept new channel failed: %s", err)
+		return
+	}
+
+	log.Debug("sshServer.handleNewDirectTcpipChannel: relaying %s", targetAddr)
+
+	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()?
+
+	relayWaitGroup := new(sync.WaitGroup)
+	relayWaitGroup.Add(1)
+	go func() {
+		defer relayWaitGroup.Done()
+		_, err := io.Copy(fwdConn, fwdChannel)
+		if err != nil {
+			log.Warning("sshServer.handleNewDirectTcpipChannel: upstream relay failed: %s", err)
+		}
+	}()
+	_, err = io.Copy(fwdChannel, fwdConn)
+	if err != nil {
+		log.Warning("sshServer.handleNewDirectTcpipChannel: downstream relay failed: %s", err)
+	}
+	fwdChannel.CloseWrite()
+	relayWaitGroup.Wait()
+
+	log.Info("sshServer.handleNewDirectTcpipChannel: exiting %s", targetAddr)
+}

+ 240 - 0
psiphon/server/webService.go

@@ -0,0 +1,240 @@
+/*
+ * 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"
+
+	log "github.com/Psiphon-Inc/logrus"
+	"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: inherit global log config?
+	logWriter := log.StandardLogger().Writer()
+	defer logWriter.Close()
+
+	httpServer := &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.Info("RunWebServer: starting server")
+
+	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 := httpServer.Serve(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.Info("RunWebServer: server stopped")
+	}()
+
+	select {
+	case <-shutdownBroadcast:
+	case err = <-errors:
+	}
+
+	listener.Close()
+
+	waitGroup.Wait()
+
+	log.Info("RunWebServer: 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.Warning("handshakeHandler: checkWebServerSecret failed")
+		// TODO: psi_web returns NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.Info("handshake: %+v", r.URL.Query())
+
+	// 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.Warning("handshakeHandler: checkWebServerSecret failed")
+		// TODO: psi_web does NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.Info("connected: %+v", r.URL.Query())
+
+	// 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.Warning("handshakeHandler: checkWebServerSecret failed")
+		// TODO: psi_web does NotFound in this case
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// TODO: validate; proper log
+	log.Info("status: %+v", r.URL.Query())
+
+	// 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.Info("status payload: %s", string(requestBody))
+
+	w.WriteHeader(http.StatusOK)
+}

+ 17 - 0
psiphon/serverEntry.go

@@ -144,6 +144,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.
 //

+ 10 - 0
psiphon/utils.go

@@ -118,6 +118,16 @@ func MakeRandomPeriod(min, max time.Duration) (duration time.Duration) {
 	return
 }
 
+// MakeRandomString returns a base64 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 base64.RawStdEncoding.EncodeToString(bytes), nil
+}
+
 func DecodeCertificate(encodedCertificate string) (certificate *x509.Certificate, err error) {
 	derEncodedCertificate, err := base64.StdEncoding.DecodeString(encodedCertificate)
 	if err != nil {