-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
base: master
Are you sure you want to change the base?
Conversation
I still need to fix the Electron test, but I think we can start the review process. |
There was a problem hiding this 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.
client/go/outline/config/config.go
Outdated
} | ||
yamlText, err := yaml.Marshal(newMap) | ||
if err != nil { | ||
return err |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
client/go/outline/config/config.go
Outdated
} | ||
newMap[k] = v | ||
} | ||
yamlText, err := yaml.Marshal(newMap) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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" | ||
) | ||
|
||
/* |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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"?
There was a problem hiding this comment.
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
There was a problem hiding this 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/" |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
client/go/outline/config/config.go
Outdated
|
||
const ConfigParserKey = "$parser" | ||
|
||
type ConfigNode any |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
client/go/outline/config/config.go
Outdated
return decoder.Decode(out) | ||
} | ||
|
||
// TypeParser creates objects of the given type T from an input config. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are these objects ConfigNode
s or not necessarily?
There was a problem hiding this comment.
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.
client/go/outline/config/endpoint.go
Outdated
}, | ||
ConnectionProviderInfo: dialer.ConnectionProviderInfo, | ||
} | ||
if dialer.ConnType == ConnTypeDirect { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this 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?
try { | ||
return pluginExecWithErrorCode('invokeMethod', methodName, params); | ||
} catch (e) { | ||
console.debug('invokeMethod failed', methodName, e); |
There was a problem hiding this comment.
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.
console.debug('invokeMethod failed', methodName, e); | |
console.error('invokeMethod failed', methodName, e); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error instead of Info?
DDLogInfo("InvokeMethod \(methodName) got nil result") | |
DDLogError("InvokeMethod \(methodName) got nil result") |
There was a problem hiding this comment.
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)") |
There was a problem hiding this comment.
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.
DDLogInfo("InvokeMethod \(methodName) failed with error \(errorJson)") | |
DDLogWarn("InvokeMethod \(methodName) failed with error \(errorJson)") |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
client/src/www/app/app.ts
Outdated
@@ -478,7 +480,7 @@ export class App { | |||
} | |||
} | |||
try { | |||
config.validateAccessKey(accessKey); | |||
config.parseAccessKey(accessKey); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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;
}
}
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this 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'; |
There was a problem hiding this comment.
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
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. |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this 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?
- The
async
validate access keys, this can be extracted first without adding the YAML support - 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", |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
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: