diff --git a/app/cmd/client.go b/app/cmd/client.go index f1243f1..e060805 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -4,11 +4,13 @@ import ( "crypto/x509" "errors" "net" + "net/url" "os" "strings" "sync" "time" + "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap" @@ -20,6 +22,11 @@ import ( "github.com/apernet/hysteria/extras/obfs" ) +// Client flags +var ( + showQR bool +) + var clientCmd = &cobra.Command{ Use: "client", Short: "Client mode", @@ -27,9 +34,14 @@ var clientCmd = &cobra.Command{ } func init() { + initClientFlags() rootCmd.AddCommand(clientCmd) } +func initClientFlags() { + clientCmd.Flags().BoolVar(&showQR, "qr", false, "show QR code for server config sharing") +} + type clientConfig struct { Server string `mapstructure:"server"` Auth string `mapstructure:"auth"` @@ -84,17 +96,15 @@ type forwardingEntry struct { UDPTimeout time.Duration `mapstructure:"udpTimeout"` } -// Config validates the fields and returns a ready-to-use Hysteria client config -func (c *clientConfig) Config() (*client.Config, error) { - hyConfig := &client.Config{} - // ConnFactory +func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error { switch strings.ToLower(c.Obfs.Type) { case "", "plain": // Default, do nothing + return nil case "salamander": ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) if err != nil { - return nil, configError{Field: "obfs.salamander.password", Err: err} + return configError{Field: "obfs.salamander.password", Err: err} } hyConfig.ConnFactory = &obfsConnFactory{ NewFunc: func(addr net.Addr) (net.PacketConn, error) { @@ -102,41 +112,55 @@ func (c *clientConfig) Config() (*client.Config, error) { }, Obfuscator: ob, } + return nil default: - return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} + return configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } - // ServerAddr +} + +func (c *clientConfig) fillServerAddr(hyConfig *client.Config) error { if c.Server == "" { - return nil, configError{Field: "server", Err: errors.New("server address is empty")} + return configError{Field: "server", Err: errors.New("server address is empty")} } host, hostPort := parseServerAddrString(c.Server) addr, err := net.ResolveUDPAddr("udp", hostPort) if err != nil { - return nil, configError{Field: "server", Err: err} + return configError{Field: "server", Err: err} } hyConfig.ServerAddr = addr - // Auth - hyConfig.Auth = c.Auth - // TLSConfig + // Special handling for SNI if c.TLS.SNI == "" { // Use server hostname as SNI hyConfig.TLSConfig.ServerName = host - } else { + } + return nil +} + +func (c *clientConfig) fillAuth(hyConfig *client.Config) error { + hyConfig.Auth = c.Auth + return nil +} + +func (c *clientConfig) fillTLSConfig(hyConfig *client.Config) error { + if c.TLS.SNI != "" { hyConfig.TLSConfig.ServerName = c.TLS.SNI } hyConfig.TLSConfig.InsecureSkipVerify = c.TLS.Insecure if c.TLS.CA != "" { ca, err := os.ReadFile(c.TLS.CA) if err != nil { - return nil, configError{Field: "tls.ca", Err: err} + return configError{Field: "tls.ca", Err: err} } cPool := x509.NewCertPool() if !cPool.AppendCertsFromPEM(ca) { - return nil, configError{Field: "tls.ca", Err: errors.New("failed to parse CA certificate")} + return configError{Field: "tls.ca", Err: errors.New("failed to parse CA certificate")} } hyConfig.TLSConfig.RootCAs = cPool } - // QUICConfig + return nil +} + +func (c *clientConfig) fillQUICConfig(hyConfig *client.Config) error { hyConfig.QUICConfig = client.QUICConfig{ InitialStreamReceiveWindow: c.QUIC.InitStreamReceiveWindow, MaxStreamReceiveWindow: c.QUIC.MaxStreamReceiveWindow, @@ -146,24 +170,76 @@ func (c *clientConfig) Config() (*client.Config, error) { KeepAlivePeriod: c.QUIC.KeepAlivePeriod, DisablePathMTUDiscovery: c.QUIC.DisablePathMTUDiscovery, } - // BandwidthConfig + return nil +} + +func (c *clientConfig) fillBandwidthConfig(hyConfig *client.Config) error { if c.Bandwidth.Up == "" || c.Bandwidth.Down == "" { - return nil, configError{Field: "bandwidth", Err: errors.New("both up and down bandwidth must be set")} + return configError{Field: "bandwidth", Err: errors.New("both up and down bandwidth must be set")} } + var err error hyConfig.BandwidthConfig.MaxTx, err = convBandwidth(c.Bandwidth.Up) if err != nil { - return nil, configError{Field: "bandwidth.up", Err: err} + return configError{Field: "bandwidth.up", Err: err} } hyConfig.BandwidthConfig.MaxRx, err = convBandwidth(c.Bandwidth.Down) if err != nil { - return nil, configError{Field: "bandwidth.down", Err: err} + return configError{Field: "bandwidth.down", Err: err} } - // FastOpen - hyConfig.FastOpen = c.FastOpen + return nil +} +func (c *clientConfig) fillFastOpen(hyConfig *client.Config) error { + hyConfig.FastOpen = c.FastOpen + return nil +} + +// Config validates the fields and returns a ready-to-use Hysteria client config +func (c *clientConfig) Config() (*client.Config, error) { + hyConfig := &client.Config{} + fillers := []func(*client.Config) error{ + c.fillConnFactory, + c.fillServerAddr, + c.fillAuth, + c.fillTLSConfig, + c.fillQUICConfig, + c.fillBandwidthConfig, + c.fillFastOpen, + } + for _, f := range fillers { + if err := f(hyConfig); err != nil { + return nil, err + } + } 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") @@ -185,6 +261,17 @@ func runClient(cmd *cobra.Command, args []string) { } defer c.Close() + uri := config.ShareURI() + logger.Info("use this URI to share your server", zap.String("uri", uri)) + if showQR { + qrterminal.GenerateWithConfig(uri, qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + }) + } + // Modes var wg sync.WaitGroup hasMode := false diff --git a/app/cmd/server.go b/app/cmd/server.go index efe981c..b7ddff4 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -87,49 +87,50 @@ type serverConfigACME struct { Dir string `mapstructure:"dir"` } -// Config validates the fields and returns a ready-to-use Hysteria server config -func (c *serverConfig) Config() (*server.Config, error) { - hyConfig := &server.Config{} - // Conn +func (c *serverConfig) fillConn(hyConfig *server.Config) error { listenAddr := c.Listen if listenAddr == "" { listenAddr = ":443" } uAddr, err := net.ResolveUDPAddr("udp", listenAddr) if err != nil { - return nil, configError{Field: "listen", Err: err} + return configError{Field: "listen", Err: err} } conn, err := net.ListenUDP("udp", uAddr) if err != nil { - return nil, configError{Field: "listen", Err: err} + return configError{Field: "listen", Err: err} } switch strings.ToLower(c.Obfs.Type) { case "", "plain": hyConfig.Conn = conn + return nil case "salamander": ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) if err != nil { - return nil, configError{Field: "obfs.salamander.password", Err: err} + return configError{Field: "obfs.salamander.password", Err: err} } hyConfig.Conn = obfs.WrapPacketConn(conn, ob) + return nil default: - return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} + return configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } - // TLSConfig +} + +func (c *serverConfig) fillTLSConfig(hyConfig *server.Config) error { if c.TLS == nil && c.ACME == nil { - return nil, configError{Field: "tls", Err: errors.New("must set either tls or acme")} + return configError{Field: "tls", Err: errors.New("must set either tls or acme")} } if c.TLS != nil && c.ACME != nil { - return nil, configError{Field: "tls", Err: errors.New("cannot set both tls and acme")} + return configError{Field: "tls", Err: errors.New("cannot set both tls and acme")} } if c.TLS != nil { // Local TLS cert if c.TLS.Cert == "" || c.TLS.Key == "" { - return nil, configError{Field: "tls", Err: errors.New("empty cert or key path")} + return configError{Field: "tls", Err: errors.New("empty cert or key path")} } cert, err := tls.LoadX509KeyPair(c.TLS.Cert, c.TLS.Key) if err != nil { - return nil, configError{Field: "tls", Err: err} + return configError{Field: "tls", Err: err} } hyConfig.TLSConfig.Certificates = []tls.Certificate{cert} } else { @@ -160,7 +161,7 @@ func (c *serverConfig) Config() (*server.Config, error) { case "zerossl", "zero": cmIssuer.CA = certmagic.ZeroSSLProductionCA default: - return nil, configError{Field: "acme.ca", Err: errors.New("unknown CA")} + return configError{Field: "acme.ca", Err: errors.New("unknown CA")} } cmCfg.Issuers = []certmagic.Issuer{cmIssuer} cmCache := certmagic.NewCache(certmagic.CacheOptions{ @@ -172,15 +173,18 @@ func (c *serverConfig) Config() (*server.Config, error) { cmCfg = certmagic.New(cmCache, *cmCfg) if len(c.ACME.Domains) == 0 { - return nil, configError{Field: "acme.domains", Err: errors.New("empty domains")} + return configError{Field: "acme.domains", Err: errors.New("empty domains")} } - err = cmCfg.ManageSync(context.Background(), c.ACME.Domains) + err := cmCfg.ManageSync(context.Background(), c.ACME.Domains) if err != nil { - return nil, configError{Field: "acme.domains", Err: err} + return configError{Field: "acme.domains", Err: err} } hyConfig.TLSConfig.GetCertificate = cmCfg.GetCertificate } - // QUICConfig + return nil +} + +func (c *serverConfig) fillQUICConfig(hyConfig *server.Config) error { hyConfig.QUICConfig = server.QUICConfig{ InitialStreamReceiveWindow: c.QUIC.InitStreamReceiveWindow, MaxStreamReceiveWindow: c.QUIC.MaxStreamReceiveWindow, @@ -190,52 +194,70 @@ func (c *serverConfig) Config() (*server.Config, error) { MaxIncomingStreams: c.QUIC.MaxIncomingStreams, DisablePathMTUDiscovery: c.QUIC.DisablePathMTUDiscovery, } - // BandwidthConfig + return nil +} + +func (c *serverConfig) fillBandwidthConfig(hyConfig *server.Config) error { + var err error if c.Bandwidth.Up != "" { hyConfig.BandwidthConfig.MaxTx, err = convBandwidth(c.Bandwidth.Up) if err != nil { - return nil, configError{Field: "bandwidth.up", Err: err} + return configError{Field: "bandwidth.up", Err: err} } } if c.Bandwidth.Down != "" { hyConfig.BandwidthConfig.MaxRx, err = convBandwidth(c.Bandwidth.Down) if err != nil { - return nil, configError{Field: "bandwidth.down", Err: err} + return configError{Field: "bandwidth.down", Err: err} } } - // DisableUDP + return nil +} + +func (c *serverConfig) fillDisableUDP(hyConfig *server.Config) error { hyConfig.DisableUDP = c.DisableUDP - // Authenticator + return nil +} + +func (c *serverConfig) fillAuthenticator(hyConfig *server.Config) error { if c.Auth.Type == "" { - return nil, configError{Field: "auth.type", Err: errors.New("empty auth type")} + return configError{Field: "auth.type", Err: errors.New("empty auth type")} } switch strings.ToLower(c.Auth.Type) { case "password": if c.Auth.Password == "" { - return nil, configError{Field: "auth.password", Err: errors.New("empty auth password")} + return configError{Field: "auth.password", Err: errors.New("empty auth password")} } hyConfig.Authenticator = &auth.PasswordAuthenticator{Password: c.Auth.Password} + return nil default: - return nil, configError{Field: "auth.type", Err: errors.New("unsupported auth type")} + return configError{Field: "auth.type", Err: errors.New("unsupported auth type")} } - // EventLogger +} + +func (c *serverConfig) fillEventLogger(hyConfig *server.Config) error { hyConfig.EventLogger = &serverLogger{} - // MasqHandler + return nil +} + +func (c *serverConfig) fillMasqHandler(hyConfig *server.Config) error { switch strings.ToLower(c.Masquerade.Type) { case "", "404": hyConfig.MasqHandler = http.NotFoundHandler() + return nil case "file": if c.Masquerade.File.Dir == "" { - return nil, configError{Field: "masquerade.file.dir", Err: errors.New("empty file directory")} + return configError{Field: "masquerade.file.dir", Err: errors.New("empty file directory")} } hyConfig.MasqHandler = http.FileServer(http.Dir(c.Masquerade.File.Dir)) + return nil case "proxy": if c.Masquerade.Proxy.URL == "" { - return nil, configError{Field: "masquerade.proxy.url", Err: errors.New("empty proxy url")} + return configError{Field: "masquerade.proxy.url", Err: errors.New("empty proxy url")} } u, err := url.Parse(c.Masquerade.Proxy.URL) if err != nil { - return nil, configError{Field: "masquerade.proxy.url", Err: err} + return configError{Field: "masquerade.proxy.url", Err: err} } hyConfig.MasqHandler = &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { @@ -251,8 +273,29 @@ func (c *serverConfig) Config() (*server.Config, error) { w.WriteHeader(http.StatusBadGateway) }, } + return nil default: - return nil, configError{Field: "masquerade.type", Err: errors.New("unsupported masquerade type")} + return configError{Field: "masquerade.type", Err: errors.New("unsupported masquerade type")} + } +} + +// Config validates the fields and returns a ready-to-use Hysteria server config +func (c *serverConfig) Config() (*server.Config, error) { + hyConfig := &server.Config{} + fillers := []func(*server.Config) error{ + c.fillConn, + c.fillTLSConfig, + c.fillQUICConfig, + c.fillBandwidthConfig, + c.fillDisableUDP, + c.fillAuthenticator, + c.fillEventLogger, + c.fillMasqHandler, + } + for _, f := range fillers { + if err := f(hyConfig); err != nil { + return nil, err + } } return hyConfig, nil } diff --git a/app/go.mod b/app/go.mod index 8fa344a..8c2a16f 100644 --- a/app/go.mod +++ b/app/go.mod @@ -6,6 +6,7 @@ require ( github.com/apernet/hysteria/core v0.0.0-00010101000000-000000000000 github.com/apernet/hysteria/extras v0.0.0-00010101000000-000000000000 github.com/caddyserver/certmagic v0.17.2 + github.com/mdp/qrterminal/v3 v3.1.1 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.15.0 github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301 @@ -49,6 +50,7 @@ require ( golang.org/x/tools v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) replace github.com/quic-go/quic-go => github.com/apernet/quic-go v0.36.1-0.20230627042819-0a89ea8e4c8d diff --git a/app/go.sum b/app/go.sum index 127182f..9bb71bb 100644 --- a/app/go.sum +++ b/app/go.sum @@ -154,6 +154,8 @@ github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdp/qrterminal/v3 v3.1.1 h1:cIPwg3QU0OIm9+ce/lRfWXhPwEjOSKwk3HBwL3HBTyc= +github.com/mdp/qrterminal/v3 v3.1.1/go.mod h1:5lJlXe7Jdr8wlPDdcsJttv1/knsRgzXASyr4dcGZqNU= github.com/mholt/acmez v1.0.4 h1:N3cE4Pek+dSolbsofIkAYz6H1d3pE+2G0os7QHslf80= github.com/mholt/acmez v1.0.4/go.mod h1:qFGLZ4u+ehWINeJZjzPlsnjJBCPAADWTcIqE/7DAYQY= github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo= @@ -572,5 +574,7 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/extras/go.mod b/extras/go.mod index 59a7f61..0082763 100644 --- a/extras/go.mod +++ b/extras/go.mod @@ -2,7 +2,10 @@ module github.com/apernet/hysteria/extras go 1.20 -require github.com/apernet/hysteria/core v0.0.0-00010101000000-000000000000 +require ( + github.com/apernet/hysteria/core v0.0.0-00010101000000-000000000000 + golang.org/x/crypto v0.4.0 +) require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect @@ -13,7 +16,6 @@ require ( github.com/quic-go/qtls-go1-19 v0.3.2 // indirect github.com/quic-go/qtls-go1-20 v0.2.2 // indirect github.com/quic-go/quic-go v0.0.0-00010101000000-000000000000 // indirect - golang.org/x/crypto v0.4.0 // indirect golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.10.0 // indirect diff --git a/go.work.sum b/go.work.sum index e7a6873..4e42e3a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -27,7 +27,9 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=