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(client): introduce extensible YAML config in Go #2306

Open
wants to merge 49 commits into
base: master
Choose a base branch
from

Conversation

fortuna
Copy link
Collaborator

@fortuna fortuna commented Dec 9, 2024

This PR moves the config parsing from Typescript to Go. The new implementation supports an extensible YAML format that is backwards-compatible with the two formats we supported (legacy SS JSON and SS URL).

The migration of parsing from Typescript to Go forced the functions to be changed to async, which cascades on a number of changes up the stack all the way to main. Constructors had to be replaced with async factory functions and web components had to have async updates. I also had to hunt unhandled promise rejections at run time, since Typescript is unable to detect that.

TODO:

  • Replace Typescript tests with Go tests
  • Cleanup Endpoint code to use ExtensibleProvider now that it supports dispatching based on config type
  • Define final format

@fortuna fortuna changed the title chore(client): introduce extensible config chore(client): introduce extensible YAML config Dec 9, 2024
@fortuna fortuna changed the title chore(client): introduce extensible YAML config chore(client): introduce extensible YAML config in Go Dec 9, 2024
@fortuna fortuna changed the title chore(client): introduce extensible YAML config in Go feat(client): introduce extensible YAML config in Go Jan 6, 2025
@fortuna fortuna requested review from sbruens and jyyi1 January 6, 2025 18:48
@fortuna fortuna marked this pull request as ready for review January 6, 2025 18:49
@fortuna fortuna requested review from a team as code owners January 6, 2025 18:49
@fortuna
Copy link
Collaborator Author

fortuna commented Jan 6, 2025

I still need to fix the Electron test, but I think we can start the review process.

Copy link
Contributor

@sbruens sbruens left a comment

Choose a reason for hiding this comment

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

Overall looks great. I think the config package could use some more liberal commenting on exported types (e.g. in module.go), but I'll leave it up to you which ones to focus the reader's attention on.

}
yamlText, err := yaml.Marshal(newMap)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

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

It may be beneficial to format this error since you can also run into errors with decoding below. That will make it easier to debug later:

	if err != nil {
		return fmt.Errorf("error marshaling to YAML: %w", err)
	}
    ...
	if err := decoder.Decode(out); err != nil {
		return fmt.Errorf("error decoding YAML: %w", err)
	}
	return nil

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

}
newMap[k] = v
}
yamlText, err := yaml.Marshal(newMap)
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the overhead of marshaling and then immediately unmarshalling below? That seems somewhat inefficient.

What's the purpose of this marshal/unmarshal step? Is it just to filter out fields? I think you may be able to use reflection to create the out struct directly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is going from a map to a struct. Yes, I could use reflect, but it's not that straightforward. I decided to leverage the YAML code to save time.

I'm not worried about performance here, since it's a relatively small object and this is only done at config parsing time.

"github.com/stretchr/testify/require"
)

