Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(x): Parse psiphon config format as part of smart-proxy #394

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

ohnorobo
Copy link
Contributor

@ohnorobo ohnorobo commented Mar 25, 2025

This is not yet setting up the psiphon connection, but I figured it would be important to hash out exactly what format we want to use in config.yaml for the fallbacks since this is modifying the format slightly from #384.

I've broken the parsing of the config out into parseConfig to deal with the string and object parsing.

Don't worry too much about the json parsing/logging around :331. It will go away in the next PR. I just wanted to make sure this parsing approach would work for getting from YAML->rawJson without actually putting the format validation here.

Tested

go run -C ./x/examples/smart-proxy/ . -v -localAddr=localhost:1080 --transport="" --domain rt.com  --config=[...]/outline-sdk/x/examples/smart-proxy/config_broken.yaml
Finding strategy
...
[DNS/TLS output omitted]
...
🏃 running test: 'ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTprSzdEdHQ0MkJLOE9hRjBKYjdpWGFK@1.2.3.4:9999/?outline=1' (domain: rt.com.)
❌ Psiphon is not yet supported, skipping: {"psiphon":{"LocalHttpProxyPort":8081,"LocalSocksProxyPort":1081,"PropagationChannelId":"FFFFFFFFFFFFFFFF","RemoteServerListDownloadFilename":"remote_server_list","RemoteServerListSignaturePublicKey":"MIICIDANBgkqhkiG9w0BAQEFAAOCAg0AMIICCAKCAgEAt7Ls+/39r+T6zNW7GiVpJfzq/xvL9SBH5rIFnk0RXYEYavax3WS6HOD35eTAqn8AniOwiH+DOkvgSKF2caqk/y1dfq47Pdymtwzp9ikpB1C5OfAysXzBiwVJlCdajBKvBZDerV1cMvRzCKvKwRmvDmHgphQQ7WfXIGbRbmmk6opMBh3roE42KcotLFtqp0RRwLtcBRNtCdsrVsjiI1Lqz/lH+T61sGjSjQ3CHMuZYSQJZo/KrvzgQXpkaCTdbObxHqb6/+i1qaVOfEsvjoiyzTxJADvSytVtcTjijhPEV6XskJVHE1Zgl+7rATr/pDQkw6DPCNBS1+Y6fy7GstZALQXwEDN/qhQI9kWkHijT8ns+i1vGg00Mk/6J75arLhqcodWsdeG/M/moWgqQAnlZAGVtJI1OgeF5fsPpXu4kctOfuZlGjVZXQNW34aOzm8r8S0eVZitPlbhcPiR4gT/aSMz/wd8lZlzZYsje/Jr8u/YtlwjjreZrGRmG8KMOzukV3lLmMppXFMvl4bxv6YFEmIuTsOhbLTwFgh7KYNjodLj/LsqRVfwz31PgWQFTEPICV7GCvgVlPRxnofqKSjgTWI4mxDhBpVcATvaoBl1L/6WLbFvBsoAUBItWwctO2xalKxF5szhGm8lccoc5MZr8kfE0uxMgsxz4er68iCID+rsCAQM=","RemoteServerListUrl":"https://s3.amazonaws.com//psiphon/web/mjr4-p23r-puwl/server_list_compressed","SponsorId":"FFFFFFFFFFFFFFFF","UseIndistinguishableTLS":true}}
🏃 running test: 'socks5://192.168.1.10:1080' (domain: rt.com.)
2025/03/25 17:08:00 Failed to find dialer: could not find a working fallback: all tests failed
exit status 1

Also tested a failing psiphon config followed by a successful outline server.

@ohnorobo ohnorobo force-pushed the psiphon-fallback branch 2 times, most recently from 4394b98 to 7a8888e Compare March 25, 2025 12:49
@ohnorobo ohnorobo requested a review from fortuna March 25, 2025 13:00
@ohnorobo
Copy link
Contributor Author

ohnorobo commented Mar 25, 2025

Alright, I made an attempt at changing for format. But I don't particularly like it. It seems like it introduces a lot of complication in parsing the config.

  • Kludging around any types instead of being able to rely on reasonable type safety from the yaml unmarshal
  • now there's parsing logic in findFallback instead of just business logic
  • confusing errors message if you write the config wrong (I mis-edited the config while testing, and my configurls started getting parsed as psiphon configs.) I think this kind of error will happen more in the future as we make yet-unknown changes to this config.
  • we no longer have the psiphon key in the config, which I think makes it easier to read for someone who doesn't already know what the big json chunk is.

All for the dubious pleasure of mixing strings and objects in a list. You're sure it wouldn't be cleaner just to switch to a keyed map while there's no one relying on this?

I do really like just copying the json from the psiphon config directly. Seems much nicer.

@ohnorobo ohnorobo requested a review from fortuna March 25, 2025 15:01

