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): make config support PacketListeners and make dependencies explicit and decoupled #304

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

Conversation

fortuna
Copy link
Contributor

@fortuna fortuna commented Oct 28, 2024

Properly Support PacketListeners in the Config

As I design the advanced config for Outline, I ran into the issue that we need to support PacketListeners for both the Outline Client and to use QUIC. This PR allows PacketListeners to have a nested config like we have for dialers. I also added support to register types.

Explicit and decouples dependencies

The factory functions used to take as input factory functions for stream and packet dialers, regardless whether they needed them or not. This made dependencies unclear and not enforceable at compile time.

It also doesn't scale well, as more advanced strategies may need dependencies of different types. With the introduction of PacketListeners, I already ran into that issue. However, instead of adding a PacketListener factory to the factory function, I simplified the registration function to only take the context and config. The extra dependencies are passed explicitly then you create the factory function. This ensures compile checks, and allows us to inject any dependency we want, without needing to change the API.

Include Context

I now pass a Context to factory functions. This will allow us to inject deadlines or cancel creation for dialers that take long (e.g. Smart Dialer, Psiphon). It may also allow us to inject a structure for "named dialers" in the future.

@fortuna fortuna requested a review from jyyi1 October 28, 2024 22:25
@fortuna
Copy link
Contributor Author

fortuna commented Oct 28, 2024

BTW, I believe it's possible to reduce some of the duplication with Generics. I'll explore that in parallel, but I wanted to advance this PR regardless of that.

@fortuna
Copy link
Contributor Author

fortuna commented Oct 28, 2024

BTW, I am not changing the config format yet, but I introduced a Config object. That can encapsulate the intermediate format for the future config.

The flow is:
string -> intermediate/generic Config -> actual type config.

@fortuna
Copy link
Contributor Author

fortuna commented Oct 29, 2024

FYI, example QUIC support using this change: #305

@fortuna
Copy link
Contributor Author

fortuna commented Oct 29, 2024

Changed the code to use Generics and save coding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note how I'm separating this module file.

I envision the config.go living in the root SDK, since it's generic and simple, with minimal dependencies, and this config module being in the x folder, since it pulls all sorts of dependencies to implement all protocols.

Once the config lives in the main module, we can also consider adding config packages to each transport in the future too, to make it easy to register them.

Copy link
Contributor

@jyyi1 jyyi1 left a comment

Choose a reason for hiding this comment

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

Thanks for the refactoring, it is great!

_ TypeRegistry[any] = (*ExtensibleProvider[any])(nil)
)

