diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 7a7c523..e5689e7 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.24" - name: Setup Python # This is for the build script uses: actions/setup-python@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70710c0..a966d86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.24" - name: Setup Python # This is for the build script uses: actions/setup-python@v5 diff --git a/app/LICENSE.md b/app/LICENSE.md new file mode 100644 index 0000000..208e8f2 --- /dev/null +++ b/app/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2023 Toby + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app/cmd/client.go b/app/cmd/client.go index 65bc7e0..05fec80 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -470,8 +470,10 @@ func runClient(cmd *cobra.Command, args []string) { defer c.Close() uri := config.URI() - logger.Info("use this URI to share your server", zap.String("uri", uri)) if showQR { + logger.Warn("--qr flag is deprecated and will be removed in future release, " + + "please use `share` subcommand to generate share URI and QR code") + logger.Info("use this URI to share your server", zap.String("uri", uri)) utils.PrintQR(uri) } diff --git a/app/cmd/root.go b/app/cmd/root.go index cc7f39f..13f9705 100644 --- a/app/cmd/root.go +++ b/app/cmd/root.go @@ -29,20 +29,24 @@ const ( var ( // These values will be injected by the build system - appVersion = "Unknown" - appDate = "Unknown" - appType = "Unknown" // aka channel - appCommit = "Unknown" - appPlatform = "Unknown" - appArch = "Unknown" + appVersion = "Unknown" + appDate = "Unknown" + appType = "Unknown" // aka channel + appToolchain = "Unknown" + appCommit = "Unknown" + appPlatform = "Unknown" + appArch = "Unknown" + libVersion = "Unknown" appVersionLong = fmt.Sprintf("Version:\t%s\n"+ "BuildDate:\t%s\n"+ "BuildType:\t%s\n"+ + "Toolchain:\t%s\n"+ "CommitHash:\t%s\n"+ "Platform:\t%s\n"+ - "Architecture:\t%s", - appVersion, appDate, appType, appCommit, appPlatform, appArch) + "Architecture:\t%s\n"+ + "Libraries:\tquic-go=%s", + appVersion, appDate, appType, appToolchain, appCommit, appPlatform, appArch, libVersion) appAboutLong = fmt.Sprintf("%s\n%s\n%s\n\n%s", appLogo, appDesc, appAuthors, appVersionLong) ) diff --git a/app/cmd/server.go b/app/cmd/server.go index b45fb15..a2aa9a4 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -83,8 +83,9 @@ type serverConfigObfs struct { } type serverConfigTLS struct { - Cert string `mapstructure:"cert"` - Key string `mapstructure:"key"` + Cert string `mapstructure:"cert"` + Key string `mapstructure:"key"` + SNIGuard string `mapstructure:"sniGuard"` // "disable", "dns-san", "strict" } type serverConfigACME struct { @@ -203,6 +204,7 @@ type serverConfigOutboundDirect struct { BindIPv4 string `mapstructure:"bindIPv4"` BindIPv6 string `mapstructure:"bindIPv6"` BindDevice string `mapstructure:"bindDevice"` + FastOpen bool `mapstructure:"fastOpen"` } type serverConfigOutboundSOCKS5 struct { @@ -236,6 +238,7 @@ type serverConfigMasqueradeFile struct { type serverConfigMasqueradeProxy struct { URL string `mapstructure:"url"` RewriteHost bool `mapstructure:"rewriteHost"` + Insecure bool `mapstructure:"insecure"` } type serverConfigMasqueradeString struct { @@ -291,30 +294,45 @@ func (c *serverConfig) fillTLSConfig(hyConfig *server.Config) error { return configError{Field: "tls", Err: errors.New("cannot set both tls and acme")} } if c.TLS != nil { + // SNI guard + var sniGuard utils.SNIGuardFunc + switch strings.ToLower(c.TLS.SNIGuard) { + case "", "dns-san": + sniGuard = utils.SNIGuardDNSSAN + case "strict": + sniGuard = utils.SNIGuardStrict + case "disable": + sniGuard = nil + default: + return configError{Field: "tls.sniGuard", Err: errors.New("unsupported SNI guard")} + } // Local TLS cert if c.TLS.Cert == "" || c.TLS.Key == "" { return configError{Field: "tls", Err: errors.New("empty cert or key path")} } + certLoader := &utils.LocalCertificateLoader{ + CertFile: c.TLS.Cert, + KeyFile: c.TLS.Key, + SNIGuard: sniGuard, + } // Try loading the cert-key pair here to catch errors early // (e.g. invalid files or insufficient permissions) - certPEMBlock, err := os.ReadFile(c.TLS.Cert) + err := certLoader.InitializeCache() if err != nil { - return configError{Field: "tls.cert", Err: err} - } - keyPEMBlock, err := os.ReadFile(c.TLS.Key) - if err != nil { - return configError{Field: "tls.key", Err: err} - } - _, err = tls.X509KeyPair(certPEMBlock, keyPEMBlock) - if err != nil { - return configError{Field: "tls", Err: fmt.Errorf("invalid cert-key pair: %w", err)} + var pathErr *os.PathError + if errors.As(err, &pathErr) { + if pathErr.Path == c.TLS.Cert { + return configError{Field: "tls.cert", Err: pathErr} + } + if pathErr.Path == c.TLS.Key { + return configError{Field: "tls.key", Err: pathErr} + } + } + return configError{Field: "tls", Err: err} } // Use GetCertificate instead of Certificates so that // users can update the cert without restarting the server. - hyConfig.TLSConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(c.TLS.Cert, c.TLS.Key) - return &cert, err - } + hyConfig.TLSConfig.GetCertificate = certLoader.GetCertificate } else { // ACME dataDir := c.ACME.Dir @@ -502,18 +520,18 @@ func (c *serverConfig) fillQUICConfig(hyConfig *server.Config) error { } func serverConfigOutboundDirectToOutbound(c serverConfigOutboundDirect) (outbounds.PluggableOutbound, error) { - var mode outbounds.DirectOutboundMode + opts := outbounds.DirectOutboundOptions{} switch strings.ToLower(c.Mode) { case "", "auto": - mode = outbounds.DirectOutboundModeAuto + opts.Mode = outbounds.DirectOutboundModeAuto case "64": - mode = outbounds.DirectOutboundMode64 + opts.Mode = outbounds.DirectOutboundMode64 case "46": - mode = outbounds.DirectOutboundMode46 + opts.Mode = outbounds.DirectOutboundMode46 case "6": - mode = outbounds.DirectOutboundMode6 + opts.Mode = outbounds.DirectOutboundMode6 case "4": - mode = outbounds.DirectOutboundMode4 + opts.Mode = outbounds.DirectOutboundMode4 default: return nil, configError{Field: "outbounds.direct.mode", Err: errors.New("unsupported mode")} } @@ -530,12 +548,14 @@ func serverConfigOutboundDirectToOutbound(c serverConfigOutboundDirect) (outboun if len(c.BindIPv6) > 0 && ip6 == nil { return nil, configError{Field: "outbounds.direct.bindIPv6", Err: errors.New("invalid IPv6 address")} } - return outbounds.NewDirectOutboundBindToIPs(mode, ip4, ip6) + opts.BindIP4 = ip4 + opts.BindIP6 = ip6 } if bindDevice { - return outbounds.NewDirectOutboundBindToDevice(mode, c.BindDevice) + opts.DeviceName = c.BindDevice } - return outbounds.NewDirectOutboundSimple(mode), nil + opts.FastOpen = c.FastOpen + return outbounds.NewDirectOutboundWithOptions(opts) } func serverConfigOutboundSOCKS5ToOutbound(c serverConfigOutboundSOCKS5) (outbounds.PluggableOutbound, error) { @@ -735,7 +755,7 @@ func (c *serverConfig) fillAuthenticator(hyConfig *server.Config) error { if len(c.Auth.UserPass) == 0 { return configError{Field: "auth.userpass", Err: errors.New("empty auth userpass")} } - hyConfig.Authenticator = &auth.UserPassAuthenticator{Users: c.Auth.UserPass} + hyConfig.Authenticator = auth.NewUserPassAuthenticator(c.Auth.UserPass) return nil case "http", "https": if c.Auth.HTTP.URL == "" { @@ -788,6 +808,28 @@ func (c *serverConfig) fillMasqHandler(hyConfig *server.Config) error { if err != nil { return configError{Field: "masquerade.proxy.url", Err: err} } + if u.Scheme != "http" && u.Scheme != "https" { + return configError{Field: "masquerade.proxy.url", Err: fmt.Errorf("unsupported protocol scheme \"%s\"", u.Scheme)} + } + transport := http.DefaultTransport + if c.Masquerade.Proxy.Insecure { + transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + // use default configs from http.DefaultTransport + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + } handler = &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(u) @@ -797,6 +839,7 @@ func (c *serverConfig) fillMasqHandler(hyConfig *server.Config) error { r.Out.Host = r.In.Host } }, + Transport: transport, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { logger.Error("HTTP reverse proxy error", zap.Error(err)) w.WriteHeader(http.StatusBadGateway) diff --git a/app/cmd/server_test.go b/app/cmd/server_test.go index bb2d12a..5849a38 100644 --- a/app/cmd/server_test.go +++ b/app/cmd/server_test.go @@ -26,8 +26,9 @@ func TestServerConfig(t *testing.T) { }, }, TLS: &serverConfigTLS{ - Cert: "some.crt", - Key: "some.key", + Cert: "some.crt", + Key: "some.key", + SNIGuard: "strict", }, ACME: &serverConfigACME{ Domains: []string{ @@ -137,6 +138,7 @@ func TestServerConfig(t *testing.T) { BindIPv4: "2.4.6.8", BindIPv6: "0:0:0:0:0:ffff:0204:0608", BindDevice: "eth233", + FastOpen: true, }, }, { @@ -169,6 +171,7 @@ func TestServerConfig(t *testing.T) { Proxy: serverConfigMasqueradeProxy{ URL: "https://some.site.net", RewriteHost: true, + Insecure: true, }, String: serverConfigMasqueradeString{ Content: "aint nothin here", diff --git a/app/cmd/server_test.yaml b/app/cmd/server_test.yaml index ff0bf52..b989b97 100644 --- a/app/cmd/server_test.yaml +++ b/app/cmd/server_test.yaml @@ -8,6 +8,7 @@ obfs: tls: cert: some.crt key: some.key + sniGuard: strict acme: domains: @@ -107,6 +108,7 @@ outbounds: bindIPv4: 2.4.6.8 bindIPv6: 0:0:0:0:0:ffff:0204:0608 bindDevice: eth233 + fastOpen: true - name: badstuff type: socks5 socks5: @@ -130,6 +132,7 @@ masquerade: proxy: url: https://some.site.net rewriteHost: true + insecure: true string: content: aint nothin here headers: diff --git a/app/cmd/share.go b/app/cmd/share.go new file mode 100644 index 0000000..ad96e80 --- /dev/null +++ b/app/cmd/share.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + + "github.com/apernet/hysteria/app/v2/internal/utils" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +var ( + noText bool + withQR bool +) + +// shareCmd represents the share command +var shareCmd = &cobra.Command{ + Use: "share", + Short: "Generate share URI", + Long: "Generate a hysteria2:// URI from a client config for sharing", + Run: runShare, +} + +func init() { + initShareFlags() + rootCmd.AddCommand(shareCmd) +} + +func initShareFlags() { + shareCmd.Flags().BoolVar(&noText, "notext", false, "do not show URI as text") + shareCmd.Flags().BoolVar(&withQR, "qr", false, "show URI as QR code") +} + +func runShare(cmd *cobra.Command, args []string) { + if err := viper.ReadInConfig(); err != nil { + logger.Fatal("failed to read client config", zap.Error(err)) + } + var config clientConfig + if err := viper.Unmarshal(&config); err != nil { + logger.Fatal("failed to parse client config", zap.Error(err)) + } + if _, err := config.Config(); err != nil { + logger.Fatal("failed to load client config", zap.Error(err)) + } + + u := config.URI() + + if !noText { + fmt.Println(u) + } + if withQR { + utils.PrintQR(u) + } +} diff --git a/app/go.mod b/app/go.mod index f995c78..25d1fd3 100644 --- a/app/go.mod +++ b/app/go.mod @@ -1,6 +1,8 @@ module github.com/apernet/hysteria/app/v2 -go 1.21 +go 1.23 + +toolchain go1.24.2 require ( github.com/apernet/go-tproxy v0.0.0-20230809025308-8f4723fd742f @@ -23,14 +25,16 @@ require ( github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301 go.uber.org/zap v1.24.0 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 - golang.org/x/sys v0.21.0 + golang.org/x/sys v0.25.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 // indirect + github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 // indirect github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 // indirect github.com/cloudflare/circl v1.3.9 // indirect + github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a // indirect + github.com/database64128/tfo-go/v2 v2.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -53,7 +57,7 @@ require ( github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/refraction-networking/utls v1.6.6 // indirect github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97 // indirect github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 // indirect @@ -67,16 +71,16 @@ require ( github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect github.com/vultr/govultr/v3 v3.6.4 // indirect go.uber.org/atomic v1.11.0 // indirect - go.uber.org/mock v0.4.0 // indirect + go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/app/go.sum b/app/go.sum index 5b4bce8..9bc7ae7 100644 --- a/app/go.sum +++ b/app/go.sum @@ -42,8 +42,8 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/apernet/go-tproxy v0.0.0-20230809025308-8f4723fd742f h1:uVh0qpEslrWjgzx9vOcyCqsOY3c9kofDZ1n+qaw35ZY= github.com/apernet/go-tproxy v0.0.0-20230809025308-8f4723fd742f/go.mod h1:xkkq9D4ygcldQQhKS/w9CadiCKwCngU7K9E3DaKahpM= -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 h1:+jKV1EuDJiUoa4XgRyle5w7wIo+0hil+YyUmwhd4ttk= -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167/go.mod h1:MjGWpXA31DZZWESdX3/PjIpSWIT1fOm8FNCqyXXFZFU= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 h1:9/jM7e+kVALd7Jfu1c27dcEpT/Fd/Gzq2OsQjKjakKI= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431/go.mod h1:I/47OIGG5H/IfAm+nz2c6hm6b/NkEhpvptAoiPcY7jQ= github.com/apernet/sing-tun v0.2.6-0.20240323130332-b9f6511036ad h1:QzQ2sKpc9o42HNRR8ukM5uMC/RzR2HgZd/Nvaqol2C0= github.com/apernet/sing-tun v0.2.6-0.20240323130332-b9f6511036ad/go.mod h1:S5IydyLSN/QAfvY+r2GoomPJ6hidtXWm/Ad18sJVssk= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= @@ -63,6 +63,10 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a h1:t4SDi0pmNkryzKdM4QF3o5vqSP4GRjeZD/6j3nyxNP0= +github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a/go.mod h1:7K2NQKbabB5mBl41vF6YayYl5g7YpDwc4dQ5iMpP3Lg= +github.com/database64128/tfo-go/v2 v2.2.2 h1:BxynF4qGF5ct3DpPLEG62uyJZ3LQhqaf0Ken+kyy7PM= +github.com/database64128/tfo-go/v2 v2.2.2/go.mod h1:2IW8jppdBwdVMjA08uEyMNnqiAHKUlqAA+J8NrsfktY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -226,8 +230,8 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= -github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/refraction-networking/utls v1.6.6 h1:igFsYBUJPYM8Rno9xUuDoM5GQrVEqY4llzEXOkL43Ig= github.com/refraction-networking/utls v1.6.6/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -296,8 +300,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -314,8 +318,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -354,8 +358,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -392,8 +396,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -418,8 +422,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -463,8 +467,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -476,13 +480,11 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -534,8 +536,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/app/internal/tun/check_ipv6_others.go b/app/internal/tun/check_ipv6_others.go new file mode 100644 index 0000000..7cfd9ab --- /dev/null +++ b/app/internal/tun/check_ipv6_others.go @@ -0,0 +1,14 @@ +//go:build !unix && !windows + +package tun + +import "net" + +func isIPv6Supported() bool { + lis, err := net.ListenPacket("udp6", "[::1]:0") + if err != nil { + return false + } + _ = lis.Close() + return true +} diff --git a/app/internal/tun/check_ipv6_unix.go b/app/internal/tun/check_ipv6_unix.go new file mode 100644 index 0000000..8fdffaf --- /dev/null +++ b/app/internal/tun/check_ipv6_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package tun + +import ( + "golang.org/x/sys/unix" +) + +func isIPv6Supported() bool { + sock, err := unix.Socket(unix.AF_INET6, unix.SOCK_DGRAM, unix.IPPROTO_UDP) + if err != nil { + return false + } + _ = unix.Close(sock) + return true +} diff --git a/app/internal/tun/check_ipv6_windows.go b/app/internal/tun/check_ipv6_windows.go new file mode 100644 index 0000000..d488d7e --- /dev/null +++ b/app/internal/tun/check_ipv6_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package tun + +import ( + "golang.org/x/sys/windows" +) + +func isIPv6Supported() bool { + var wsaData windows.WSAData + err := windows.WSAStartup(uint32(0x202), &wsaData) + if err != nil { + // Failing silently: it is not our duty to report such errors + return true + } + defer windows.WSACleanup() + + sock, err := windows.Socket(windows.AF_INET6, windows.SOCK_DGRAM, windows.IPPROTO_UDP) + if err != nil { + return false + } + _ = windows.Closesocket(sock) + return true +} diff --git a/app/internal/tun/server.go b/app/internal/tun/server.go index 303d4ec..a999051 100644 --- a/app/internal/tun/server.go +++ b/app/internal/tun/server.go @@ -49,6 +49,10 @@ type EventLogger interface { } func (s *Server) Serve() error { + if !isIPv6Supported() { + s.Logger.Warn("tun-pre-check", zap.String("msg", "IPv6 is not supported or enabled on this system, TUN device is created without IPv6 support.")) + s.Inet6Address = nil + } tunOpts := tun.Options{ Name: s.IfName, Inet4Address: s.Inet4Address, diff --git a/app/internal/utils/certloader.go b/app/internal/utils/certloader.go new file mode 100644 index 0000000..fb41a3c --- /dev/null +++ b/app/internal/utils/certloader.go @@ -0,0 +1,198 @@ +package utils + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "strings" + "sync" + "sync/atomic" + "time" +) + +type LocalCertificateLoader struct { + CertFile string + KeyFile string + SNIGuard SNIGuardFunc + + lock sync.Mutex + cache atomic.Pointer[localCertificateCache] +} + +type SNIGuardFunc func(info *tls.ClientHelloInfo, cert *tls.Certificate) error + +// localCertificateCache holds the certificate and its mod times. +// this struct is designed to be read-only. +// +// to update the cache, use LocalCertificateLoader.makeCache and +// update the LocalCertificateLoader.cache field. +type localCertificateCache struct { + certificate *tls.Certificate + certModTime time.Time + keyModTime time.Time +} + +func (l *LocalCertificateLoader) InitializeCache() error { + l.lock.Lock() + defer l.lock.Unlock() + + cache, err := l.makeCache() + if err != nil { + return err + } + + l.cache.Store(cache) + return nil +} + +func (l *LocalCertificateLoader) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := l.getCertificateWithCache() + if err != nil { + return nil, err + } + + if l.SNIGuard == nil { + return cert, nil + } + err = l.SNIGuard(info, cert) + if err != nil { + return nil, err + } + + return cert, nil +} + +func (l *LocalCertificateLoader) checkModTime() (certModTime, keyModTime time.Time, err error) { + fi, err := os.Stat(l.CertFile) + if err != nil { + err = fmt.Errorf("failed to stat certificate file: %w", err) + return + } + certModTime = fi.ModTime() + + fi, err = os.Stat(l.KeyFile) + if err != nil { + err = fmt.Errorf("failed to stat key file: %w", err) + return + } + keyModTime = fi.ModTime() + return +} + +func (l *LocalCertificateLoader) makeCache() (cache *localCertificateCache, err error) { + c := &localCertificateCache{} + + c.certModTime, c.keyModTime, err = l.checkModTime() + if err != nil { + return + } + + cert, err := tls.LoadX509KeyPair(l.CertFile, l.KeyFile) + if err != nil { + return + } + c.certificate = &cert + if c.certificate.Leaf == nil { + // certificate.Leaf was left nil by tls.LoadX509KeyPair before Go 1.23 + c.certificate.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return + } + } + + cache = c + return +} + +func (l *LocalCertificateLoader) getCertificateWithCache() (*tls.Certificate, error) { + cache := l.cache.Load() + + certModTime, keyModTime, terr := l.checkModTime() + if terr != nil { + if cache != nil { + // use cache when file is temporarily unavailable + return cache.certificate, nil + } + return nil, terr + } + + if cache != nil && cache.certModTime.Equal(certModTime) && cache.keyModTime.Equal(keyModTime) { + // cache is up-to-date + return cache.certificate, nil + } + + if cache != nil { + if !l.lock.TryLock() { + // another goroutine is updating the cache + return cache.certificate, nil + } + } else { + l.lock.Lock() + } + defer l.lock.Unlock() + + if l.cache.Load() != cache { + // another goroutine updated the cache + return l.cache.Load().certificate, nil + } + + newCache, err := l.makeCache() + if err != nil { + if cache != nil { + // use cache when loading failed + return cache.certificate, nil + } + return nil, err + } + + l.cache.Store(newCache) + return newCache.certificate, nil +} + +// getNameFromClientHello returns a normalized form of hello.ServerName. +// If hello.ServerName is empty (i.e. client did not use SNI), then the +// associated connection's local address is used to extract an IP address. +// +// ref: https://github.com/caddyserver/certmagic/blob/3bad5b6bb595b09c14bd86ff0b365d302faaf5e2/handshake.go#L838 +func getNameFromClientHello(hello *tls.ClientHelloInfo) string { + normalizedName := func(serverName string) string { + return strings.ToLower(strings.TrimSpace(serverName)) + } + localIPFromConn := func(c net.Conn) string { + if c == nil { + return "" + } + localAddr := c.LocalAddr().String() + ip, _, err := net.SplitHostPort(localAddr) + if err != nil { + ip = localAddr + } + if scopeIDStart := strings.Index(ip, "%"); scopeIDStart > -1 { + ip = ip[:scopeIDStart] + } + return ip + } + + if name := normalizedName(hello.ServerName); name != "" { + return name + } + return localIPFromConn(hello.Conn) +} + +func SNIGuardDNSSAN(info *tls.ClientHelloInfo, cert *tls.Certificate) error { + if len(cert.Leaf.DNSNames) == 0 { + return nil + } + return SNIGuardStrict(info, cert) +} + +func SNIGuardStrict(info *tls.ClientHelloInfo, cert *tls.Certificate) error { + hostname := getNameFromClientHello(info) + err := cert.Leaf.VerifyHostname(hostname) + if err != nil { + return fmt.Errorf("sni guard: %w", err) + } + return nil +} diff --git a/app/internal/utils/certloader_test.go b/app/internal/utils/certloader_test.go new file mode 100644 index 0000000..3a8e26b --- /dev/null +++ b/app/internal/utils/certloader_test.go @@ -0,0 +1,139 @@ +package utils + +import ( + "crypto/tls" + "log" + "net/http" + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + testListen = "127.82.39.147:12947" + testCAFile = "./testcerts/ca" + testCertFile = "./testcerts/cert" + testKeyFile = "./testcerts/key" +) + +func TestCertificateLoaderPathError(t *testing.T) { + assert.NoError(t, os.RemoveAll(testCertFile)) + assert.NoError(t, os.RemoveAll(testKeyFile)) + loader := LocalCertificateLoader{ + CertFile: testCertFile, + KeyFile: testKeyFile, + SNIGuard: SNIGuardStrict, + } + err := loader.InitializeCache() + var pathErr *os.PathError + assert.ErrorAs(t, err, &pathErr) +} + +func TestCertificateLoaderFullChain(t *testing.T) { + assert.NoError(t, generateTestCertificate([]string{"example.com"}, "fullchain")) + + loader := LocalCertificateLoader{ + CertFile: testCertFile, + KeyFile: testKeyFile, + SNIGuard: SNIGuardStrict, + } + assert.NoError(t, loader.InitializeCache()) + + lis, err := tls.Listen("tcp", testListen, &tls.Config{ + GetCertificate: loader.GetCertificate, + }) + assert.NoError(t, err) + defer lis.Close() + go http.Serve(lis, nil) + + assert.Error(t, runTestTLSClient("unmatched-sni.example.com")) + assert.Error(t, runTestTLSClient("")) + assert.NoError(t, runTestTLSClient("example.com")) +} + +func TestCertificateLoaderNoSAN(t *testing.T) { + assert.NoError(t, generateTestCertificate(nil, "selfsign")) + + loader := LocalCertificateLoader{ + CertFile: testCertFile, + KeyFile: testKeyFile, + SNIGuard: SNIGuardDNSSAN, + } + assert.NoError(t, loader.InitializeCache()) + + lis, err := tls.Listen("tcp", testListen, &tls.Config{ + GetCertificate: loader.GetCertificate, + }) + assert.NoError(t, err) + defer lis.Close() + go http.Serve(lis, nil) + + assert.NoError(t, runTestTLSClient("")) +} + +func TestCertificateLoaderReplaceCertificate(t *testing.T) { + assert.NoError(t, generateTestCertificate([]string{"example.com"}, "fullchain")) + + loader := LocalCertificateLoader{ + CertFile: testCertFile, + KeyFile: testKeyFile, + SNIGuard: SNIGuardStrict, + } + assert.NoError(t, loader.InitializeCache()) + + lis, err := tls.Listen("tcp", testListen, &tls.Config{ + GetCertificate: loader.GetCertificate, + }) + assert.NoError(t, err) + defer lis.Close() + go http.Serve(lis, nil) + + assert.NoError(t, runTestTLSClient("example.com")) + assert.Error(t, runTestTLSClient("2.example.com")) + + assert.NoError(t, generateTestCertificate([]string{"2.example.com"}, "fullchain")) + + assert.Error(t, runTestTLSClient("example.com")) + assert.NoError(t, runTestTLSClient("2.example.com")) +} + +func generateTestCertificate(dnssan []string, certType string) error { + args := []string{ + "certloader_test_gencert.py", + "--ca", testCAFile, + "--cert", testCertFile, + "--key", testKeyFile, + "--type", certType, + } + if len(dnssan) > 0 { + args = append(args, "--dnssan", strings.Join(dnssan, ",")) + } + cmd := exec.Command("python", args...) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Failed to generate test certificate: %s", out) + return err + } + return nil +} + +func runTestTLSClient(sni string) error { + args := []string{ + "certloader_test_tlsclient.py", + "--server", testListen, + "--ca", testCAFile, + } + if sni != "" { + args = append(args, "--sni", sni) + } + cmd := exec.Command("python", args...) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Failed to run test TLS client: %s", out) + return err + } + return nil +} diff --git a/app/internal/utils/certloader_test_gencert.py b/app/internal/utils/certloader_test_gencert.py new file mode 100644 index 0000000..d4d5695 --- /dev/null +++ b/app/internal/utils/certloader_test_gencert.py @@ -0,0 +1,134 @@ +import argparse +import datetime +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption + + +def create_key(): + return ec.generate_private_key(ec.SECP256R1()) + + +def create_certificate(cert_type, subject, issuer, private_key, public_key, dns_san=None): + serial_number = x509.random_serial_number() + not_valid_before = datetime.datetime.now(datetime.UTC) + not_valid_after = not_valid_before + datetime.timedelta(days=365) + + subject_name = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, subject.get('C', 'ZZ')), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, subject.get('O', 'No Organization')), + x509.NameAttribute(NameOID.COMMON_NAME, subject.get('CN', 'No CommonName')), + ]) + issuer_name = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, issuer.get('C', 'ZZ')), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, issuer.get('O', 'No Organization')), + x509.NameAttribute(NameOID.COMMON_NAME, issuer.get('CN', 'No CommonName')), + ]) + builder = x509.CertificateBuilder() + builder = builder.subject_name(subject_name) + builder = builder.issuer_name(issuer_name) + builder = builder.public_key(public_key) + builder = builder.serial_number(serial_number) + builder = builder.not_valid_before(not_valid_before) + builder = builder.not_valid_after(not_valid_after) + if cert_type == 'root': + builder = builder.add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + elif cert_type == 'intermediate': + builder = builder.add_extension( + x509.BasicConstraints(ca=True, path_length=0), critical=True + ) + elif cert_type == 'leaf': + builder = builder.add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + else: + raise ValueError(f'Invalid cert_type: {cert_type}') + if dns_san: + builder = builder.add_extension( + x509.SubjectAlternativeName([x509.DNSName(d) for d in dns_san.split(',')]), + critical=False + ) + return builder.sign(private_key=private_key, algorithm=hashes.SHA256()) + + +def main(): + parser = argparse.ArgumentParser(description='Generate HTTPS server certificate.') + parser.add_argument('--ca', required=True, + help='Path to write the X509 CA certificate in PEM format') + parser.add_argument('--cert', required=True, + help='Path to write the X509 certificate in PEM format') + parser.add_argument('--key', required=True, + help='Path to write the private key in PEM format') + parser.add_argument('--dnssan', required=False, default=None, + help='Comma-separated list of DNS SANs') + parser.add_argument('--type', required=True, choices=['selfsign', 'fullchain'], + help='Type of certificate to generate') + + args = parser.parse_args() + + key = create_key() + public_key = key.public_key() + + if args.type == 'selfsign': + subject = {"C": "ZZ", "O": "Certificate", "CN": "Certificate"} + cert = create_certificate( + cert_type='root', + subject=subject, + issuer=subject, + private_key=key, + public_key=public_key, + dns_san=args.dnssan) + with open(args.ca, 'wb') as f: + f.write(cert.public_bytes(Encoding.PEM)) + with open(args.cert, 'wb') as f: + f.write(cert.public_bytes(Encoding.PEM)) + with open(args.key, 'wb') as f: + f.write( + key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())) + + elif args.type == 'fullchain': + ca_key = create_key() + ca_public_key = ca_key.public_key() + ca_subject = {"C": "ZZ", "O": "Root CA", "CN": "Root CA"} + ca_cert = create_certificate( + cert_type='root', + subject=ca_subject, + issuer=ca_subject, + private_key=ca_key, + public_key=ca_public_key) + + intermediate_key = create_key() + intermediate_public_key = intermediate_key.public_key() + intermediate_subject = {"C": "ZZ", "O": "Intermediate CA", "CN": "Intermediate CA"} + intermediate_cert = create_certificate( + cert_type='intermediate', + subject=intermediate_subject, + issuer=ca_subject, + private_key=ca_key, + public_key=intermediate_public_key) + + leaf_subject = {"C": "ZZ", "O": "Leaf Certificate", "CN": "Leaf Certificate"} + cert = create_certificate( + cert_type='leaf', + subject=leaf_subject, + issuer=intermediate_subject, + private_key=intermediate_key, + public_key=public_key, + dns_san=args.dnssan) + + with open(args.ca, 'wb') as f: + f.write(ca_cert.public_bytes(Encoding.PEM)) + with open(args.cert, 'wb') as f: + f.write(cert.public_bytes(Encoding.PEM)) + f.write(intermediate_cert.public_bytes(Encoding.PEM)) + with open(args.key, 'wb') as f: + f.write( + key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())) + + +if __name__ == "__main__": + main() diff --git a/app/internal/utils/certloader_test_tlsclient.py b/app/internal/utils/certloader_test_tlsclient.py new file mode 100644 index 0000000..3b7efd6 --- /dev/null +++ b/app/internal/utils/certloader_test_tlsclient.py @@ -0,0 +1,60 @@ +import argparse +import ssl +import socket +import sys + + +def check_tls(server, ca_cert, sni, alpn): + try: + host, port = server.split(":") + port = int(port) + + if ca_cert: + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_cert) + context.check_hostname = sni is not None + context.verify_mode = ssl.CERT_REQUIRED + else: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + if alpn: + context.set_alpn_protocols([p for p in alpn.split(",")]) + + with socket.create_connection((host, port)) as sock: + with context.wrap_socket(sock, server_hostname=sni) as ssock: + # Verify handshake and certificate + print(f'Connected to {ssock.version()} using {ssock.cipher()}') + print(f'Server certificate validated and details: {ssock.getpeercert()}') + print("OK") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + + +def main(): + parser = argparse.ArgumentParser(description="Test TLS Server") + parser.add_argument("--server", required=True, + help="Server address to test (e.g., 127.1.2.3:8443)") + parser.add_argument("--ca", required=False, default=None, + help="CA certificate file used to validate the server certificate" + "Omit to use insecure connection") + parser.add_argument("--sni", required=False, default=None, + help="SNI to send in ClientHello") + parser.add_argument("--alpn", required=False, default='h2', + help="ALPN to send in ClientHello") + + args = parser.parse_args() + + exit_status = check_tls( + server=args.server, + ca_cert=args.ca, + sni=args.sni, + alpn=args.alpn) + + sys.exit(exit_status) + + +if __name__ == "__main__": + main() diff --git a/app/internal/utils/testcerts/.gitignore b/app/internal/utils/testcerts/.gitignore new file mode 100644 index 0000000..082821a --- /dev/null +++ b/app/internal/utils/testcerts/.gitignore @@ -0,0 +1,3 @@ +# This directory is used for certificate generation in certloader_test.go +/* +!/.gitignore diff --git a/core/LICENSE.md b/core/LICENSE.md new file mode 100644 index 0000000..208e8f2 --- /dev/null +++ b/core/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2023 Toby + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/core/client/client.go b/core/client/client.go index 91d59fd..3691d1e 100644 --- a/core/client/client.go +++ b/core/client/client.go @@ -3,6 +3,7 @@ package client import ( "context" "crypto/tls" + "errors" "net" "net/http" "net/url" @@ -83,6 +84,7 @@ func (c *clientImpl) connect() (*HandshakeInfo, error) { KeepAlivePeriod: c.config.QUICConfig.KeepAlivePeriod, DisablePathMTUDiscovery: c.config.QUICConfig.DisablePathMTUDiscovery, EnableDatagrams: true, + DisablePathManager: true, } // Prepare RoundTripper var conn quic.EarlyConnection @@ -221,18 +223,21 @@ func (c *clientImpl) Close() error { return nil } +var nonPermanentErrors = []error{ + quic.StreamLimitReachedError{}, +} + // wrapIfConnectionClosed checks if the error returned by quic-go -// indicates that the QUIC connection has been permanently closed, -// and if so, wraps the error with coreErrs.ClosedError. -// PITFALL: sometimes quic-go has "internal errors" that are not net.Error, -// but we still need to treat them as ClosedError. +// is recoverable (listed in nonPermanentErrors) or permanent. +// Recoverable errors are returned as-is, +// permanent ones are wrapped as ClosedError. func wrapIfConnectionClosed(err error) error { - netErr, ok := err.(net.Error) - if !ok || !netErr.Temporary() { - return coreErrs.ClosedError{Err: err} - } else { - return err + for _, e := range nonPermanentErrors { + if errors.Is(err, e) { + return err + } } + return coreErrs.ClosedError{Err: err} } type tcpConn struct { diff --git a/core/go.mod b/core/go.mod index 77513a9..a6ba575 100644 --- a/core/go.mod +++ b/core/go.mod @@ -1,9 +1,11 @@ module github.com/apernet/hysteria/core/v2 -go 1.21 +go 1.23 + +toolchain go1.24.2 require ( - github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 + github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 github.com/stretchr/testify v1.9.0 go.uber.org/goleak v1.2.1 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 @@ -18,16 +20,17 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - go.uber.org/mock v0.4.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/core/go.sum b/core/go.sum index c36f51e..f44407d 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,5 +1,5 @@ -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 h1:+jKV1EuDJiUoa4XgRyle5w7wIo+0hil+YyUmwhd4ttk= -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167/go.mod h1:MjGWpXA31DZZWESdX3/PjIpSWIT1fOm8FNCqyXXFZFU= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 h1:9/jM7e+kVALd7Jfu1c27dcEpT/Fd/Gzq2OsQjKjakKI= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431/go.mod h1:I/47OIGG5H/IfAm+nz2c6hm6b/NkEhpvptAoiPcY7jQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -32,8 +32,8 @@ github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+q github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= -github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= @@ -45,27 +45,27 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/core/internal/congestion/bbr/packet_number_indexed_queue.go b/core/internal/congestion/bbr/packet_number_indexed_queue.go index 86fe52d..08b99de 100644 --- a/core/internal/congestion/bbr/packet_number_indexed_queue.go +++ b/core/internal/congestion/bbr/packet_number_indexed_queue.go @@ -152,7 +152,7 @@ func (p *packetNumberIndexedQueue[T]) EntrySlotsUsed() int { return p.entries.Len() } -// LastPacket returns packet number of the first entry in the queue. +// FirstPacket returns packet number of the first entry in the queue. func (p *packetNumberIndexedQueue[T]) FirstPacket() (packetNumber congestion.PacketNumber) { return p.firstPacket } diff --git a/core/internal/integration_tests/hook_test.go b/core/internal/integration_tests/hook_test.go index 1121d13..64affe8 100644 --- a/core/internal/integration_tests/hook_test.go +++ b/core/internal/integration_tests/hook_test.go @@ -132,7 +132,9 @@ func TestClientServerHookUDP(t *testing.T) { rData, rAddr, err := conn.Receive() assert.NoError(t, err) assert.Equal(t, sData, rData) - assert.Equal(t, realEchoAddr, rAddr) + // Hook address change is transparent, + // the client should still see the fake echo address it sent packets to + assert.Equal(t, fakeEchoAddr, rAddr) // Subsequent packets should also be sent to the real echo server sData = []byte("never stop fighting") @@ -141,5 +143,5 @@ func TestClientServerHookUDP(t *testing.T) { rData, rAddr, err = conn.Receive() assert.NoError(t, err) assert.Equal(t, sData, rData) - assert.Equal(t, realEchoAddr, rAddr) + assert.Equal(t, fakeEchoAddr, rAddr) } diff --git a/core/internal/integration_tests/mocks/mock_TrafficLogger.go b/core/internal/integration_tests/mocks/mock_TrafficLogger.go index 9de44b9..1ed977e 100644 --- a/core/internal/integration_tests/mocks/mock_TrafficLogger.go +++ b/core/internal/integration_tests/mocks/mock_TrafficLogger.go @@ -2,7 +2,12 @@ package mocks -import mock "github.com/stretchr/testify/mock" +import ( + quic "github.com/apernet/quic-go" + mock "github.com/stretchr/testify/mock" + + server "github.com/apernet/hysteria/core/v2/server" +) // MockTrafficLogger is an autogenerated mock type for the TrafficLogger type type MockTrafficLogger struct { @@ -99,6 +104,73 @@ func (_c *MockTrafficLogger_LogTraffic_Call) RunAndReturn(run func(string, uint6 return _c } +// TraceStream provides a mock function with given fields: stream, stats +func (_m *MockTrafficLogger) TraceStream(stream quic.Stream, stats *server.StreamStats) { + _m.Called(stream, stats) +} + +// MockTrafficLogger_TraceStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TraceStream' +type MockTrafficLogger_TraceStream_Call struct { + *mock.Call +} + +// TraceStream is a helper method to define mock.On call +// - stream quic.Stream +// - stats *server.StreamStats +func (_e *MockTrafficLogger_Expecter) TraceStream(stream interface{}, stats interface{}) *MockTrafficLogger_TraceStream_Call { + return &MockTrafficLogger_TraceStream_Call{Call: _e.mock.On("TraceStream", stream, stats)} +} + +func (_c *MockTrafficLogger_TraceStream_Call) Run(run func(stream quic.Stream, stats *server.StreamStats)) *MockTrafficLogger_TraceStream_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(quic.Stream), args[1].(*server.StreamStats)) + }) + return _c +} + +func (_c *MockTrafficLogger_TraceStream_Call) Return() *MockTrafficLogger_TraceStream_Call { + _c.Call.Return() + return _c +} + +func (_c *MockTrafficLogger_TraceStream_Call) RunAndReturn(run func(quic.Stream, *server.StreamStats)) *MockTrafficLogger_TraceStream_Call { + _c.Call.Return(run) + return _c +} + +// UntraceStream provides a mock function with given fields: stream +func (_m *MockTrafficLogger) UntraceStream(stream quic.Stream) { + _m.Called(stream) +} + +// MockTrafficLogger_UntraceStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UntraceStream' +type MockTrafficLogger_UntraceStream_Call struct { + *mock.Call +} + +// UntraceStream is a helper method to define mock.On call +// - stream quic.Stream +func (_e *MockTrafficLogger_Expecter) UntraceStream(stream interface{}) *MockTrafficLogger_UntraceStream_Call { + return &MockTrafficLogger_UntraceStream_Call{Call: _e.mock.On("UntraceStream", stream)} +} + +func (_c *MockTrafficLogger_UntraceStream_Call) Run(run func(stream quic.Stream)) *MockTrafficLogger_UntraceStream_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(quic.Stream)) + }) + return _c +} + +func (_c *MockTrafficLogger_UntraceStream_Call) Return() *MockTrafficLogger_UntraceStream_Call { + _c.Call.Return() + return _c +} + +func (_c *MockTrafficLogger_UntraceStream_Call) RunAndReturn(run func(quic.Stream)) *MockTrafficLogger_UntraceStream_Call { + _c.Call.Return(run) + return _c +} + // NewMockTrafficLogger creates a new instance of MockTrafficLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockTrafficLogger(t interface { diff --git a/core/internal/integration_tests/trafficlogger_test.go b/core/internal/integration_tests/trafficlogger_test.go index b5355ff..841f4ff 100644 --- a/core/internal/integration_tests/trafficlogger_test.go +++ b/core/internal/integration_tests/trafficlogger_test.go @@ -62,6 +62,7 @@ func TestClientServerTrafficLoggerTCP(t *testing.T) { return nil }) serverOb.EXPECT().TCP(addr).Return(sobConn, nil).Once() + trafficLogger.EXPECT().TraceStream(mock.Anything, mock.Anything).Return().Once() conn, err := c.TCP(addr) assert.NoError(t, err) @@ -84,6 +85,7 @@ func TestClientServerTrafficLoggerTCP(t *testing.T) { time.Sleep(1 * time.Second) // Need some time for the server to receive the data // Client reads from server again but blocked + trafficLogger.EXPECT().UntraceStream(mock.Anything).Return().Once() trafficLogger.EXPECT().LogTraffic("nobody", uint64(0), uint64(4)).Return(false).Once() trafficLogger.EXPECT().LogOnlineState("nobody", false).Return().Once() sobConnCh <- []byte("nope") diff --git a/core/internal/utils/atomic.go b/core/internal/utils/atomic.go index e3c3d97..7739013 100644 --- a/core/internal/utils/atomic.go +++ b/core/internal/utils/atomic.go @@ -22,3 +22,33 @@ func (t *AtomicTime) Set(new time.Time) { func (t *AtomicTime) Get() time.Time { return t.v.Load().(time.Time) } + +type Atomic[T any] struct { + v atomic.Value +} + +func (a *Atomic[T]) Load() T { + value := a.v.Load() + if value == nil { + var zero T + return zero + } + return value.(T) +} + +func (a *Atomic[T]) Store(value T) { + a.v.Store(value) +} + +func (a *Atomic[T]) Swap(new T) T { + old := a.v.Swap(new) + if old == nil { + var zero T + return zero + } + return old.(T) +} + +func (a *Atomic[T]) CompareAndSwap(old, new T) bool { + return a.v.CompareAndSwap(old, new) +} diff --git a/core/server/config.go b/core/server/config.go index f90c820..a01f478 100644 --- a/core/server/config.go +++ b/core/server/config.go @@ -4,10 +4,12 @@ import ( "crypto/tls" "net" "net/http" + "sync/atomic" "time" "github.com/apernet/hysteria/core/v2/errors" "github.com/apernet/hysteria/core/v2/internal/pmtud" + "github.com/apernet/hysteria/core/v2/internal/utils" "github.com/apernet/quic-go" ) @@ -212,4 +214,66 @@ type EventLogger interface { type TrafficLogger interface { LogTraffic(id string, tx, rx uint64) (ok bool) LogOnlineState(id string, online bool) + TraceStream(stream quic.Stream, stats *StreamStats) + UntraceStream(stream quic.Stream) +} + +type StreamState int + +const ( + // StreamStateInitial indicates the initial state of a stream. + // Client has opened the stream, but we have not received the proxy request yet. + StreamStateInitial StreamState = iota + + // StreamStateHooking indicates that the hook (usually sniff) is processing. + // Client has sent the proxy request, but sniff requires more data to complete. + StreamStateHooking + + // StreamStateConnecting indicates that we are connecting to the proxy target. + StreamStateConnecting + + // StreamStateEstablished indicates the proxy is established. + StreamStateEstablished + + // StreamStateClosed indicates the stream is closed. + StreamStateClosed +) + +func (s StreamState) String() string { + switch s { + case StreamStateInitial: + return "init" + case StreamStateHooking: + return "hook" + case StreamStateConnecting: + return "connect" + case StreamStateEstablished: + return "estab" + case StreamStateClosed: + return "closed" + default: + return "unknown" + } +} + +type StreamStats struct { + State utils.Atomic[StreamState] + + AuthID string + ConnID uint32 + InitialTime time.Time + + ReqAddr utils.Atomic[string] + HookedReqAddr utils.Atomic[string] + + Tx atomic.Uint64 + Rx atomic.Uint64 + + LastActiveTime utils.Atomic[time.Time] +} + +func (s *StreamStats) setHookedReqAddr(addr string) { + if addr != s.ReqAddr.Load() { + s.HookedReqAddr.Store(addr) + } } diff --git a/core/server/copy.go b/core/server/copy.go index d55dcef..7123fc8 100644 --- a/core/server/copy.go +++ b/core/server/copy.go @@ -3,6 +3,7 @@ package server import ( "errors" "io" + "time" ) var errDisconnect = errors.New("traffic logger requested disconnect") @@ -31,15 +32,19 @@ func copyBufferLog(dst io.Writer, src io.Reader, log func(n uint64) bool) error } } -func copyTwoWayWithLogger(id string, serverRw, remoteRw io.ReadWriter, l TrafficLogger) error { +func copyTwoWayEx(id string, serverRw, remoteRw io.ReadWriter, l TrafficLogger, stats *StreamStats) error { errChan := make(chan error, 2) go func() { errChan <- copyBufferLog(serverRw, remoteRw, func(n uint64) bool { + stats.LastActiveTime.Store(time.Now()) + stats.Rx.Add(n) return l.LogTraffic(id, 0, n) }) }() go func() { errChan <- copyBufferLog(remoteRw, serverRw, func(n uint64) bool { + stats.LastActiveTime.Store(time.Now()) + stats.Tx.Add(n) return l.LogTraffic(id, n, 0) }) }() @@ -47,7 +52,7 @@ func copyTwoWayWithLogger(id string, serverRw, remoteRw io.ReadWriter, l Traffic return <-errChan } -// copyTwoWay is the "fast-path" version of copyTwoWayWithLogger that does not log traffic. +// copyTwoWay is the "fast-path" version of copyTwoWayEx that does not log traffic or update stream stats. // It uses the built-in io.Copy instead of our own copyBufferLog. func copyTwoWay(serverRw, remoteRw io.ReadWriter) error { errChan := make(chan error, 2) diff --git a/core/server/server.go b/core/server/server.go index ba55b31..89645e0 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -3,8 +3,10 @@ package server import ( "context" "crypto/tls" + "math/rand" "net/http" "sync" + "time" "github.com/apernet/quic-go" "github.com/apernet/quic-go/http3" @@ -41,6 +43,7 @@ func NewServer(config *Config) (Server, error) { MaxIncomingStreams: config.QUICConfig.MaxIncomingStreams, DisablePathMTUDiscovery: config.QUICConfig.DisablePathMTUDiscovery, EnableDatagrams: true, + DisablePathManager: true, } listener, err := quic.Listen(config.Conn, tlsConfig, quicConfig) if err != nil { @@ -100,6 +103,7 @@ type h3sHandler struct { authenticated bool authMutex sync.Mutex authID string + connID uint32 // a random id for dump streams udpSM *udpSessionManager // Only set after authentication } @@ -108,6 +112,7 @@ func newH3sHandler(config *Config, conn quic.Connection) *h3sHandler { return &h3sHandler{ config: config, conn: conn, + connID: rand.Uint32(), } } @@ -205,12 +210,29 @@ func (h *h3sHandler) ProxyStreamHijacker(ft http3.FrameType, id quic.ConnectionT } func (h *h3sHandler) handleTCPRequest(stream quic.Stream) { + trafficLogger := h.config.TrafficLogger + streamStats := &StreamStats{ + AuthID: h.authID, + ConnID: h.connID, + InitialTime: time.Now(), + } + streamStats.State.Store(StreamStateInitial) + streamStats.LastActiveTime.Store(time.Now()) + defer func() { + streamStats.State.Store(StreamStateClosed) + }() + if trafficLogger != nil { + trafficLogger.TraceStream(stream, streamStats) + defer trafficLogger.UntraceStream(stream) + } + // Read request reqAddr, err := protocol.ReadTCPRequest(stream) if err != nil { _ = stream.Close() return } + streamStats.ReqAddr.Store(reqAddr) // Call the hook if set var putback []byte var hooked bool @@ -220,12 +242,14 @@ func (h *h3sHandler) handleTCPRequest(stream quic.Stream) { // so that the client will send whatever request the hook wants to see. // This is essentially a server-side fast-open. if hooked { + streamStats.State.Store(StreamStateHooking) _ = protocol.WriteTCPResponse(stream, true, "RequestHook enabled") putback, err = h.config.RequestHook.TCP(stream, &reqAddr) if err != nil { _ = stream.Close() return } + streamStats.setHookedReqAddr(reqAddr) } } // Log the event @@ -233,6 +257,7 @@ func (h *h3sHandler) handleTCPRequest(stream quic.Stream) { h.config.EventLogger.TCPRequest(h.conn.RemoteAddr(), h.authID, reqAddr) } // Dial target + streamStats.State.Store(StreamStateConnecting) tConn, err := h.config.Outbound.TCP(reqAddr) if err != nil { if !hooked { @@ -248,13 +273,15 @@ func (h *h3sHandler) handleTCPRequest(stream quic.Stream) { if !hooked { _ = protocol.WriteTCPResponse(stream, true, "Connected") } + streamStats.State.Store(StreamStateEstablished) // Put back the data if the hook requested if len(putback) > 0 { - _, _ = tConn.Write(putback) + n, _ := tConn.Write(putback) + streamStats.Tx.Add(uint64(n)) } // Start proxying - if h.config.TrafficLogger != nil { - err = copyTwoWayWithLogger(h.authID, stream, tConn, h.config.TrafficLogger) + if trafficLogger != nil { + err = copyTwoWayEx(h.authID, stream, tConn, trafficLogger, streamStats) } else { // Use the fast path if no traffic logger is set err = copyTwoWay(stream, tConn) diff --git a/core/server/udp.go b/core/server/udp.go index ecaee29..14efc9e 100644 --- a/core/server/udp.go +++ b/core/server/udp.go @@ -31,11 +31,57 @@ type udpEventLogger interface { type udpSessionEntry struct { ID uint32 - Conn UDPConn OverrideAddr string // Ignore the address in the UDP message, always use this if not empty + OriginalAddr string // The original address in the UDP message D *frag.Defragger Last *utils.AtomicTime - Timeout bool // true if the session is closed due to timeout + IO udpIO + + DialFunc func(addr string, firstMsgData []byte) (conn UDPConn, actualAddr string, err error) + ExitFunc func(err error) + + conn UDPConn + connLock sync.Mutex + closed bool +} + +func newUDPSessionEntry( + id uint32, io udpIO, + dialFunc func(string, []byte) (UDPConn, string, error), + exitFunc func(error), +) (e *udpSessionEntry) { + e = &udpSessionEntry{ + ID: id, + D: &frag.Defragger{}, + Last: utils.NewAtomicTime(time.Now()), + IO: io, + + DialFunc: dialFunc, + ExitFunc: exitFunc, + } + + return +} + +// CloseWithErr closes the session and calls ExitFunc with the given error. +// A nil error indicates the session is cleaned up due to timeout. +func (e *udpSessionEntry) CloseWithErr(err error) { + // We need this lock to ensure not to create conn after session exit + e.connLock.Lock() + + if e.closed { + // Already closed + e.connLock.Unlock() + return + } + + e.closed = true + if e.conn != nil { + _ = e.conn.Close() + } + e.connLock.Unlock() + + e.ExitFunc(err) } // Feed feeds a UDP message to the session. @@ -49,27 +95,78 @@ func (e *udpSessionEntry) Feed(msg *protocol.UDPMessage) (int, error) { if dfMsg == nil { return 0, nil } - if e.OverrideAddr != "" { - return e.Conn.WriteTo(dfMsg.Data, e.OverrideAddr) - } else { - return e.Conn.WriteTo(dfMsg.Data, dfMsg.Addr) + + if e.conn == nil { + err := e.initConn(dfMsg) + if err != nil { + return 0, err + } } + + addr := dfMsg.Addr + if e.OverrideAddr != "" { + addr = e.OverrideAddr + } + + return e.conn.WriteTo(dfMsg.Data, addr) } -// ReceiveLoop receives incoming UDP packets, packs them into UDP messages, -// and sends using the provided io. -// Exit and returns error when either the underlying UDP connection returns -// error (e.g. closed), or the provided io returns error when sending. -func (e *udpSessionEntry) ReceiveLoop(io udpIO) error { +// initConn initializes the UDP connection of the session. +// If no error is returned, the e.conn is set to the new connection. +func (e *udpSessionEntry) initConn(firstMsg *protocol.UDPMessage) error { + // We need this lock to ensure not to create conn after session exit + e.connLock.Lock() + + if e.closed { + e.connLock.Unlock() + return errors.New("session is closed") + } + + conn, actualAddr, err := e.DialFunc(firstMsg.Addr, firstMsg.Data) + if err != nil { + // Fail fast if DialFunc failed + // (usually indicates the connection has been rejected by the ACL) + e.connLock.Unlock() + // CloseWithErr acquires the connLock again + e.CloseWithErr(err) + return err + } + + e.conn = conn + + if firstMsg.Addr != actualAddr { + // Hook changed the address, enable address override + e.OverrideAddr = actualAddr + e.OriginalAddr = firstMsg.Addr + } + go e.receiveLoop() + + e.connLock.Unlock() + return nil +} + +// receiveLoop receives incoming UDP packets, packs them into UDP messages, +// and sends using the IO. +// Exit when either the underlying UDP connection returns error (e.g. closed), +// or the IO returns error when sending. +func (e *udpSessionEntry) receiveLoop() { udpBuf := make([]byte, protocol.MaxUDPSize) msgBuf := make([]byte, protocol.MaxUDPSize) for { - udpN, rAddr, err := e.Conn.ReadFrom(udpBuf) + udpN, rAddr, err := e.conn.ReadFrom(udpBuf) if err != nil { - return err + e.CloseWithErr(err) + return } e.Last.Set(time.Now()) + if e.OriginalAddr != "" { + // Use the original address in the opposite direction, + // otherwise the QUIC clients or NAT on the client side + // may not treat it as the same UDP session. + rAddr = e.OriginalAddr + } + msg := &protocol.UDPMessage{ SessionID: e.ID, PacketID: 0, @@ -78,9 +175,10 @@ func (e *udpSessionEntry) ReceiveLoop(io udpIO) error { Addr: rAddr, Data: udpBuf[:udpN], } - err = sendMessageAutoFrag(io, msgBuf, msg) + err = sendMessageAutoFrag(e.IO, msgBuf, msg) if err != nil { - return err + e.CloseWithErr(err) + return } } } @@ -161,19 +259,23 @@ func (m *udpSessionManager) idleCleanupLoop(stopCh <-chan struct{}) { } func (m *udpSessionManager) cleanup(idleOnly bool) { + timeoutEntry := make([]*udpSessionEntry, 0, len(m.m)) + // We use RLock here as we are only scanning the map, not deleting from it. m.mutex.RLock() - defer m.mutex.RUnlock() - now := time.Now() for _, entry := range m.m { if !idleOnly || now.Sub(entry.Last.Get()) > m.idleTimeout { - entry.Timeout = true - _ = entry.Conn.Close() - // Closing the connection here will cause the ReceiveLoop to exit, - // and the session will be removed from the map there. + timeoutEntry = append(timeoutEntry, entry) } } + m.mutex.RUnlock() + + for _, entry := range timeoutEntry { + // This eventually calls entry.ExitFunc, + // where the m.mutex will be locked again to remove the entry from the map. + entry.CloseWithErr(nil) + } } func (m *udpSessionManager) feed(msg *protocol.UDPMessage) { @@ -183,47 +285,31 @@ func (m *udpSessionManager) feed(msg *protocol.UDPMessage) { // Create a new session if not exists if entry == nil { - // Call the hook - origMsgAddr := msg.Addr - err := m.io.Hook(msg.Data, &msg.Addr) - if err != nil { - return - } - // Log the event - m.eventLogger.New(msg.SessionID, msg.Addr) - // Dial target & create a new session entry - conn, err := m.io.UDP(msg.Addr) - if err != nil { - m.eventLogger.Close(msg.SessionID, err) - return - } - entry = &udpSessionEntry{ - ID: msg.SessionID, - Conn: conn, - D: &frag.Defragger{}, - Last: utils.NewAtomicTime(time.Now()), - } - if origMsgAddr != msg.Addr { - // Hook changed the address, enable address override - entry.OverrideAddr = msg.Addr - } - // Start the receive loop for this session - go func() { - err := entry.ReceiveLoop(m.io) - if !entry.Timeout { - _ = entry.Conn.Close() - m.eventLogger.Close(entry.ID, err) - } else { - // Connection already closed by timeout cleanup, - // no need to close again here. - // Use nil error to indicate timeout. - m.eventLogger.Close(entry.ID, nil) + dialFunc := func(addr string, firstMsgData []byte) (conn UDPConn, actualAddr string, err error) { + // Call the hook + err = m.io.Hook(firstMsgData, &addr) + if err != nil { + return } + actualAddr = addr + // Log the event + m.eventLogger.New(msg.SessionID, addr) + // Dial target + conn, err = m.io.UDP(addr) + return + } + exitFunc := func(err error) { + // Log the event + m.eventLogger.Close(entry.ID, err) + // Remove the session from the map m.mutex.Lock() delete(m.m, entry.ID) m.mutex.Unlock() - }() + } + + entry = newUDPSessionEntry(msg.SessionID, m.io, dialFunc, exitFunc) + // Insert the session into the map m.mutex.Lock() m.m[msg.SessionID] = entry diff --git a/core/server/udp_test.go b/core/server/udp_test.go index 045edbd..8aa899f 100644 --- a/core/server/udp_test.go +++ b/core/server/udp_test.go @@ -25,7 +25,6 @@ func TestUDPSessionManager(t *testing.T) { } return m, nil }) - io.EXPECT().Hook(mock.Anything, mock.Anything).Return(nil) go sm.Run() @@ -50,6 +49,7 @@ func TestUDPSessionManager(t *testing.T) { eventLogger.EXPECT().New(msg1.SessionID, msg1.Addr).Return().Once() udpConn1 := newMockUDPConn(t) udpConn1Ch := make(chan []byte, 1) + io.EXPECT().Hook(msg1.Data, &msg1.Addr).Return(nil).Once() io.EXPECT().UDP(msg1.Addr).Return(udpConn1, nil).Once() udpConn1.EXPECT().WriteTo(msg1.Data, msg1.Addr).Return(5, nil).Once() udpConn1.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(b []byte) (int, string, error) { @@ -66,31 +66,44 @@ func TestUDPSessionManager(t *testing.T) { msgCh <- msg1 udpConn1Ch <- []byte("hi back") - msg2 := &protocol.UDPMessage{ + msg2data := []byte("how are you doing?") + msg2_1 := &protocol.UDPMessage{ SessionID: 5678, PacketID: 0, FragID: 0, - FragCount: 1, + FragCount: 2, Addr: "address2.net:12450", - Data: []byte("how are you"), + Data: msg2data[:6], } - eventLogger.EXPECT().New(msg2.SessionID, msg2.Addr).Return().Once() + msg2_2 := &protocol.UDPMessage{ + SessionID: 5678, + PacketID: 0, + FragID: 1, + FragCount: 2, + Addr: "address2.net:12450", + Data: msg2data[6:], + } + + eventLogger.EXPECT().New(msg2_1.SessionID, msg2_1.Addr).Return().Once() udpConn2 := newMockUDPConn(t) udpConn2Ch := make(chan []byte, 1) - io.EXPECT().UDP(msg2.Addr).Return(udpConn2, nil).Once() - udpConn2.EXPECT().WriteTo(msg2.Data, msg2.Addr).Return(11, nil).Once() + // On fragmentation, make sure hook gets the whole message + io.EXPECT().Hook(msg2data, &msg2_1.Addr).Return(nil).Once() + io.EXPECT().UDP(msg2_1.Addr).Return(udpConn2, nil).Once() + udpConn2.EXPECT().WriteTo(msg2data, msg2_1.Addr).Return(11, nil).Once() udpConn2.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(b []byte) (int, string, error) { - return udpReadFunc(msg2.Addr, udpConn2Ch, b) + return udpReadFunc(msg2_1.Addr, udpConn2Ch, b) }) io.EXPECT().SendMessage(mock.Anything, &protocol.UDPMessage{ - SessionID: msg2.SessionID, + SessionID: msg2_1.SessionID, PacketID: 0, FragID: 0, FragCount: 1, - Addr: msg2.Addr, + Addr: msg2_1.Addr, Data: []byte("im fine"), }).Return(nil).Once() - msgCh <- msg2 + msgCh <- msg2_1 + msgCh <- msg2_2 udpConn2Ch <- []byte("im fine") msg3 := &protocol.UDPMessage{ @@ -123,7 +136,7 @@ func TestUDPSessionManager(t *testing.T) { return nil }).Once() eventLogger.EXPECT().Close(msg1.SessionID, nil).Once() - eventLogger.EXPECT().Close(msg2.SessionID, nil).Once() + eventLogger.EXPECT().Close(msg2_1.SessionID, nil).Once() time.Sleep(3 * time.Second) // Wait for timeout mock.AssertExpectationsForObjects(t, io, eventLogger, udpConn1, udpConn2) @@ -140,6 +153,7 @@ func TestUDPSessionManager(t *testing.T) { } eventLogger.EXPECT().New(msg4.SessionID, msg4.Addr).Return().Once() udpConn4 := newMockUDPConn(t) + io.EXPECT().Hook(msg4.Data, &msg4.Addr).Return(nil).Once() io.EXPECT().UDP(msg4.Addr).Return(udpConn4, nil).Once() udpConn4.EXPECT().WriteTo(msg4.Data, msg4.Addr).Return(12, nil).Once() udpConn4.EXPECT().ReadFrom(mock.Anything).Return(0, "", errUDPClosed).Once() @@ -161,6 +175,7 @@ func TestUDPSessionManager(t *testing.T) { Data: []byte("babe i miss you"), } eventLogger.EXPECT().New(msg5.SessionID, msg5.Addr).Return().Once() + io.EXPECT().Hook(msg5.Data, &msg5.Addr).Return(nil).Once() io.EXPECT().UDP(msg5.Addr).Return(nil, errUDPIO).Once() eventLogger.EXPECT().Close(msg5.SessionID, errUDPIO).Once() msgCh <- msg5 diff --git a/extras/LICENSE.md b/extras/LICENSE.md new file mode 100644 index 0000000..208e8f2 --- /dev/null +++ b/extras/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2023 Toby + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extras/auth/userpass.go b/extras/auth/userpass.go index 8faf87a..9d11cd9 100644 --- a/extras/auth/userpass.go +++ b/extras/auth/userpass.go @@ -16,7 +16,17 @@ var _ server.Authenticator = &UserPassAuthenticator{} // UserPassAuthenticator checks the provided auth string against a map of username/password pairs. // The format of the auth string must be "username:password". type UserPassAuthenticator struct { - Users map[string]string + users map[string]string +} + +func NewUserPassAuthenticator(users map[string]string) *UserPassAuthenticator { + // Usernames are case-insensitive, as they are already lowercased by viper. + // Lowercase it again on our own to make it explicit. + lcUsers := make(map[string]string, len(users)) + for user, pass := range users { + lcUsers[strings.ToLower(user)] = pass + } + return &UserPassAuthenticator{users: lcUsers} } func (a *UserPassAuthenticator) Authenticate(addr net.Addr, auth string, tx uint64) (ok bool, id string) { @@ -24,7 +34,7 @@ func (a *UserPassAuthenticator) Authenticate(addr net.Addr, auth string, tx uint if !ok { return false, "" } - rp, ok := a.Users[u] + rp, ok := a.users[u] if !ok || rp != p { return false, "" } @@ -36,5 +46,6 @@ func splitUserPass(auth string) (user, pass string, ok bool) { if len(rs) != 2 { return "", "", false } - return rs[0], rs[1], true + // Usernames are case-insensitive + return strings.ToLower(rs[0]), rs[1], true } diff --git a/extras/auth/userpass_test.go b/extras/auth/userpass_test.go index 05f788e..0f1b568 100644 --- a/extras/auth/userpass_test.go +++ b/extras/auth/userpass_test.go @@ -85,12 +85,26 @@ func TestUserPassAuthenticator(t *testing.T) { wantOk: false, wantId: "", }, + { + name: "case insensitive username", + fields: fields{ + Users: map[string]string{ + "gawR": "gura", + "fubuki": "shirakami", + }, + }, + args: args{ + addr: nil, + auth: "Gawr:gura", + tx: 0, + }, + wantOk: true, + wantId: "gawr", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - a := &UserPassAuthenticator{ - Users: tt.fields.Users, - } + a := NewUserPassAuthenticator(tt.fields.Users) gotOk, gotId := a.Authenticate(tt.args.addr, tt.args.auth, tt.args.tx) if gotOk != tt.wantOk { t.Errorf("Authenticate() gotOk = %v, want %v", gotOk, tt.wantOk) diff --git a/extras/go.mod b/extras/go.mod index 358ae7d..23b060a 100644 --- a/extras/go.mod +++ b/extras/go.mod @@ -1,24 +1,28 @@ module github.com/apernet/hysteria/extras/v2 -go 1.21 +go 1.23 + +toolchain go1.24.2 require ( github.com/apernet/hysteria/core/v2 v2.0.0-00010101000000-000000000000 - github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 + github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 + github.com/database64128/tfo-go/v2 v2.2.2 github.com/hashicorp/golang-lru/v2 v2.0.5 github.com/miekg/dns v1.1.59 github.com/refraction-networking/utls v1.6.6 github.com/stretchr/testify v1.9.0 github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301 - golang.org/x/crypto v0.24.0 - golang.org/x/net v0.25.0 + golang.org/x/crypto v0.26.0 + golang.org/x/net v0.28.0 google.golang.org/protobuf v1.34.1 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/cloudflare/circl v1.3.9 // indirect + github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect @@ -27,16 +31,16 @@ require ( github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/txthinking/runnergroup v0.0.0-20210608031112-152c7c4432bf // indirect - go.uber.org/mock v0.4.0 // indirect + go.uber.org/mock v0.5.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/extras/go.sum b/extras/go.sum index e8be6d6..ac6d04a 100644 --- a/extras/go.sum +++ b/extras/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167 h1:+jKV1EuDJiUoa4XgRyle5w7wIo+0hil+YyUmwhd4ttk= -github.com/apernet/quic-go v0.46.1-0.20240816230517-268ed2476167/go.mod h1:MjGWpXA31DZZWESdX3/PjIpSWIT1fOm8FNCqyXXFZFU= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431 h1:9/jM7e+kVALd7Jfu1c27dcEpT/Fd/Gzq2OsQjKjakKI= +github.com/apernet/quic-go v0.52.1-0.20250607183305-9320c9d14431/go.mod h1:I/47OIGG5H/IfAm+nz2c6hm6b/NkEhpvptAoiPcY7jQ= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -10,6 +10,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a h1:t4SDi0pmNkryzKdM4QF3o5vqSP4GRjeZD/6j3nyxNP0= +github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a/go.mod h1:7K2NQKbabB5mBl41vF6YayYl5g7YpDwc4dQ5iMpP3Lg= +github.com/database64128/tfo-go/v2 v2.2.2 h1:BxynF4qGF5ct3DpPLEG62uyJZ3LQhqaf0Ken+kyy7PM= +github.com/database64128/tfo-go/v2 v2.2.2/go.mod h1:2IW8jppdBwdVMjA08uEyMNnqiAHKUlqAA+J8NrsfktY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,8 +47,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= -github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/refraction-networking/utls v1.6.6 h1:igFsYBUJPYM8Rno9xUuDoM5GQrVEqY4llzEXOkL43Ig= github.com/refraction-networking/utls v1.6.6/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -62,29 +66,29 @@ github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301/go.mod h1:ntmMHL github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -92,8 +96,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -101,16 +105,14 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/extras/outbounds/fastopen.go b/extras/outbounds/fastopen.go new file mode 100644 index 0000000..1d5d1ee --- /dev/null +++ b/extras/outbounds/fastopen.go @@ -0,0 +1,229 @@ +package outbounds + +import ( + "net" + "sync" + "time" + + "github.com/database64128/tfo-go/v2" +) + +type fastOpenDialer struct { + dialer *tfo.Dialer +} + +func newFastOpenDialer(netDialer *net.Dialer) *fastOpenDialer { + return &fastOpenDialer{ + dialer: &tfo.Dialer{ + Dialer: *netDialer, + }, + } +} + +// Dial returns immediately without actually establishing a connection. +// The connection will be established by the first Write() call. +func (d *fastOpenDialer) Dial(network, address string) (net.Conn, error) { + return &fastOpenConn{ + dialer: d.dialer, + network: network, + address: address, + readyChan: make(chan struct{}), + }, nil +} + +type fastOpenConn struct { + dialer *tfo.Dialer + network string + address string + + conn net.Conn + connLock sync.RWMutex + readyChan chan struct{} + + // States before connection ready + deadline *time.Time + readDeadline *time.Time + writeDeadline *time.Time +} + +func (c *fastOpenConn) Read(b []byte) (n int, err error) { + c.connLock.RLock() + conn := c.conn + c.connLock.RUnlock() + + if conn != nil { + return conn.Read(b) + } + + // Wait until the connection is ready or closed + <-c.readyChan + + if c.conn == nil { + // This is equivalent to isClosedBeforeReady() == true + return 0, net.ErrClosed + } + + return c.conn.Read(b) +} + +func (c *fastOpenConn) Write(b []byte) (n int, err error) { + c.connLock.RLock() + conn := c.conn + c.connLock.RUnlock() + + if conn != nil { + return conn.Write(b) + } + + c.connLock.RLock() + closed := c.isClosedBeforeReady() + c.connLock.RUnlock() + + if closed { + return 0, net.ErrClosed + } + + c.connLock.Lock() + defer c.connLock.Unlock() + + if c.isClosedBeforeReady() { + // Closed by other goroutine + return 0, net.ErrClosed + } + + conn = c.conn + if conn != nil { + // Established by other goroutine + return conn.Write(b) + } + + conn, err = c.dialer.Dial(c.network, c.address, b) + if err != nil { + close(c.readyChan) + return 0, err + } + + // Apply pre-set states + if c.deadline != nil { + _ = conn.SetDeadline(*c.deadline) + } + if c.readDeadline != nil { + _ = conn.SetReadDeadline(*c.readDeadline) + } + if c.writeDeadline != nil { + _ = conn.SetWriteDeadline(*c.writeDeadline) + } + + c.conn = conn + close(c.readyChan) + return len(b), nil +} + +func (c *fastOpenConn) Close() error { + c.connLock.RLock() + defer c.connLock.RUnlock() + + if c.isClosedBeforeReady() { + return net.ErrClosed + } + + if c.conn != nil { + return c.conn.Close() + } + + close(c.readyChan) + return nil +} + +// isClosedBeforeReady returns true if the connection is closed before the real connection is established. +// This function should be called with connLock.RLock(). +func (c *fastOpenConn) isClosedBeforeReady() bool { + select { + case <-c.readyChan: + if c.conn == nil { + return true + } + default: + } + return false +} + +func (c *fastOpenConn) LocalAddr() net.Addr { + c.connLock.RLock() + defer c.connLock.RUnlock() + + if c.conn != nil { + return c.conn.LocalAddr() + } + + return nil +} + +func (c *fastOpenConn) RemoteAddr() net.Addr { + c.connLock.RLock() + conn := c.conn + c.connLock.RUnlock() + + if conn != nil { + return conn.RemoteAddr() + } + + addr, err := net.ResolveTCPAddr(c.network, c.address) + if err != nil { + return nil + } + return addr +} + +func (c *fastOpenConn) SetDeadline(t time.Time) error { + c.connLock.RLock() + defer c.connLock.RUnlock() + + c.deadline = &t + + if c.conn != nil { + return c.conn.SetDeadline(t) + } + + if c.isClosedBeforeReady() { + return net.ErrClosed + } + + return nil +} + +func (c *fastOpenConn) SetReadDeadline(t time.Time) error { + c.connLock.RLock() + defer c.connLock.RUnlock() + + c.readDeadline = &t + + if c.conn != nil { + return c.conn.SetReadDeadline(t) + } + + if c.isClosedBeforeReady() { + return net.ErrClosed + } + + return nil +} + +func (c *fastOpenConn) SetWriteDeadline(t time.Time) error { + c.connLock.RLock() + defer c.connLock.RUnlock() + + c.writeDeadline = &t + + if c.conn != nil { + return c.conn.SetWriteDeadline(t) + } + + if c.isClosedBeforeReady() { + return net.ErrClosed + } + + return nil +} + +var _ net.Conn = (*fastOpenConn)(nil) diff --git a/extras/outbounds/ob_direct.go b/extras/outbounds/ob_direct.go index b80ac00..de7ddd2 100644 --- a/extras/outbounds/ob_direct.go +++ b/extras/outbounds/ob_direct.go @@ -35,8 +35,8 @@ type directOutbound struct { Mode DirectOutboundMode // Dialer4 and Dialer6 are used for IPv4 and IPv6 TCP connections respectively. - Dialer4 *net.Dialer - Dialer6 *net.Dialer + DialFunc4 func(network, address string) (net.Conn, error) + DialFunc6 func(network, address string) (net.Conn, error) // DeviceName & BindIPs are for UDP connections. They don't use dialers, so we // need to bind them when creating the connection. @@ -45,6 +45,16 @@ type directOutbound struct { BindIP6 net.IP } +type DirectOutboundOptions struct { + Mode DirectOutboundMode + + DeviceName string + BindIP4 net.IP + BindIP6 net.IP + + FastOpen bool +} + type noAddressError struct { IPv4 bool IPv6 bool @@ -84,6 +94,57 @@ func (e resolveError) Unwrap() error { return e.Err } +func NewDirectOutboundWithOptions(opts DirectOutboundOptions) (PluggableOutbound, error) { + dialer4 := &net.Dialer{ + Timeout: defaultDialerTimeout, + } + if opts.BindIP4 != nil { + if opts.BindIP4.To4() == nil { + return nil, errors.New("BindIP4 must be an IPv4 address") + } + dialer4.LocalAddr = &net.TCPAddr{ + IP: opts.BindIP4, + } + } + dialer6 := &net.Dialer{ + Timeout: defaultDialerTimeout, + } + if opts.BindIP6 != nil { + if opts.BindIP6.To4() != nil { + return nil, errors.New("BindIP6 must be an IPv6 address") + } + dialer6.LocalAddr = &net.TCPAddr{ + IP: opts.BindIP6, + } + } + if opts.DeviceName != "" { + err := dialerBindToDevice(dialer4, opts.DeviceName) + if err != nil { + return nil, err + } + err = dialerBindToDevice(dialer6, opts.DeviceName) + if err != nil { + return nil, err + } + } + + dialFunc4 := dialer4.Dial + dialFunc6 := dialer6.Dial + if opts.FastOpen { + dialFunc4 = newFastOpenDialer(dialer4).Dial + dialFunc6 = newFastOpenDialer(dialer6).Dial + } + + return &directOutbound{ + Mode: opts.Mode, + DialFunc4: dialFunc4, + DialFunc6: dialFunc6, + DeviceName: opts.DeviceName, + BindIP4: opts.BindIP4, + BindIP6: opts.BindIP6, + }, nil +} + // NewDirectOutboundSimple creates a new directOutbound with the given mode, // without binding to a specific device. Works on all platforms. func NewDirectOutboundSimple(mode DirectOutboundMode) PluggableOutbound { @@ -91,9 +152,9 @@ func NewDirectOutboundSimple(mode DirectOutboundMode) PluggableOutbound { Timeout: defaultDialerTimeout, } return &directOutbound{ - Mode: mode, - Dialer4: d, - Dialer6: d, + Mode: mode, + DialFunc4: d.Dial, + DialFunc6: d.Dial, } } @@ -102,34 +163,20 @@ func NewDirectOutboundSimple(mode DirectOutboundMode) PluggableOutbound { // can be nil, in which case the directOutbound will not bind to a specific address // for that family. func NewDirectOutboundBindToIPs(mode DirectOutboundMode, bindIP4, bindIP6 net.IP) (PluggableOutbound, error) { - if bindIP4 != nil && bindIP4.To4() == nil { - return nil, errors.New("bindIP4 must be an IPv4 address") - } - if bindIP6 != nil && bindIP6.To4() != nil { - return nil, errors.New("bindIP6 must be an IPv6 address") - } - ob := &directOutbound{ - Mode: mode, - Dialer4: &net.Dialer{ - Timeout: defaultDialerTimeout, - }, - Dialer6: &net.Dialer{ - Timeout: defaultDialerTimeout, - }, + return NewDirectOutboundWithOptions(DirectOutboundOptions{ + Mode: mode, BindIP4: bindIP4, BindIP6: bindIP6, - } - if bindIP4 != nil { - ob.Dialer4.LocalAddr = &net.TCPAddr{ - IP: bindIP4, - } - } - if bindIP6 != nil { - ob.Dialer6.LocalAddr = &net.TCPAddr{ - IP: bindIP6, - } - } - return ob, nil + }) +} + +// NewDirectOutboundBindToDevice creates a new directOutbound with the given mode, +// and binds to the given device. Only works on Linux. +func NewDirectOutboundBindToDevice(mode DirectOutboundMode, deviceName string) (PluggableOutbound, error) { + return NewDirectOutboundWithOptions(DirectOutboundOptions{ + Mode: mode, + DeviceName: deviceName, + }) } // resolve is our built-in DNS resolver for handling the case when @@ -201,9 +248,9 @@ func (d *directOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) { func (d *directOutbound) dialTCP(ip net.IP, port uint16) (net.Conn, error) { if ip.To4() != nil { - return d.Dialer4.Dial("tcp4", net.JoinHostPort(ip.String(), strconv.Itoa(int(port)))) + return d.DialFunc4("tcp4", net.JoinHostPort(ip.String(), strconv.Itoa(int(port)))) } else { - return d.Dialer6.Dial("tcp6", net.JoinHostPort(ip.String(), strconv.Itoa(int(port)))) + return d.DialFunc6("tcp6", net.JoinHostPort(ip.String(), strconv.Itoa(int(port)))) } } diff --git a/extras/outbounds/ob_direct_linux.go b/extras/outbounds/ob_direct_linux.go index 33b7d09..5607e50 100644 --- a/extras/outbounds/ob_direct_linux.go +++ b/extras/outbounds/ob_direct_linux.go @@ -6,31 +6,31 @@ import ( "syscall" ) -// NewDirectOutboundBindToDevice creates a new directOutbound with the given mode, -// and binds to the given device. Only works on Linux. -func NewDirectOutboundBindToDevice(mode DirectOutboundMode, deviceName string) (PluggableOutbound, error) { +func dialerBindToDevice(dialer *net.Dialer, deviceName string) error { if err := verifyDeviceName(deviceName); err != nil { - return nil, err + return err } - d := &net.Dialer{ - Timeout: defaultDialerTimeout, - Control: func(network, address string, c syscall.RawConn) error { - var errBind error - err := c.Control(func(fd uintptr) { - errBind = syscall.BindToDevice(int(fd), deviceName) - }) + + originControl := dialer.Control + dialer.Control = func(network, address string, c syscall.RawConn) error { + if originControl != nil { + // Chaining other control function + err := originControl(network, address, c) if err != nil { return err } - return errBind - }, + } + + var errBind error + err := c.Control(func(fd uintptr) { + errBind = syscall.BindToDevice(int(fd), deviceName) + }) + if err != nil { + return err + } + return errBind } - return &directOutbound{ - Mode: mode, - Dialer4: d, - Dialer6: d, - DeviceName: deviceName, - }, nil + return nil } func verifyDeviceName(deviceName string) error { diff --git a/extras/outbounds/ob_direct_others.go b/extras/outbounds/ob_direct_others.go index b416c30..eeedc84 100644 --- a/extras/outbounds/ob_direct_others.go +++ b/extras/outbounds/ob_direct_others.go @@ -7,11 +7,8 @@ import ( "net" ) -// NewDirectOutboundBindToDevice creates a new directOutbound with the given mode, -// and binds to the given device. This doesn't work on non-Linux platforms, so this -// is just a stub function that always returns an error. -func NewDirectOutboundBindToDevice(mode DirectOutboundMode, deviceName string) (PluggableOutbound, error) { - return nil, errors.New("binding to device is not supported on this platform") +func dialerBindToDevice(dialer *net.Dialer, deviceName string) error { + return errors.New("binding to device is not supported on this platform") } func udpConnBindToDevice(conn *net.UDPConn, deviceName string) error { diff --git a/extras/trafficlogger/http.go b/extras/trafficlogger/http.go index 9ab943a..8b26217 100644 --- a/extras/trafficlogger/http.go +++ b/extras/trafficlogger/http.go @@ -1,12 +1,18 @@ package trafficlogger import ( + "cmp" "encoding/json" + "fmt" "net/http" + "slices" "strconv" + "strings" "sync" + "time" "github.com/apernet/hysteria/core/v2/server" + "github.com/apernet/quic-go" ) const ( @@ -25,6 +31,7 @@ func NewTrafficStatsServer(secret string) TrafficStatsServer { StatsMap: make(map[string]*trafficStatsEntry), KickMap: make(map[string]struct{}), OnlineMap: make(map[string]int), + StreamMap: make(map[quic.Stream]*server.StreamStats), Secret: secret, } } @@ -33,6 +40,7 @@ type trafficStatsServerImpl struct { Mutex sync.RWMutex StatsMap map[string]*trafficStatsEntry OnlineMap map[string]int + StreamMap map[quic.Stream]*server.StreamStats KickMap map[string]struct{} Secret string } @@ -63,7 +71,7 @@ func (s *trafficStatsServerImpl) LogTraffic(id string, tx, rx uint64) (ok bool) return true } -// LogOnlineStateChanged updates the online state to the online map. +// LogOnlineState updates the online state to the online map. func (s *trafficStatsServerImpl) LogOnlineState(id string, online bool) { s.Mutex.Lock() defer s.Mutex.Unlock() @@ -78,6 +86,20 @@ func (s *trafficStatsServerImpl) LogOnlineState(id string, online bool) { } } +func (s *trafficStatsServerImpl) TraceStream(stream quic.Stream, stats *server.StreamStats) { + s.Mutex.Lock() + defer s.Mutex.Unlock() + + s.StreamMap[stream] = stats +} + +func (s *trafficStatsServerImpl) UntraceStream(stream quic.Stream) { + s.Mutex.Lock() + defer s.Mutex.Unlock() + + delete(s.StreamMap, stream) +} + func (s *trafficStatsServerImpl) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s.Secret != "" && r.Header.Get("Authorization") != s.Secret { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -99,6 +121,10 @@ func (s *trafficStatsServerImpl) ServeHTTP(w http.ResponseWriter, r *http.Reques s.getOnline(w, r) return } + if r.Method == http.MethodGet && r.URL.Path == "/dump/streams" { + s.getDumpStreams(w, r) + return + } http.NotFound(w, r) } @@ -137,6 +163,126 @@ func (s *trafficStatsServerImpl) getOnline(w http.ResponseWriter, r *http.Reques _, _ = w.Write(jb) } +type dumpStreamEntry struct { + State string `json:"state"` + + Auth string `json:"auth"` + Connection uint32 `json:"connection"` + Stream uint64 `json:"stream"` + + ReqAddr string `json:"req_addr"` + HookedReqAddr string `json:"hooked_req_addr"` + + Tx uint64 `json:"tx"` + Rx uint64 `json:"rx"` + + InitialAt string `json:"initial_at"` + LastActiveAt string `json:"last_active_at"` + + // for text/plain output + initialTime time.Time + lastActiveTime time.Time +} + +func (e *dumpStreamEntry) fromStreamStats(stream quic.Stream, s *server.StreamStats) { + e.State = s.State.Load().String() + e.Auth = s.AuthID + e.Connection = s.ConnID + e.Stream = uint64(stream.StreamID()) + e.ReqAddr = s.ReqAddr.Load() + e.HookedReqAddr = s.HookedReqAddr.Load() + e.Tx = s.Tx.Load() + e.Rx = s.Rx.Load() + e.initialTime = s.InitialTime + e.lastActiveTime = s.LastActiveTime.Load() + e.InitialAt = e.initialTime.Format(time.RFC3339Nano) + e.LastActiveAt = e.lastActiveTime.Format(time.RFC3339Nano) +} + +func formatDumpStreamLine(state, auth, connection, stream, reqAddr, hookedReqAddr, tx, rx, lifetime, lastActive string) string { + return fmt.Sprintf("%-8s %-12s %12s %8s %12s %12s %12s %12s %-16s %s", state, auth, connection, stream, tx, rx, lifetime, lastActive, reqAddr, hookedReqAddr) +} + +func (e *dumpStreamEntry) String() string { + stateText := strings.ToUpper(e.State) + connectionText := fmt.Sprintf("%08X", e.Connection) + streamText := strconv.FormatUint(e.Stream, 10) + reqAddrText := e.ReqAddr + if reqAddrText == "" { + reqAddrText = "-" + } + hookedReqAddrText := e.HookedReqAddr + if hookedReqAddrText == "" { + hookedReqAddrText = "-" + } + txText := strconv.FormatUint(e.Tx, 10) + rxText := strconv.FormatUint(e.Rx, 10) + lifetime := time.Now().Sub(e.initialTime) + if lifetime < 10*time.Minute { + lifetime = lifetime.Round(time.Millisecond) + } else { + lifetime = lifetime.Round(time.Second) + } + lastActive := time.Now().Sub(e.lastActiveTime) + if lastActive < 10*time.Minute { + lastActive = lastActive.Round(time.Millisecond) + } else { + lastActive = lastActive.Round(time.Second) + } + + return formatDumpStreamLine(stateText, e.Auth, connectionText, streamText, reqAddrText, hookedReqAddrText, txText, rxText, lifetime.String(), lastActive.String()) +} + +func (s *trafficStatsServerImpl) getDumpStreams(w http.ResponseWriter, r *http.Request) { + var entries []dumpStreamEntry + + s.Mutex.RLock() + entries = make([]dumpStreamEntry, len(s.StreamMap)) + index := 0 + for stream, stats := range s.StreamMap { + entries[index].fromStreamStats(stream, stats) + index++ + } + s.Mutex.RUnlock() + + slices.SortFunc(entries, func(lhs, rhs dumpStreamEntry) int { + if ret := cmp.Compare(lhs.Auth, rhs.Auth); ret != 0 { + return ret + } + if ret := cmp.Compare(lhs.Connection, rhs.Connection); ret != 0 { + return ret + } + if ret := cmp.Compare(lhs.Stream, rhs.Stream); ret != 0 { + return ret + } + return 0 + }) + + accept := r.Header.Get("Accept") + + if strings.Contains(accept, "text/plain") { + // Generate netstat-like output for humans + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + // Print table header + _, _ = fmt.Fprintln(w, formatDumpStreamLine("State", "Auth", "Connection", "Stream", "Req-Addr", "Hooked-Req-Addr", "TX-Bytes", "RX-Bytes", "Lifetime", "Last-Active")) + for _, entry := range entries { + _, _ = fmt.Fprintln(w, entry.String()) + } + return + } + + // Response with json by default + wrapper := struct { + Streams []dumpStreamEntry `json:"streams"` + }{entries} + w.Header().Set("Content-Type", "application/json; charset=utf-8") + err := json.NewEncoder(w).Encode(&wrapper) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func (s *trafficStatsServerImpl) kick(w http.ResponseWriter, r *http.Request) { var ids []string err := json.NewDecoder(r.Body).Decode(&ids) diff --git a/extras/utils/portunion.go b/extras/utils/portunion.go index 20a31d0..f76a6fd 100644 --- a/extras/utils/portunion.go +++ b/extras/utils/portunion.go @@ -74,7 +74,7 @@ func (u PortUnion) Normalize() PortUnion { normalized := PortUnion{u[0]} for _, current := range u[1:] { last := &normalized[len(normalized)-1] - if current.Start <= last.End+1 { + if uint32(current.Start) <= uint32(last.End)+1 { if current.End > last.End { last.End = current.End } @@ -89,8 +89,8 @@ func (u PortUnion) Normalize() PortUnion { func (u PortUnion) Ports() []uint16 { var ports []uint16 for _, r := range u { - for i := r.Start; i <= r.End; i++ { - ports = append(ports, i) + for i := uint32(r.Start); i <= uint32(r.End); i++ { + ports = append(ports, uint16(i)) } } return ports diff --git a/extras/utils/portunion_test.go b/extras/utils/portunion_test.go index 551bae1..ba056a3 100644 --- a/extras/utils/portunion_test.go +++ b/extras/utils/portunion_test.go @@ -2,6 +2,7 @@ package utils import ( "reflect" + "slices" "testing" ) @@ -51,6 +52,16 @@ func TestParsePortUnion(t *testing.T) { s: "5678,1200-1236,9100-9012,1234-1240", want: PortUnion{{1200, 1240}, {5678, 5678}, {9012, 9100}}, }, + { + name: "multiple ports and ranges with 65535 (reversed, unsorted, overlapping)", + s: "5678,1200-1236,65531-65535,65532-65534,9100-9012,1234-1240", + want: PortUnion{{1200, 1240}, {5678, 5678}, {9012, 9100}, {65531, 65535}}, + }, + { + name: "multiple ports and ranges with 65535 (reversed, unsorted, overlapping) 2", + s: "5678,1200-1236,65532-65535,65531-65534,9100-9012,1234-1240", + want: PortUnion{{1200, 1240}, {5678, 5678}, {9012, 9100}, {65531, 65535}}, + }, { name: "invalid 1", s: "1234-", @@ -90,3 +101,50 @@ func TestParsePortUnion(t *testing.T) { }) } } + +func TestPortUnion_Ports(t *testing.T) { + tests := []struct { + name string + pu PortUnion + want []uint16 + }{ + { + name: "single port", + pu: PortUnion{{1234, 1234}}, + want: []uint16{1234}, + }, + { + name: "multiple ports", + pu: PortUnion{{1234, 1236}}, + want: []uint16{1234, 1235, 1236}, + }, + { + name: "multiple ports and ranges", + pu: PortUnion{{1234, 1236}, {5678, 5680}, {9000, 9002}}, + want: []uint16{1234, 1235, 1236, 5678, 5679, 5680, 9000, 9001, 9002}, + }, + { + name: "single port 65535", + pu: PortUnion{{65535, 65535}}, + want: []uint16{65535}, + }, + { + name: "port range with 65535", + pu: PortUnion{{65530, 65535}}, + want: []uint16{65530, 65531, 65532, 65533, 65534, 65535}, + }, + { + name: "multiple ports and ranges with 65535", + pu: PortUnion{{65530, 65535}, {1234, 1236}}, + want: []uint16{65530, 65531, 65532, 65533, 65534, 65535, 1234, 1235, 1236}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.pu.Ports(); !slices.Equal(got, tt.want) { + t.Errorf("PortUnion.Ports() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.work b/go.work index bde9a9c..1d4fa8b 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,6 @@ -go 1.21 +go 1.23 + +toolchain go1.24.2 use ( ./app diff --git a/go.work.sum b/go.work.sum index 44dac15..95d2882 100644 --- a/go.work.sum +++ b/go.work.sum @@ -290,6 +290,7 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk= @@ -298,6 +299,7 @@ golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852 h1:xYq6+9AtI+xP3M4r0N1hCkHr golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -310,6 +312,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= @@ -327,6 +331,8 @@ golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/hyperbole.py b/hyperbole.py index fc38eba..13dcd0a 100755 --- a/hyperbole.py +++ b/hyperbole.py @@ -74,6 +74,9 @@ ARCH_ALIASES = { "GOARCH": "amd64", "GOAMD64": "v3", }, + "loong64": { + "GOARCH": "loong64", + }, } @@ -145,12 +148,33 @@ def get_app_commit(): return app_commit +def get_toolchain(): + try: + output = subprocess.check_output(["go", "version"]).decode().strip() + if output.startswith("go version "): + output = output[11:] + return output + except Exception: + return "Unknown" + + def get_current_os_arch(): d_os = subprocess.check_output(["go", "env", "GOOS"]).decode().strip() d_arch = subprocess.check_output(["go", "env", "GOARCH"]).decode().strip() return (d_os, d_arch) +def get_lib_version(): + try: + with open(CORE_SRC_DIR + "/go.mod") as f: + for line in f: + line = line.strip() + if line.startswith("github.com/apernet/quic-go"): + return line.split(" ")[1].strip() + except Exception: + return "Unknown" + + def get_app_platforms(): platforms = os.environ.get("HY_APP_PLATFORMS") if not platforms: @@ -176,8 +200,12 @@ def cmd_build(pprof=False, release=False, race=False): os.makedirs(BUILD_DIR, exist_ok=True) app_version = get_app_version() - app_date = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + app_date = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + app_toolchain = get_toolchain() app_commit = get_app_commit() + lib_version = get_lib_version() ldflags = [ "-X", @@ -190,7 +218,11 @@ def cmd_build(pprof=False, release=False, race=False): + ("release" if release else "dev") + ("-pprof" if pprof else ""), "-X", + '"' + APP_SRC_CMD_PKG + ".appToolchain=" + app_toolchain + '"', + "-X", APP_SRC_CMD_PKG + ".appCommit=" + app_commit, + "-X", + APP_SRC_CMD_PKG + ".libVersion=" + lib_version, ] if release: ldflags.append("-s") @@ -267,8 +299,12 @@ def cmd_run(args, pprof=False, race=False): return app_version = get_app_version() - app_date = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + app_date = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + app_toolchain = get_toolchain() app_commit = get_app_commit() + lib_version = get_lib_version() current_os, current_arch = get_current_os_arch() @@ -280,11 +316,15 @@ def cmd_run(args, pprof=False, race=False): "-X", APP_SRC_CMD_PKG + ".appType=dev-run", "-X", + '"' + APP_SRC_CMD_PKG + ".appToolchain=" + app_toolchain + '"', + "-X", APP_SRC_CMD_PKG + ".appCommit=" + app_commit, "-X", APP_SRC_CMD_PKG + ".appPlatform=" + current_os, "-X", APP_SRC_CMD_PKG + ".appArch=" + current_arch, + "-X", + APP_SRC_CMD_PKG + ".libVersion=" + lib_version, ] cmd = ["go", "run", "-ldflags", " ".join(ldflags)] diff --git a/platforms.txt b/platforms.txt index ea0ddf3..9059333 100644 --- a/platforms.txt +++ b/platforms.txt @@ -22,6 +22,7 @@ linux/s390x linux/mipsle linux/mipsle-sf linux/riscv64 +linux/loong64 # Android android/386 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..44ee651 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.8.2 +cffi==1.17.0 +click==8.1.7 +cryptography==43.0.0 +Flask==3.0.3 +itsdangerous==2.2.0 +Jinja2==3.1.4 +MarkupSafe==2.1.5 +pycparser==2.22 +PySocks==1.7.1 +Werkzeug==3.0.4 diff --git a/scripts/install_server.sh b/scripts/install_server.sh index d222f33..93c06d9 100644 --- a/scripts/install_server.sh +++ b/scripts/install_server.sh @@ -436,6 +436,9 @@ check_environment_architecture() { 's390x') ARCHITECTURE='s390x' ;; + 'loongarch64') + ARCHITECTURE='loong64' + ;; *) error "The architecture '$(uname -a)' is not supported." note "Specify ARCHITECTURE= to bypass this check and force this script to run on this $(uname -m)." @@ -465,7 +468,7 @@ check_environment_systemd() { } check_environment_selinux() { - if ! has_command chcon; then + if ! has_command getenforce; then return fi @@ -872,7 +875,7 @@ is_hysteria1_version() { get_installed_version() { if is_hysteria_installed; then if "$EXECUTABLE_INSTALL_PATH" version > /dev/null 2>&1; then - "$EXECUTABLE_INSTALL_PATH" version | grep Version | grep -o 'v[.0-9]*' + "$EXECUTABLE_INSTALL_PATH" version | grep '^Version' | grep -o 'v[.0-9]*' elif "$EXECUTABLE_INSTALL_PATH" -v > /dev/null 2>&1; then # hysteria 1 "$EXECUTABLE_INSTALL_PATH" -v | cut -d ' ' -f 3