return &SearchResult{dialer, fallbackConfig}, nil
case map[string]interface{}:
psiphonJSON, err := json.Marshal(v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need a switch here based on what type field was set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a check for the string, but I'm not sure I'm understanding what you mean.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to do the same we do for DNS, like:

if cfg := entry.Psiphon; cfg != nil {
  // Process Psiphon config
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doing it like

type psiphonEntryConfig struct {
	Psiphon any	`yaml:"psiphon,omitempty"`
}

...

psiphonCfg, ok := fallbackConfig.(psiphonEntryConfig)

doesn't succeed. ok = false and it fails to coerce the type.

case psiphonEntryConfig:

also fails

The point of the map[string]interface{} type was to get enough of a toehold on the type that I'm able to extract the child object.

Attempting to call fallbackConfig.Psiphon fails to build because it's not certain the field exists.

Because there's no union type yaml.Unmarshal can't do all the work for us of mapping the yaml into structs while we're mixing structs and strings.

We could force the conversion here with something like https://pkg.go.dev/reflect, but that seems heavyweight when we're just trying to check and will be passing on to the psiphon lib in a moment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That type should be called something else, since it should allow other types. Example:

type fallbackEntryConfig struct {
  Psiphon any
  FooBar SomeType
}

To parse, you can change the list entry to be ast.Node (from github.com/goccy/go-yaml).

Then you can use NodeToValue.

Or do what we do in the client with mapToAny.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

broke out a parseConfig function that parses strings and objects directly from the ast. I would love ideas on how to make this part shorter though.

@ohnorobo ohnorobo requested a review from fortuna March 25, 2025 16:57

return &SearchResult{dialer, fallbackConfig}, nil
case map[string]interface{}:
psiphonJSON, err := json.Marshal(v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to extract the config from the psiphon field, like we do for DNS.
Perhaps define a fallbackEntryConfig with the Psiphon: any field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


return &SearchResult{dialer, fallbackConfig}, nil
case map[string]interface{}:
psiphonJSON, err := json.Marshal(v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to do the same we do for DNS, like:

if cfg := entry.Psiphon; cfg != nil {
  // Process Psiphon config
}

@ohnorobo ohnorobo requested a review from fortuna March 26, 2025 17:41

return &SearchResult{dialer, fallbackConfig}, nil
case map[string]interface{}:
psiphonJSON, err := json.Marshal(v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That type should be called something else, since it should allow other types. Example:

type fallbackEntryConfig struct {
  Psiphon any
  FooBar SomeType
}

To parse, you can change the list entry to be ast.Node (from github.com/goccy/go-yaml).

Then you can use NodeToValue.

Or do what we do in the client with mapToAny.

@ohnorobo ohnorobo changed the base branch from main to update-yaml March 27, 2025 11:16
@ohnorobo ohnorobo requested a review from fortuna March 27, 2025 13:41
Base automatically changed from update-yaml to main March 27, 2025 14:41
return &SearchResult{dialer, fallbackUrl}, nil
return &SearchResult{dialer, fallbackConfig}, nil
case fallbackEntryStructConfig:
psiphonCfg := v.Psiphon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please test if Psiphon is not nil. I want the Psiphon code to be in it's own block. Think about how it would look with more types.
Also, you shouldn't call Marshall on nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return nil, fmt.Errorf("getStreamDialer failed: %w", err)
}
fallback, err := raceTests(ctx, 250*time.Millisecond, fallbackConfigs, func(fallbackConfig fallbackEntryConfig) (*SearchResult, error) {
switch v := fallbackConfig.(type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parsing logic probably deserves a unit test.

Copy link
Contributor Author

@ohnorobo ohnorobo Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think this specifically and the selection logic in general would benefit from more unit testing. I'm also a bit nervous about the correctness of where we're returning errors vs logging error output.

To do it as a unit test will require some refactoring though, since currently the network calls are intertwined. Can I do it in a follow up?

I did add a few tests, but just for ParseConfig, since it's currently well isolated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that may help for testing is replacing the base dialers (though we can't do that for Psiphon). But that can be done some other time.

@@ -365,14 +394,58 @@ func (f *StrategyFinder) newProxylessDialer(ctx context.Context, testDomains []s
return f.findTLS(ctx, testDomains, dnsDialer, config.TLS)
}

func (f *StrategyFinder) parseConfig(configBytes []byte) (configConfig, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you doing this manual parsing instead of unmarshalling into the output directly?

Copy link
Contributor Author

@ohnorobo ohnorobo Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got rid of the manual parsing and switched this to use mapToAny + Unmarshal.

I've just copied mapToAny in wholesale from outline-apps. I figure we'll want it anyway eventually. But let me know if you'd like me to put it somewhere other than this file in anticipation of eventually moving over more code.

I've also added a bit of unit testing, partially just to enumerate the allowed config fields in a single readable place besides x/examples/smart_proxy.

@ohnorobo ohnorobo force-pushed the psiphon-fallback branch 2 times, most recently from 4cd69e3 to 58822c9 Compare April 1, 2025 14:37
@ohnorobo ohnorobo requested a review from fortuna April 1, 2025 14:48
@ohnorobo ohnorobo marked this pull request as ready for review April 1, 2025 14:52
return nil, fmt.Errorf("getStreamDialer failed: %w", err)
}
fallback, err := raceTests(ctx, 250*time.Millisecond, fallbackConfigs, func(fallbackConfig fallbackEntryConfig) (*SearchResult, error) {
switch v := fallbackConfig.(type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that may help for testing is replacing the base dialers (though we can't do that for Psiphon). But that can be done some other time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants