diff --git a/app/geodata/download.go b/app/geodata/download.go index 11218e7fa..4d941af58 100644 --- a/app/geodata/download.go +++ b/app/geodata/download.go @@ -21,7 +21,10 @@ import ( "golang.org/x/net/http2" ) -const idleTimeout = 30 * time.Second +const ( + idleTimeout = 30 * time.Second + maxDownloadSize int64 = 256 * 1024 * 1024 +) // DO NOT EDIT. Ignored by geodata, rejected by Lua. const luaGuardHeader = "\x12\x01\x00" @@ -33,7 +36,6 @@ type stage struct { type downloader struct { ctx context.Context - httpClient *http.Client httpsClient *http.Client } @@ -61,12 +63,11 @@ func (c *idleConn) Write(b []byte) (int, error) { func newDownloader(ctx context.Context, dispatcher routing.Dispatcher, outbound string) *downloader { return &downloader{ ctx: ctx, - httpClient: newClient(ctx, dispatcher, outbound, false), - httpsClient: newClient(ctx, dispatcher, outbound, true), + httpsClient: newClient(ctx, dispatcher, outbound), } } -func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound string, isHTTPS bool) *http.Client { +func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound string) *http.Client { dial := func(ctx context.Context, network, address string) (net.Conn, error) { var conn net.Conn err := task.Run(ctx, func() error { @@ -91,53 +92,33 @@ func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound Conn: conn, }, nil } - if isHTTPS { - return &http.Client{ - Transport: &http2.Transport{ - DialTLSContext: func(ctx context.Context, network string, address string, cfg *tls.Config) (net.Conn, error) { - conn, err := dial(ctx, network, address) - if err != nil { - return nil, err - } - host, _, _ := net.SplitHostPort(address) - tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloChrome_Auto) - handshakeCtx, cancel := context.WithTimeout(ctx, idleTimeout) - defer cancel() - if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { - conn.Close() - return nil, err - } - return tlsConn, nil - }, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - return errors.New("redirected to non-https URL: ", req.URL.String()) + return &http.Client{ + Transport: &http2.Transport{ + DialTLSContext: func(ctx context.Context, network string, address string, cfg *tls.Config) (net.Conn, error) { + conn, err := dial(ctx, network, address) + if err != nil { + return nil, err } - if len(via) >= 10 { - return errors.New("stopped after 10 redirects") + host, _, _ := net.SplitHostPort(address) + tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloChrome_Auto) + handshakeCtx, cancel := context.WithTimeout(ctx, idleTimeout) + defer cancel() + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { + conn.Close() + return nil, err } - return nil + return tlsConn, nil }, - } - } else { - return &http.Client{ - Transport: &http.Transport{ - Proxy: nil, - DisableKeepAlives: true, - DialContext: dial, - ResponseHeaderTimeout: idleTimeout, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - return errors.New("redirected to non-https URL: ", req.URL.String()) - } - if len(via) >= 10 { - return errors.New("stopped after 10 redirects") - } - return nil - }, - } + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return errors.New("redirected to non-https URL: ", req.URL.String()) + } + if len(via) >= 20 { + return errors.New("stopped after 20 redirects") + } + return nil + }, } } @@ -203,27 +184,26 @@ func (d *downloader) fetch(rawURL string, writer io.Writer) error { } utils.TryDefaultHeadersWith(req.Header, "nav") - var client *http.Client - if req.URL.Scheme == "https" { - client = d.httpsClient - } else { - client = d.httpClient - } - resp, err := client.Do(req) + resp, err := d.httpsClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - io.Copy(io.Discard, resp.Body) return errors.New("unexpected status code: ", resp.StatusCode) } + if resp.ContentLength > maxDownloadSize { + return errors.New("geodata download exceeds 256 MiB") + } - n, err := io.Copy(writer, resp.Body) + n, err := io.Copy(writer, io.LimitReader(resp.Body, maxDownloadSize+1)) if err != nil { return err } + if n > maxDownloadSize { + return errors.New("geodata download exceeds 256 MiB") + } if n == 0 { return errors.New("empty response body") } diff --git a/app/geodata/download_test.go b/app/geodata/download_test.go index c7e225aa7..bfe07f0bc 100644 --- a/app/geodata/download_test.go +++ b/app/geodata/download_test.go @@ -7,10 +7,13 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" commongeodata "github.com/xtls/xray-core/common/geodata" xlua "github.com/xtls/xray-core/common/lua" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/core" "github.com/yuin/gopher-lua/parse" "google.golang.org/protobuf/proto" ) @@ -31,11 +34,11 @@ func TestDownloadOneLuaGuard(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "asset.lua"), nil, 0o600); err != nil { t.Fatal(err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(tc.body)) })) defer server.Close() - d := &downloader{ctx: context.Background(), httpClient: server.Client()} + d := &downloader{ctx: context.Background(), httpsClient: server.Client()} staged, err := d.downloadOne(&Asset{Url: server.URL, File: "asset.lua"}) if err != nil { t.Fatal(err) @@ -71,3 +74,53 @@ func TestDownloadOneLuaGuard(t *testing.T) { }) } } + +func TestProtobufConfigValidation(t *testing.T) { + dir := t.TempDir() + t.Setenv("xray.location.asset", dir) + for _, name := range []string{"asset.dat", "asset.DAT"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(dir, "directory.dat"), 0o700); err != nil { + t.Fatal(err) + } + server, err := core.New(&core.Config{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + for _, tc := range []struct { + name string + config *Config + wantError string + }{ + {"minutely cron", &Config{Cron: "* * * * *"}, ""}, + {"minimum interval", &Config{Cron: "@every 1m"}, ""}, + {"short interval", &Config{Cron: "@every 59s"}, "interval must be at least 1 minute"}, + {"fractional interval", &Config{Cron: "@every 59.999s"}, "interval must be at least 1 minute"}, + {"invalid cron", &Config{Cron: "invalid"}, "invalid geodata cron"}, + {"dat extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.dat"}}}, ""}, + {"uppercase extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.DAT"}}}, ""}, + {"invalid extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.lua"}}}, "extension must be .dat"}, + {"missing extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset"}}}, "extension must be .dat"}, + {"missing file", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "missing.dat"}}}, "invalid geodata asset file"}, + {"directory", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "directory.dat"}}}, "asset is not a regular file"}, + } { + t.Run(tc.name, func(t *testing.T) { + config, err := serial.ToTypedMessage(tc.config).GetInstance() + if err != nil { + t.Fatal(err) + } + _, err = core.CreateObject(server, config) + if tc.wantError == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("CreateObject() error = %v; want %q", err, tc.wantError) + } + }) + } +} diff --git a/app/geodata/geodata.go b/app/geodata/geodata.go index 5b0111a81..25187b455 100644 --- a/app/geodata/geodata.go +++ b/app/geodata/geodata.go @@ -2,12 +2,16 @@ package geodata import ( "context" + "path/filepath" + "strings" "sync" + "time" "github.com/robfig/cron/v3" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/errors" commongeodata "github.com/xtls/xray-core/common/geodata" + "github.com/xtls/xray-core/common/platform/filesystem" "github.com/xtls/xray-core/core" "github.com/xtls/xray-core/features/routing" ) @@ -25,6 +29,24 @@ func New(ctx context.Context, config *Config) (*Instance, error) { if config.Cron == "" { return &Instance{}, nil } + if len(config.Assets) > 100 { + return nil, errors.New("geodata supports at most 100 assets") + } + for _, asset := range config.Assets { + if !strings.EqualFold(filepath.Ext(asset.GetFile()), ".dat") { + return nil, errors.New("geodata asset file extension must be .dat") + } + if _, err := filesystem.ResolveAsset(asset.GetFile()); err != nil { + return nil, errors.New("invalid geodata asset file: ", asset.GetFile()).Base(err) + } + } + schedule, err := cron.ParseStandard(config.Cron) + if err != nil { + return nil, errors.New("invalid geodata cron").Base(err) + } + if interval, ok := schedule.(cron.ConstantDelaySchedule); ok && interval.Delay < time.Minute { + return nil, errors.New("geodata cron interval must be at least 1 minute") + } g := &Instance{ assets: config.Assets, @@ -44,9 +66,7 @@ func New(ctx context.Context, config *Config) (*Instance, error) { cron.WithChain(cron.SkipIfStillRunning(cron.DiscardLogger)), cron.WithLogger(cron.DiscardLogger), ) - if _, err := g.tasker.AddFunc(config.Cron, g.execute); err != nil { - return nil, errors.New("invalid geodata cron").Base(err) - } + g.tasker.Schedule(schedule, cron.FuncJob(g.execute)) errors.LogInfo(ctx, "scheduled geodata reload with cron: ", config.Cron) return g, nil diff --git a/infra/conf/geodata.go b/infra/conf/geodata.go index 13e01a072..20e7520a2 100644 --- a/infra/conf/geodata.go +++ b/infra/conf/geodata.go @@ -6,7 +6,6 @@ import ( "github.com/robfig/cron/v3" "github.com/xtls/xray-core/app/geodata" "github.com/xtls/xray-core/common/errors" - "github.com/xtls/xray-core/common/platform/filesystem" "google.golang.org/protobuf/proto" ) @@ -19,9 +18,6 @@ func (c *GeodataAssetConfig) Build() (*geodata.Asset, error) { if err := validateHTTPS(c.URL); err != nil { return nil, errors.New("invalid geodata asset url: ", c.URL).Base(err) } - if _, err := filesystem.ResolveAsset(c.File); err != nil { - return nil, errors.New("invalid geodata asset file: ", c.File).Base(err) - } return &geodata.Asset{ Url: c.URL, File: c.File, diff --git a/infra/conf/geodata_test.go b/infra/conf/geodata_test.go index d75d04527..4ce47c886 100644 --- a/infra/conf/geodata_test.go +++ b/infra/conf/geodata_test.go @@ -1,7 +1,6 @@ package conf_test import ( - "path/filepath" "testing" "github.com/xtls/xray-core/app/geodata" @@ -9,8 +8,6 @@ import ( ) func TestGeodataConfig(t *testing.T) { - t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources")) - creator := func() Buildable { return new(GeodataConfig) } @@ -38,27 +35,7 @@ func TestGeodataConfig(t *testing.T) { }) } -func TestGeodataAssetConfig(t *testing.T) { - t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources")) - - if _, err := (&GeodataAssetConfig{ - URL: "https://example.com/geoip.dat", - File: "geoip.dat", - }).Build(); err != nil { - t.Fatal(err) - } - - if _, err := (&GeodataAssetConfig{ - URL: "https://example.com/geoip.dat", - File: "missing.dat", - }).Build(); err == nil { - t.Fatal("expected error") - } -} - func TestGeodataAssetConfigInvalidURL(t *testing.T) { - t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources")) - for _, rawURL := range []string{ "", "http://example.com/geoip.dat",