From 07b7f14bef4c790d20fad826e930e61433a5a1b0 Mon Sep 17 00:00:00 2001 From: tobyxdd Date: Fri, 14 Jul 2023 16:47:10 -0700 Subject: [PATCH] feat: client config URI support --- app/cmd/client.go | 100 ++++++++++++++++++++++++++++++----------- app/cmd/client_test.go | 77 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/app/cmd/client.go b/app/cmd/client.go index 0131db4..e65bf38 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -6,6 +6,7 @@ import ( "net" "net/url" "os" + "strconv" "strings" "sync" "time" @@ -193,8 +194,79 @@ func (c *clientConfig) fillFastOpen(hyConfig *client.Config) error { return nil } +// URI generates a URI for sharing the config with others. +// Note that only the bare minimum of information required to +// connect to the server is included in the URI, specifically: +// - server address +// - authentication +// - obfuscation type +// - obfuscation password +// - TLS SNI +// - TLS insecure +func (c *clientConfig) URI() string { + q := url.Values{} + switch strings.ToLower(c.Obfs.Type) { + case "salamander": + q.Set("obfs", "salamander") + q.Set("obfs-password", c.Obfs.Salamander.Password) + } + if c.TLS.SNI != "" { + q.Set("sni", c.TLS.SNI) + } + if c.TLS.Insecure { + q.Set("insecure", "1") + } + var user *url.Userinfo + if c.Auth != "" { + user = url.User(c.Auth) + } + u := url.URL{ + Scheme: "hysteria2", + User: user, + Host: c.Server, + Path: "/", + RawQuery: q.Encode(), + } + return u.String() +} + +// parseURI tries to parse the server address field as a URI, +// and fills the config with the information contained in the URI. +// Returns whether the server address field is a valid URI. +// This allows a user to use put a URI as the server address and +// omit the fields that are already contained in the URI. +func (c *clientConfig) parseURI() bool { + u, err := url.Parse(c.Server) + if err != nil { + return false + } + if u.Scheme != "hysteria2" && u.Scheme != "hy2" { + return false + } + if u.User != nil { + c.Auth = u.User.String() + } + c.Server = u.Host + q := u.Query() + if obfsType := q.Get("obfs"); obfsType != "" { + c.Obfs.Type = obfsType + switch strings.ToLower(obfsType) { + case "salamander": + c.Obfs.Salamander.Password = q.Get("obfs-password") + } + } + if sni := q.Get("sni"); sni != "" { + c.TLS.SNI = sni + } + if insecure, err := strconv.ParseBool(q.Get("insecure")); err == nil { + c.TLS.Insecure = insecure + } + return true +} + // Config validates the fields and returns a ready-to-use Hysteria client config func (c *clientConfig) Config() (*client.Config, error) { + c.parseURI() hyConfig := &client.Config{} fillers := []func(*client.Config) error{ c.fillConnFactory, @@ -213,32 +285,6 @@ func (c *clientConfig) Config() (*client.Config, error) { return hyConfig, nil } -// ShareURI generates a URI for sharing the config with others. -// Note that only the fields necessary for a client to connect to the server are included. -// It doesn't include local modes, for example. -func (c *clientConfig) ShareURI() string { - q := url.Values{} - switch strings.ToLower(c.Obfs.Type) { - case "salamander": - q.Set("obfs", "salamander") - q.Set("obfs-password", c.Obfs.Salamander.Password) - } - if c.TLS.SNI != "" { - q.Set("sni", c.TLS.SNI) - } - if c.TLS.Insecure { - q.Set("insecure", "1") - } - u := url.URL{ - Scheme: "hysteria2", - User: url.User(c.Auth), - Host: c.Server, - Path: "/", - RawQuery: q.Encode(), - } - return u.String() -} - func runClient(cmd *cobra.Command, args []string) { logger.Info("client mode") @@ -260,7 +306,7 @@ func runClient(cmd *cobra.Command, args []string) { } defer c.Close() - uri := config.ShareURI() + uri := config.URI() logger.Info("use this URI to share your server", zap.String("uri", uri)) if showQR { printQR(uri) diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index 5e74b06..f5ad852 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -98,3 +98,80 @@ func TestClientConfig(t *testing.T) { t.Fatal("parsed client config is not equal to expected") } } + +// TestClientConfigURI tests URI-related functions of clientConfig +func TestClientConfigURI(t *testing.T) { + tests := []struct { + uri string + uriOK bool + config *clientConfig + }{ + { + uri: "hysteria2://god@zilla.jp/", + uriOK: true, + config: &clientConfig{ + Server: "zilla.jp", + Auth: "god", + }, + }, + { + uri: "hysteria2://noauth.com/?insecure=1&obfs=salamander&obfs-password=66ccff&sni=crap.cc", + uriOK: true, + config: &clientConfig{ + Server: "noauth.com", + Auth: "", + Obfs: struct { + Type string `mapstructure:"type"` + Salamander struct { + Password string `mapstructure:"password"` + } `mapstructure:"salamander"` + }{ + Type: "salamander", + Salamander: struct { + Password string `mapstructure:"password"` + }{ + Password: "66ccff", + }, + }, + TLS: struct { + SNI string `mapstructure:"sni"` + Insecure bool `mapstructure:"insecure"` + CA string `mapstructure:"ca"` + }{ + SNI: "crap.cc", + Insecure: true, + }, + }, + }, + { + uri: "invalid.bs", + uriOK: false, + config: nil, + }, + { + uri: "https://www.google.com/search?q=test", + uriOK: false, + config: nil, + }, + } + for _, test := range tests { + t.Run(test.uri, func(t *testing.T) { + // Test parseURI + nc := &clientConfig{Server: test.uri} + if ok := nc.parseURI(); ok != test.uriOK { + t.Fatal("unexpected parseURI ok result") + } + if test.uriOK && !reflect.DeepEqual(nc, test.config) { + t.Fatal("unexpected parsed client config from URI") + } + // Test URI generation + if test.config == nil { + // config is nil if parseURI is expected to fail + return + } + if uri := test.config.URI(); uri != test.uri { + t.Fatalf("generated URI mismatch: %s != %s", uri, test.uri) + } + }) + } +}