/*
Copy link
Contributor

Choose a reason for hiding this comment

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

Delete these commented out tests?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

"github.com/stretchr/testify/require"
)

// TODO:
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these tests not already working? They are the main way to see how this module can be used, so it will be helpful for these tests to be live and comprehensive so they can be self-documenting.

Or are these what you meant by "need to fix electron tests"?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

They are working now. Readded

Copy link
Contributor

@daniellacosse daniellacosse left a comment

Choose a reason for hiding this comment

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

Looks good! Just a few thoughts/observations

)

func Test_NewTransport_SS_URL(t *testing.T) {
config := "ss://[email protected]:4321/"
Copy link
Contributor

Choose a reason for hiding this comment

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

might want to test multiple new-line separated ss keys, too

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We don't support multiple keys yet.


const ConfigParserKey = "$parser"

type ConfigNode any
Copy link
Contributor

Choose a reason for hiding this comment

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

is it not possible to define an interface for this? the parser library doesn't provide one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not really. It can be anything.

return decoder.Decode(out)
}

// TypeParser creates objects of the given type T from an input config.
Copy link
Contributor

Choose a reason for hiding this comment

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

are these objects ConfigNodes or not necessarily?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In this case they are always map, since we can only specify a subparser in a map.

},
ConnectionProviderInfo: dialer.ConnectionProviderInfo,
}
if dialer.ConnType == ConnTypeDirect {
Copy link
Contributor

Choose a reason for hiding this comment

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

Sightly confused, is this a different type of Direct? if not, should this method fail to parse if it isn't?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If the connection is direct from the dialer, then we use the address we are dialing.
If the connection is tunneled, then we use the firstHop from the Dialer.

return;
}
this.loadServersV0();
async internalCreateServer(
Copy link
Contributor

Choose a reason for hiding this comment

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

why remove private? because it's only at compile time?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's needed in loadServersV* and OutlineServerRepository is not exported.

Copy link
Contributor

Choose a reason for hiding this comment

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

also internal doesn't add any information to the function, maybe createAndRegisterServer

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It conveys the idea that this is not supposed to be used as an external API. It's for internal use only.

readonly tunnelConfigLocation: URL;
private displayAddress: string;
private readonly staticTunnelConfig?: TunnelConfigJson;
export async function newOutlineServer(
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I prefer make to new to more explicitly call out that you're using a factory function and not a constructor, but I'm ambivalent

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

New is more aligned with the factory methods in Go. I believe this will be more consistent. Also, we use new in TS to create objects.

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.

Starting with the TypeScript review first. The PR is pretty big, is it feasible to separate it platform-by-platform?

infrastructure/net.ts Show resolved Hide resolved
infrastructure/net.ts Show resolved Hide resolved
try {
return pluginExecWithErrorCode('invokeMethod', methodName, params);
} catch (e) {
console.debug('invokeMethod failed', methodName, e);
Copy link
Contributor

Choose a reason for hiding this comment

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

error instead of debug? Also, are you planning to catch the error of "initiating" the promise or "during" the promise? This will decide whether we need to add await here.

Suggested change
console.debug('invokeMethod failed', methodName, e);
console.error('invokeMethod failed', methodName, e);

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think this should be errors, because the calls may fail for various reasons (like failed to parse the config in the key validation). This is really for debugging. The caller should decide whether to log.

go.mod Outdated
@@ -1,6 +1,6 @@
module github.com/Jigsaw-Code/outline-apps

go 1.21
go 1.22
Copy link
Contributor

Choose a reason for hiding this comment

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

Are there any go 1.22 features we are using in this PR? If not, maybe we can separate the PR since it's already pretty big.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I thought there was, but I guess not. Reverted.

@@ -154,10 +154,12 @@ class OutlinePlugin: CDVPlugin {
DDLogInfo("Invoking Method \(methodName) with input \(input)")
Task {
guard let result = OutlineInvokeMethod(methodName, input) else {
DDLogInfo("InvokeMethod \(methodName) got nil result")
Copy link
Contributor

Choose a reason for hiding this comment

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

Error instead of Info?

Suggested change
DDLogInfo("InvokeMethod \(methodName) got nil result")
DDLogError("InvokeMethod \(methodName) got nil result")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated

return self.sendError("unexpected invoke error", callbackId: command.callbackId)
}
if result.error != nil {
let errorJson = marshalErrorJson(error: OutlineError.platformError(result.error!))
DDLogInfo("InvokeMethod \(methodName) failed with error \(errorJson)")
Copy link
Contributor

Choose a reason for hiding this comment

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

Warning ? Considering it might be user errors.

Suggested change
DDLogInfo("InvokeMethod \(methodName) failed with error \(errorJson)")
DDLogWarn("InvokeMethod \(methodName) failed with error \(errorJson)")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I disagree with this one. The invoked method may fail for various reasons (e.g. connectivity) and it's not necessarily something to report in the logs here. It's up to the caller to decide.
I had this to debug the calls during development.

@@ -105,13 +112,22 @@ export class AddAccessKeyDialog extends LitElement {
</md-text-button>
<md-filled-button
@click=${this.handleConfirm}
?disabled=${!this.accessKey || !this.isValidAccessKey(this.accessKey)}
?disabled=${!this.accessKey || !this.isValidAccessKey}
Copy link
Contributor

Choose a reason for hiding this comment

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

The validation is async, will the user be able to click this button even if the key is invalid during validation? Take an example of the following sequence:

Key is valid, <Button Enabled>, but user wants to change the key
Paste in the new (invalid) key
Validating ...
<Button still Enabled>    <= is the user able to click the button here?
Validation done, invalid key!
<Button Disabled>

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

They will, but the add will fail.

@@ -478,7 +480,7 @@ export class App {
}
}
try {
config.validateAccessKey(accessKey);
config.parseAccessKey(accessKey);
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 need to await here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, updated

storage,
localize
);
await loadServers(storage, repo);
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the only async code in the constructor, maybe we can add a static function newAndLoadServers() { ... } in OutlineServerRepository, and then marks the OutlineServerRepository.constructor to private. We can still leave other synchronized code in the constructor. I feel this would better encapsulate the creation logic compared to the existing implementation (a caller can call newOutlineServerRepository or new OutlineServerRepository() because it is not private).

export class OutlineServerRepository implements ServerRepository {
  private constructor(...) {
    // All existing code except for loadServers
  }

  static async createAndLoadServers(...) {
    const repo = new OutlineServerRepository(...);
    await loadServers(storage, repo);
    return repo;
  }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think there's a misunderstanding. OutlineServerRepository is no longer exported. So you can't call the constructor outside this file. newOutlineServerRepository is the only way to create the repo.

accessKey: string,
localize: Localizer
): Promise<Server> {
const serviceConfig = await parseAccessKey(accessKey);
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to the comment in OutlineServerRepository, I'd prefer a private constructor of the OutlineServer class and a static async parseKeyAndCreate() in the class.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same comment. The class is no longer exported, so you must use the factory function.

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.

First round of the Go code review

@@ -12,11 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {SHADOWSOCKS_URI} from 'ShadowsocksConfig';
import * as method_channel from '@outline/client/src/www/app/method_channel';
Copy link
Contributor

Choose a reason for hiding this comment

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

https://google.github.io/styleguide/tsguide.html#identifiers-imports

Suggested change
import * as method_channel from '@outline/client/src/www/app/method_channel';
import * as methodChannel from '@outline/client/src/www/app/method_channel';

ConnTypeTunneled
)

// ConnProviderConfig represents a dialer or endpoint that can create connections.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this comment still valid?

// RegisterSubParser registers the given subparser function with the given name for the type T.
// Note that a subparser always take a map[string]any, not ConfigNode, since we must have a map[string]any in
// order to set the value for the ConfigParserKey.
func (p *TypeParser[T]) RegisterSubParser(name string, function func(context.Context, map[string]any) (T, error)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This function type is used in several places, shall we define a dedicated type for it?

@@ -109,82 +77,44 @@ export function setTransportConfigHost(
* This is used by the server to parse the config fetched from the dynamic key, and to parse
* static keys as tunnel configs (which may be present in the dynamic config).
*/
export function parseTunnelConfig(
export async function parseTunnelConfig(
Copy link
Contributor

Choose a reason for hiding this comment

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

The user-provided dynamic key error is removed here, are we still supporting this feature? You can use the following key to test, expect to see "Usage quota exceeded 😭😭😭":

https://raw.githubusercontent.com/jyyi1/jyyibin/refs/heads/main/keys/error_msg.okey#Dynamic%20Error%20Msg

)

// TransportPair provides a StreamDialer and PacketListener, to use as the transport in a Tun2Socks VPN.
type TransportPair 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 this type? Why not just returning (*Dialer[transport.StreamConn], *PacketListener, error) in all functions?

proxyAddress := net.JoinHostPort(host, fmt.Sprint(port))

cryptoKey, err := shadowsocks.NewEncryptionKey(cipherName, password)
func newClientWithBaseDialers(transportConfig string, tcpDialer transport.StreamDialer, udpDialer transport.PacketDialer) (*Client, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We are changing the UDP handler from a PacketListener to a PacketDialer here, will this cause any unexpected errors?

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.

This change is a huge leap forward! Can we split the PR into at least two to make it smaller and easier to review?

  1. The async validate access keys, this can be extracted first without adding the YAML support
  2. Adding YAML to the format (may be further splited if possible)

The YAML parsing logic feels complicated, especially the definitions of **Endpoint **Dialer, Listener, not sure whether we can simplify it. Probably providing the users with a documentation would be useful (this could be done once we migrate the code to SDK).

"cipher": "chacha20-ietf-poly1305",
"secret": "SECRET",
"prefix": "outline-123",
"extra": "NOT SUPPORTED",
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 need to fail on extra fields? We can simply ignore them.


streamEndpoints = NewTypeParser(func(ctx context.Context, input ConfigNode) (*Endpoint[transport.StreamConn], error) {
// TODO: perhaps only support string here to force the struct to have an explicit parser.
return parseDirectDialerEndpoint(ctx, input, streamDialers.Parse)
Copy link
Contributor

Choose a reason for hiding this comment

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

So this is the fallback dialer if the user doesn't specify the parser type?

provider := newTestProvider()

node, err := ParseConfigYAML(`
$type: ss
Copy link
Contributor

Choose a reason for hiding this comment

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

$parser or $type?


// TypeParser creates objects of the given type T from an input config.
// You can register type-specific sub-parsers that get called when marked in the config.
// The default value is not valid. Use [NewTypeParser] instead.
Copy link
Contributor

Choose a reason for hiding this comment

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

If the default value is not valid, the recommended way in Go is to expose an interface instead.

return node, nil
}

// mapToAny marshalls a map into a struct. It's a helper for parsers that want to
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel decodeMapToStruct would be a better name?


cryptoKey, err := shadowsocks.NewEncryptionKey(cipherName, password)
func newClientWithBaseDialers(transportConfig string, tcpDialer transport.StreamDialer, udpDialer transport.PacketDialer) (*Client, error) {
transportYAML, err := config.ParseConfigYAML(transportConfig)
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we still accepting the old JSON format here? Existing dynamic keys are still using JSON.

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

Successfully merging this pull request may close these issues.

4 participants