From e451c05f614beb637ce83b721ff90b948fc3eb06 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Mon, 3 Jun 2024 17:13:03 -0400 Subject: [PATCH 1/4] Allow for black box config --- x/psiphon/psiphon.go | 181 ++++++++++---------------------------- x/psiphon/psiphon_test.go | 89 ++++++++----------- 2 files changed, 85 insertions(+), 185 deletions(-) diff --git a/x/psiphon/psiphon.go b/x/psiphon/psiphon.go index a581c1e6..e917670b 100644 --- a/x/psiphon/psiphon.go +++ b/x/psiphon/psiphon.go @@ -17,21 +17,17 @@ package psiphon import ( - "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" - "runtime" - "strings" "sync" "github.com/Jigsaw-Code/outline-sdk/transport" "github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib" psi "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon" - "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters" ) // The single [Dialer] we can have. @@ -45,130 +41,15 @@ var ( errTunnelTimeout = errors.New("tunnel establishment timed out") ) -// ClientInfo specifies information about the client app that should be communicated -// to Psiphon for authentication and metrics. -type ClientInfo struct { - // PropagationChannelId is a string identifier which indicates how the - // Psiphon client was distributed. This parameter is required. This value - // is supplied by and depends on the Psiphon Network. - PropagationChannelId string - - // SponsorId is a string identifier which indicates who is sponsoring this - // Psiphon client. This parameter is required. This value is supplied - // by and depends on the Psiphon Network. - SponsorId string - - // ClientVersion is the client version number that the client reports to - // the server. The version number refers to the host client application, - // not the core tunnel library. - ClientVersion string - - // ClientPlatform is the client platform ("Windows", "Android", etc.) that - // the client reports to the server. - ClientPlatform string -} - -// ServerListConfig specifies how Psiphon obtains server lists. -type ServerListConfig struct { - // ObfuscatedServerListRootURLs is a list of URLs which specify root - // locations from which to fetch obfuscated server list files. This value - // is supplied by and depends on the Psiphon Network, and is typically - // embedded in the client binary. All URLs must point to the same entity - // with the same ETag. At least one DownloadURL must have - // OnlyAfterAttempts = 0. - ObfuscatedServerListRootURLs parameters.TransferURLs - - // RemoteServerListURLs is list of URLs which specify locations to fetch - // out-of-band server entries. This facility is used when a tunnel cannot - // be established to known servers. This value is supplied by and depends - // on the Psiphon Network, and is typically embedded in the client binary. - // All URLs must point to the same entity with the same ETag. At least one - // TransferURL must have OnlyAfterAttempts = 0. - RemoteServerListURLs parameters.TransferURLs - - // RemoteServerListSignaturePublicKey specifies a public key that's used - // to authenticate the remote server list payload. This value is supplied - // by and depends on the Psiphon Network, and is typically embedded in the - // client binary. - RemoteServerListSignaturePublicKey string - - // ServerEntrySignaturePublicKey is a base64-encoded, ed25519 public - // key value used to verify individual server entry signatures. This value - // is supplied by and depends on the Psiphon Network, and is typically - // embedded in the client binary. - ServerEntrySignaturePublicKey string - - // TargetServerEntry is an encoded server entry. When specified, this - // server entry is used exclusively and all other known servers are - // ignored; also, when set, ConnectionWorkerPoolSize is ignored and - // the pool size is 1. - TargetServerEntry string -} - -// StorageConfig specifies where Psiphon should store its data. -type StorageConfig struct { - // DataRootDirectory is the directory in which to store persistent files, - // which contain information such as server entries. By default, current - // working directory. - // - // Psiphon will assume full control of files under this directory. They may - // be deleted, moved or overwritten. +type DialerConfig struct { + // Used as the directory for the datastore, remote server list, and obfuscasted + // server list. + // Empty string means the default will be used (current working directory). + // Strongly recommended. DataRootDirectory string -} - -// Config specifies how the Psiphon dialer should behave. -type Config struct { - ClientInfo - ServerListConfig - StorageConfig -} - -// extendedConfig allows us to evaluate some settings that are present in the Psiphon config, -// but the user shouldn't be specifying. -type extendedConfig struct { - Config - - // DisableLocalHTTPProxy disables running the local HTTP proxy. - DisableLocalHTTPProxy *bool - // DisableLocalSocksProxy disables running the local SOCKS proxy. - DisableLocalSocksProxy *bool - // TargetApiProtocol specifies whether to force use of "ssh" or "web" API - // protocol. When blank, the default, the optimal API protocol is used. - // Note that this capability check is not applied before the - // "CandidateServers" count is emitted. - // - // This parameter is intended for testing and debugging only. Not all - // parameters are supported in the legacy "web" API protocol, including - // speed test samples. - TargetApiProtocol *string -} - -// ParseConfig parses the config JSON into a structure that can be further edited. -func ParseConfig(configJSON []byte) (*Config, error) { - var extCfg extendedConfig - - // Set default values - extCfg.ClientPlatform = strings.ReplaceAll(fmt.Sprintf("OutlineSDK_%s_%s", runtime.GOOS, runtime.GOARCH), " ", "_") - - decoder := json.NewDecoder(bytes.NewReader(configJSON)) - decoder.DisallowUnknownFields() - err := decoder.Decode(&extCfg) - if err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) - } - - // We ignore these fields in the config, but only if they are set to the proper values. - if extCfg.DisableLocalHTTPProxy != nil && !*extCfg.DisableLocalHTTPProxy { - return nil, fmt.Errorf("DisableLocalHTTPProxy must be true if set") - } - if extCfg.DisableLocalSocksProxy != nil && !*extCfg.DisableLocalSocksProxy { - return nil, fmt.Errorf("DisableLocalSocksProxy must be true if set") - } - if extCfg.TargetApiProtocol != nil && *extCfg.TargetApiProtocol != "ssh" { - return nil, fmt.Errorf(`TargetApiProtocol must be "ssh" if set`) - } - return &extCfg.Config, nil + // Raw JSON config provided by Psiphon. + ProviderConfig json.RawMessage } // Dialer is a [transport.StreamDialer] that uses Psiphon to connect to a destination. @@ -208,21 +89,51 @@ func (d *Dialer) DialStream(unusedContext context.Context, addr string) (transpo return streamConn{netConn}, nil } -// Start configures and runs the Dialer. It must be called before you can use the Dialer. It returns when the tunnel is ready. -func (d *Dialer) Start(ctx context.Context, config *Config) error { - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to convert config to JSON") +func newPsiphonConfig(config *DialerConfig) (*psi.Config, error) { + if config == nil { + return nil, errors.New("config must not be nil") } - pConfig, err := psi.LoadConfig(configJSON) + // Validate keys. We parse as a map first because we need to check for the existence + // of certain keys. + var configMap map[string]interface{} + if err := json.Unmarshal(config.ProviderConfig, &configMap); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + for key, value := range configMap { + switch key { + case "DisableLocalHTTPProxy", "DisableLocalSocksProxy": + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("field %v is not a boolean", key) + } + if b != true { + return nil, fmt.Errorf("field %v must be true if set", key) + } + case "DataRootDirectory": + return nil, errors.New("field DataRootDirectory must not be set in the provider config. Specify it in the DialerConfig instead.") + } + } + + // Parse provider config. + pConfig, err := psi.LoadConfig(config.ProviderConfig) if err != nil { - return fmt.Errorf("config load failed: %w", err) + return nil, fmt.Errorf("config load failed: %w", err) } - // Override some Psiphon defaults. + // Force some Psiphon config defaults for the Outline SDK case. pConfig.DisableLocalHTTPProxy = true pConfig.DisableLocalSocksProxy = true - pConfig.TargetApiProtocol = "ssh" + pConfig.DataRootDirectory = config.DataRootDirectory + + return pConfig, nil +} + +// Start configures and runs the Dialer. It must be called before you can use the Dialer. It returns when the tunnel is ready. +func (d *Dialer) Start(ctx context.Context, config *DialerConfig) error { + pConfig, err := newPsiphonConfig(config) + if err != nil { + return err + } // Will receive a value if an error occurs during the connection sequence. // It will be closed on succesful connection. diff --git a/x/psiphon/psiphon_test.go b/x/psiphon/psiphon_test.go index a231cb5a..bd970a83 100644 --- a/x/psiphon/psiphon_test.go +++ b/x/psiphon/psiphon_test.go @@ -18,9 +18,8 @@ package psiphon import ( "context" - "fmt" + "encoding/json" "io" - "runtime" "testing" "time" @@ -29,61 +28,44 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseConfig_ParseCorrectly(t *testing.T) { - config, err := ParseConfig([]byte(`{ - "PropagationChannelId": "ID1", - "SponsorId": "ID2" - }`)) +func TestNewPsiphonConfig_ParseCorrectly(t *testing.T) { + config, err := newPsiphonConfig(&DialerConfig{ + ProviderConfig: json.RawMessage(`{ + "PropagationChannelId": "ID1", + "SponsorId": "ID2" + }`), + }) require.NoError(t, err) require.Equal(t, "ID1", config.PropagationChannelId) require.Equal(t, "ID2", config.SponsorId) } -func TestParseConfig_DefaultClientPlatform(t *testing.T) { - config, err := ParseConfig([]byte(`{}`)) - require.NoError(t, err) - require.Equal(t, fmt.Sprintf("OutlineSDK_%v_%v", runtime.GOOS, runtime.GOARCH), config.ClientPlatform) -} - -func TestParseConfig_OverrideClientPlatform(t *testing.T) { - config, err := ParseConfig([]byte(`{"ClientPlatform": "win"}`)) - require.NoError(t, err) - require.Equal(t, "win", config.ClientPlatform) -} - -func TestParseConfig_AcceptOkOptions(t *testing.T) { - _, err := ParseConfig([]byte(`{ +func TestNewPsiphonConfig_AcceptOkOptions(t *testing.T) { + _, err := newPsiphonConfig(&DialerConfig{ + ProviderConfig: json.RawMessage(`{ "DisableLocalHTTPProxy": true, - "DisableLocalSocksProxy": true, - "TargetApiProtocol": "ssh" - }`)) + "DisableLocalSocksProxy": true + }`)}) require.NoError(t, err) } -func TestParseConfig_RejectBadOptions(t *testing.T) { - _, err := ParseConfig([]byte(`{"DisableLocalHTTPProxy": false}`)) +func TestNewPsiphonConfig_RejectBadOptions(t *testing.T) { + _, err := newPsiphonConfig(&DialerConfig{ + ProviderConfig: json.RawMessage(`{"DisableLocalHTTPProxy": false}`)}) require.Error(t, err) - _, err = ParseConfig([]byte(`{"DisableLocalSocksProxy": false}`)) + _, err = newPsiphonConfig(&DialerConfig{ + ProviderConfig: json.RawMessage(`{"DisableLocalSocksProxy": false}`)}) require.Error(t, err) - - _, err = ParseConfig([]byte(`{"TargetApiProtocol": "web"}`)) - require.Error(t, err) -} - -func TestParseConfig_RejectUnknownFields(t *testing.T) { - _, err := ParseConfig([]byte(`{ - "PropagationChannelId": "ID", - "UknownField": false - }`)) require.Error(t, err) } func TestDialer_StartSuccessful(t *testing.T) { // Create minimal config. - cfg := &Config{} - cfg.PropagationChannelId = "test" - cfg.SponsorId = "test" + cfg := &DialerConfig{ProviderConfig: json.RawMessage(`{ + "PropagationChannelId": "test", + "SponsorId": "test" + }`)} // Intercept notice writer. dialer := GetSingletonDialer() @@ -102,10 +84,15 @@ func TestDialer_StartSuccessful(t *testing.T) { errCh <- dialer.Start(ctx, cfg) }() - // Notify fake tunnel establishment. - w := <-wCh - psi.SetNoticeWriter(w) - psi.NoticeTunnels(1) + // We use a select because the error may happen before the notice writer is set. + select { + case w := <-wCh: + // Notify fake tunnel establishment once we have the notice writer. + psi.SetNoticeWriter(w) + psi.NoticeTunnels(1) + case err := <-errCh: + t.Fatalf("Got error from Start: %v", err) + } err := <-errCh require.NoError(t, err) @@ -113,9 +100,10 @@ func TestDialer_StartSuccessful(t *testing.T) { } func TestDialerStart_Cancelled(t *testing.T) { - cfg := &Config{} - cfg.PropagationChannelId = "test" - cfg.SponsorId = "test" + cfg := &DialerConfig{ProviderConfig: json.RawMessage(`{ + "PropagationChannelId": "test", + "SponsorId": "test" + }`)} errCh := make(chan error) ctx, cancel := context.WithCancel(context.Background()) go func() { @@ -127,9 +115,10 @@ func TestDialerStart_Cancelled(t *testing.T) { } func TestDialerStart_Timeout(t *testing.T) { - cfg := &Config{} - cfg.PropagationChannelId = "test" - cfg.SponsorId = "test" + cfg := &DialerConfig{ProviderConfig: json.RawMessage(`{ + "PropagationChannelId": "test", + "SponsorId": "test" + }`)} errCh := make(chan error) ctx, cancel := context.WithDeadline(context.Background(), time.Now()) defer cancel() From 1b370241bca97138848f3e29e6a762a221d774c4 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Mon, 3 Jun 2024 17:19:40 -0400 Subject: [PATCH 2/4] change to field blocklist --- x/examples/fetch-psiphon/main.go | 5 +---- x/psiphon/psiphon.go | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/x/examples/fetch-psiphon/main.go b/x/examples/fetch-psiphon/main.go index eee3c1bd..695dd60f 100644 --- a/x/examples/fetch-psiphon/main.go +++ b/x/examples/fetch-psiphon/main.go @@ -67,10 +67,7 @@ func main() { if err != nil { log.Fatalf("Could not read config file: %v\n", err) } - config, err := psiphon.ParseConfig(configJSON) - if err != nil { - log.Fatalf("Failed to load Psiphon config: %v\n", err) - } + config := &psiphon.DialerConfig{ProviderConfig: configJSON} cacheBaseDir, err := os.UserCacheDir() if err != nil { log.Fatalf("Failed to get the user cache directory: %v", err) diff --git a/x/psiphon/psiphon.go b/x/psiphon/psiphon.go index e917670b..31ff7b74 100644 --- a/x/psiphon/psiphon.go +++ b/x/psiphon/psiphon.go @@ -41,6 +41,7 @@ var ( errTunnelTimeout = errors.New("tunnel establishment timed out") ) +// DialerConfig specifies the parameters for [Dialer]. type DialerConfig struct { // Used as the directory for the datastore, remote server list, and obfuscasted // server list. From 31fa7b2ed523f3e8358e821317a19f52fe2805a8 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Mon, 3 Jun 2024 17:29:03 -0400 Subject: [PATCH 3/4] Fix doc --- x/psiphon/build_tag.go | 21 +++++++++++++++++++++ x/psiphon/doc.go | 2 ++ x/psiphon/psiphon.go | 2 -- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 x/psiphon/build_tag.go diff --git a/x/psiphon/build_tag.go b/x/psiphon/build_tag.go new file mode 100644 index 00000000..81fefa9e --- /dev/null +++ b/x/psiphon/build_tag.go @@ -0,0 +1,21 @@ +// Copyright 2024 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build psiphon + +package psiphon + +// Fake variable used in the package to require the build tag, without preventing the rest of the code +// from being processed by the Go documentation service. +var mustSetPsiphonBuildTag = struct{}{} diff --git a/x/psiphon/doc.go b/x/psiphon/doc.go index a28d1481..5ee5d3d8 100644 --- a/x/psiphon/doc.go +++ b/x/psiphon/doc.go @@ -38,3 +38,5 @@ To prevent accidental inclusion of unvetted licenses, you must use the "psiphon" [generate a Psiphon config yourself]: https://github.com/Psiphon-Labs/psiphon-tunnel-core/tree/master?tab=readme-ov-file#generate-configuration-data */ package psiphon + +var _ = mustSetPsiphonBuildTag diff --git a/x/psiphon/psiphon.go b/x/psiphon/psiphon.go index 31ff7b74..7a070816 100644 --- a/x/psiphon/psiphon.go +++ b/x/psiphon/psiphon.go @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build psiphon - package psiphon import ( From 8544d475d196ef54311116450fdec2e82a9c4473 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Mon, 3 Jun 2024 17:37:20 -0400 Subject: [PATCH 4/4] Tweak --- x/psiphon/psiphon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/psiphon/psiphon.go b/x/psiphon/psiphon.go index 7a070816..5f357fda 100644 --- a/x/psiphon/psiphon.go +++ b/x/psiphon/psiphon.go @@ -103,7 +103,7 @@ func newPsiphonConfig(config *DialerConfig) (*psi.Config, error) { case "DisableLocalHTTPProxy", "DisableLocalSocksProxy": b, ok := value.(bool) if !ok { - return nil, fmt.Errorf("field %v is not a boolean", key) + return nil, fmt.Errorf("field %v must be a boolean", key) } if b != true { return nil, fmt.Errorf("field %v must be true if set", key)