func (p *ExtensibleProvider[ObjectType]) buildersMap() map[string]BuildFunc[ObjectType] {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe call it ensureBuildersMap since it has side effect to the object?

Suggested change
func (p *ExtensibleProvider[ObjectType]) buildersMap() map[string]BuildFunc[ObjectType] {
func (p *ExtensibleProvider[ObjectType]) ensureBuildersMap() map[string]BuildFunc[ObjectType] {

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 p
// ExtensibleProvider creates instances of ObjectType in a way that can be extended via its [TypeRegistry] interface.
type ExtensibleProvider[ObjectType comparable] struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need the comparable constraint here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, because we compare it against the zero instance in NewInstance.

func (p *ExtensibleProvider[ObjectType]) RegisterType(subtype string, newInstance BuildFunc[ObjectType]) error {
builders := p.buildersMap()
if _, found := builders[subtype]; found {
return fmt.Errorf("type %v registered twice", subtype)
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can replace the old entry? This might be useful in some "override" scenarios.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Changed accordingly.

p.RegisterStreamDialerType("ws", wrapStreamDialerWithWebSocket)
p.RegisterPacketDialerType("ws", wrapPacketDialerWithWebSocket)
// TypeRegistry registers config types.
type TypeRegistry[ObjectType any] interface {
Copy link
Contributor

Choose a reason for hiding this comment

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

T would be good enough, since ObjectType doesn't add anything more meaningful than T?

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 find ObjectType clearer than simply T, and it reads better in the comment as well.

}

if _, found := p.pdBuilders[subtype]; found {
return fmt.Errorf("config parser %v for StreamDialer added twice", subtype)
newDialer, ok := p.buildersMap()[config.URL.Scheme]
Copy link
Contributor

Choose a reason for hiding this comment

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

To be consistent with the name in RegisterType.

Suggested change
newDialer, ok := p.buildersMap()[config.URL.Scheme]
newInstance, ok := p.buildersMap()[config.URL.Scheme]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, leftover from the refactoring. Renamed.

func TestTLS_SNI(t *testing.T) {
tlsURL, err := url.Parse("tls:sni=www.google.com")
tlsURL, err := ParseConfig("tls:sni=www.google.com")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
tlsURL, err := ParseConfig("tls:sni=www.google.com")
config, err := ParseConfig("tls:sni=www.google.com")

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

@@ -0,0 +1,39 @@
// Copyright 2023 The Outline Authors
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Copyright 2023 The Outline Authors
// Copyright 2024 The Outline Authors

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

return nil, err
}
if closer, ok := sd.(io.Closer); ok {
defer closer.Close()
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 we closing the sd here? It will be passed to dns.NewStreamDialer below, will it be invalid after closing?

I think we may just need to close it when we return a non-nil error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed.

I was playing with the concept of cleaning up. We will need to add some mechanism for cleanup, or else we might leak resources. But I'll leave that to a future PR.

return nil, err
}
if closer, ok := pd.(io.Closer); ok {
defer closer.Close()
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same

// TODO: accept an inner dialer from the caller and pass it to UDPEndpoint
ep := &transport.UDPEndpoint{Address: config.serverAddress}
return shadowsocks.NewPacketListener(ep, config.cryptoKey)
func registerShadowsocksPacketListener(r TypeRegistry[transport.PacketListener], typeID string, newPD BuildFunc[transport.PacketDialer]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for this PR, but I think we can replace all PacketListener with the PacketDialer for Shadowsocks in the future. Are we doing anything special in the PacketListener?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maaaaybe. That's something to be investigated, and it may increase the server capacity. But it may also break NAT traversal, since it will tie the UDP socket to a single destination, preventing the local address from receiving packets from other addresses not yet contacted. This can in turn break some communication tools.

Copy link
Contributor Author

@fortuna fortuna left a comment

Choose a reason for hiding this comment

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

Another look?

p.RegisterStreamDialerType("ws", wrapStreamDialerWithWebSocket)
p.RegisterPacketDialerType("ws", wrapPacketDialerWithWebSocket)
// TypeRegistry registers config types.
type TypeRegistry[ObjectType any] interface {
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 find ObjectType clearer than simply T, and it reads better in the comment as well.


return p
// ExtensibleProvider creates instances of ObjectType in a way that can be extended via its [TypeRegistry] interface.
type ExtensibleProvider[ObjectType comparable] struct {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, because we compare it against the zero instance in NewInstance.

func (p *ExtensibleProvider[ObjectType]) RegisterType(subtype string, newInstance BuildFunc[ObjectType]) error {
builders := p.buildersMap()
if _, found := builders[subtype]; found {
return fmt.Errorf("type %v registered twice", subtype)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Changed accordingly.

}

if _, found := p.pdBuilders[subtype]; found {
return fmt.Errorf("config parser %v for StreamDialer added twice", subtype)
newDialer, ok := p.buildersMap()[config.URL.Scheme]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, leftover from the refactoring. Renamed.

Configurable transports simplifies the way you create and manage transports.
With the config package, you can use [NewPacketDialer] and [NewStreamDialer] to create dialers using a simple text string.
Configurable strategies simplifies the way you create and manage strategies.
With the configurl package, you can use [NewPacketDialer], [NewStreamDialer] and [NewPacketListener] to create objects using a simple text string.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated it to refer to the ConfigModule functions.

@@ -0,0 +1,39 @@
// Copyright 2023 The Outline Authors
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated


// ConfigModule enables the creation of network objects based on a config. The config is
// extensible by registering providers for config subtypes.
type ConfigModule struct {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not really "Transports". It could be anything. I may need to include dns.Resolvers there, Stream/Packet Endpoints, or http.Clients. So it's really generic.

We can't use both Factory and Provider. They effectively mean the same thing. We could call it something Provider, but it's not clear what "something" should be, and Provider collides with the basic Provider type we already have.

I'm renaming it to ProviderContainer. What do you think?

I also created some auxiliary functions to make it more flexible.

_ TypeRegistry[any] = (*ExtensibleProvider[any])(nil)
)

func (p *ExtensibleProvider[ObjectType]) buildersMap() map[string]BuildFunc[ObjectType] {
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, err
}
if closer, ok := sd.(io.Closer); ok {
defer closer.Close()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed.

I was playing with the concept of cleaning up. We will need to add some mechanism for cleanup, or else we might leak resources. But I'll leave that to a future PR.

return nil, err
}
if closer, ok := pd.(io.Closer); ok {
defer closer.Close()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same

@fortuna fortuna requested a review from jyyi1 October 30, 2024 22:38
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