diff --git a/go.mod b/go.mod index b31660a1..97970d6c 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 github.com/qiniu/api.v7/v7 v7.8.2 - github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 + github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb github.com/qor5/go-bus v0.1.2 github.com/qor5/go-que v1.1.0 github.com/qor5/kx v0.0.0-20260713082723-dc32af6f8fd6 @@ -45,7 +45,7 @@ require ( github.com/rs/cors v1.11.1 github.com/rs/xid v1.6.0 github.com/samber/lo v1.52.0 - github.com/spf13/cast v1.7.1 + github.com/spf13/cast v1.9.2 github.com/stretchr/testify v1.11.1 github.com/sunfmin/reflectutils v1.0.6 github.com/testcontainers/testcontainers-go v0.42.0 @@ -118,8 +118,8 @@ require ( github.com/ebitengine/purego v0.10.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 // indirect github.com/go-logfmt/logfmt v0.5.1 // indirect @@ -131,8 +131,9 @@ require ( github.com/go-playground/form/v4 v4.2.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.25.0 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.3.6 // indirect github.com/gorilla/context v1.1.2 // indirect @@ -143,7 +144,6 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -155,7 +155,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/client v0.4.0 // indirect @@ -172,20 +171,19 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/ory/pagination v0.0.1 // indirect github.com/pborman/uuid v1.2.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/redis/go-redis/v9 v9.16.0 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/spf13/viper v1.19.0 // indirect + github.com/spf13/viper v1.20.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 // indirect @@ -204,12 +202,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gorm.io/datatypes v1.2.7 // indirect gorm.io/driver/mysql v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 4ced66b6..df817c8e 100644 --- a/go.sum +++ b/go.sum @@ -159,10 +159,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -198,11 +198,13 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= -github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -251,8 +253,6 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= @@ -314,8 +314,6 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -359,8 +357,8 @@ github.com/ory/pagination v0.0.1 h1:Zp+0n/UXSGYlJAMN0BuRjZhULsQRebGHfqByKtZXNYI= github.com/ory/pagination v0.0.1/go.mod h1:d1ToRROAUleriPhmb2dYbhANhhLwZ8s395m2yJCDFh8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -376,8 +374,8 @@ github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/qiniu/api.v7/v7 v7.8.2 h1:f08kI0MmsJNzK4sUS8bG3HDH67ktwd/ji23Gkiy2ra4= github.com/qiniu/api.v7/v7 v7.8.2/go.mod h1:FPsIqxh1Ym3X01sANE5ZwXfLZSWoCUp5+jNI8cLo3l0= -github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 h1:sO/saPkFgwfLaiCVTg+e9x6lAYBrqY91h1REu4NLMm0= -github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54/go.mod h1:03dPo1SHYn9sU57mH67Y1p9FIcglWaHr4i/xkeYmX4o= +github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb h1:ret+vc4A9xsYTjlhBbsahak1lbT+3tlAWLk/EyB+QbI= +github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb/go.mod h1:gD6PmeWoKN36hzDv+3DK73PPvTMftOoazmDwFQ1IZ+8= github.com/qor5/go-bus v0.1.2 h1:R/4uRTqqDUDqd2VZwlzHE+zVurFRMfxSyXOn1Me7dy8= github.com/qor5/go-bus v0.1.2/go.mod h1:VSVJetwyy8ljDMGbRYdk9mW7Mb1wet89UZSp5avh0YQ= github.com/qor5/go-que v1.1.0 h1:jv7BYovZTXRwpshzvaolZezVILlny++AjeMdV/MV/Tc= @@ -395,10 +393,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= +github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= @@ -412,12 +408,12 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -512,8 +508,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -604,8 +598,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/gormx/database.go b/gormx/database.go index 06b0311f..b0e2c7d3 100644 --- a/gormx/database.go +++ b/gormx/database.go @@ -43,13 +43,26 @@ type IAMDialectorConfig struct { } type DatabaseConfig struct { - DSN string `confx:"dsn" usage:"Database connection string" validate:"required"` - Debug bool `confx:"debug" usage:"Enable debug mode"` - Tracing TracingConfig `confx:"tracing" usage:"Tracing configuration"` - MaxIdleConns int `confx:"maxIdleConns" usage:"Maximum number of idle connections" validate:"ltefield=MaxOpenConns"` - MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum number of open connections"` - ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` - ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections" validate:"ltefield=ConnMaxLifetime"` + DSN string `confx:"dsn" usage:"Database connection string" validate:"required"` + Debug bool `confx:"debug" usage:"Enable debug mode"` + Tracing TracingConfig `confx:"tracing" usage:"Tracing configuration"` + // MaxIdleConns is the number of idle connections database/sql keeps in the + // pool; connections beyond it are closed when returned. It does not cap how + // many connections may be open. + // + // stop_if guards the ltefield: MaxOpenConns == 0 means unlimited, so it is + // not an upper bound to compare against, and a plain `ltefield` would + // reject the (20, 0) pairing. + MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept in the pool" validate:"stop_if=MaxOpenConns 0,ltefield=MaxOpenConns"` + // MaxOpenConns caps concurrent connections. 0 (the default) means + // unlimited, matching database/sql's own default: past the cap, callers + // block inside sql.DB waiting for a connection to be returned, and the wait + // is only visible through DBStats.WaitCount. + MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited"` + ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` + // Same shape as MaxIdleConns above: ConnMaxLifetime == 0 means connections + // are never recycled, so it is not an upper bound either. + ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections" validate:"stop_if=ConnMaxLifetime 0,ltefield=ConnMaxLifetime"` AuthMethod AuthMethod `confx:"authMethod" usage:"Authentication method: 'password' or 'iam'" validate:"required,oneof=password iam"` IAM IAMDialectorConfig `confx:"iam" validate:"skip_nested_unless=AuthMethod iam" usage:"IAM configuration"` } diff --git a/gormx/database_test.go b/gormx/database_test.go index f972d224..131d870f 100644 --- a/gormx/database_test.go +++ b/gormx/database_test.go @@ -79,7 +79,11 @@ func TestConfig(t *testing.T) { }, }, { - Name: "invalid config - connection constraints", + // Both pairings still validate here, but each ltefield is guarded by + // a stop_if: 0 on the right-hand side means "no limit", so it is not + // an upper bound and the comparison must not run. Below, both limits + // ARE set, so both comparisons apply and both are violated. + Name: "invalid config - idle above a real cap, idleTime beyond a real lifetime", Config: &gormx.DatabaseConfig{ DSN: "postgres://user:pass@localhost:5432/db", Debug: true, @@ -95,6 +99,23 @@ func TestConfig(t *testing.T) { {Path: "ConnMaxIdleTime", Tag: "ltefield"}, }, }, + { + // The new default. Before this change maxOpenConns defaulted to 200 + // and MaxIdleConns carried `ltefield=MaxOpenConns`, so flipping the + // default to 0 would have made 20 <= 0 fail and every consumer would + // have failed to start. + Name: "valid config - unlimited maxOpenConns with warm idle pool", + Config: &gormx.DatabaseConfig{ + DSN: "postgres://user:pass@localhost:5432/db", + Tracing: gormx.TracingConfig{}, + MaxIdleConns: 20, + MaxOpenConns: 0, + ConnMaxIdleTime: 10 * time.Minute, + ConnMaxLifetime: 30 * time.Minute, + AuthMethod: gormx.AuthMethodPassword, + }, + ExpectedErrors: nil, + }, { Name: "invalid config - auth method", Config: &gormx.DatabaseConfig{ @@ -117,7 +138,7 @@ func TestConfig(t *testing.T) { DSN: "", // empty dsn Debug: true, Tracing: gormx.TracingConfig{}, - MaxIdleConns: 11, // maxIdleConns > maxOpenConns + MaxIdleConns: 11, // above MaxOpenConns, and MaxOpenConns is a real cap MaxOpenConns: 10, ConnMaxIdleTime: 30 * time.Minute, // maxIdleTime > maxLifetime ConnMaxLifetime: 10 * time.Minute, diff --git a/gormx/embed/default-database-config.yaml b/gormx/embed/default-database-config.yaml index 6286927c..117b0ac8 100644 --- a/gormx/embed/default-database-config.yaml +++ b/gormx/embed/default-database-config.yaml @@ -6,7 +6,9 @@ database: excludeQueryVars: false maxQueryLength: 0 # Maximum query length for tracing, 0 uses default (4096) maxIdleConns: 20 - maxOpenConns: 200 + # 0 = unlimited, same as database/sql's own default. Set a cap here only if + # your deployment needs one. + maxOpenConns: 0 connMaxLifetime: "30m" connMaxIdleTime: "10m" authMethod: "password" # password, iam diff --git a/httpx/config.go b/httpx/config.go index fbaef193..8bc7c623 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -6,14 +6,40 @@ import ( ) type ServerConfig struct { - Address string `confx:"address" usage:"HTTP server address" validate:"required"` - PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` - ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` - ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"` - WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` - IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` - TLS TLSConfig `confx:"tls"` - Security SecurityConfig `confx:",squash"` + Address string `confx:"address" usage:"HTTP server address" validate:"required"` + PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` + ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` + // stop_if guards the ltefield: ReadTimeout == 0 means no read deadline at + // all, so it is not an upper bound, and a plain `ltefield` would reject a + // config that sets only a header timeout. + ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"stop_if=ReadTimeout 0,ltefield=ReadTimeout"` + WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` + IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` + // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. + // Without it a single oversized body can be read entirely into memory. + MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited" validate:"gte=0"` + // MaxConcurrentStreams caps HTTP/2 streams per connection. 0 uses Go's default (250). + // + // This is PER CONNECTION, not global. Together with MaxConnections it gives a hard + // upper bound on in-flight requests: MaxConnections × MaxConcurrentStreams. On its own + // it bounds nothing — a client can just open more connections. + // + // Lowering it buys little: the same request volume just opens more connections. + // Leave it at 0 unless you specifically need the in-flight bound to be + // arithmetically knowable. + MaxConcurrentStreams int `confx:"maxConcurrentStreams" usage:"max HTTP/2 streams per connection (per-connection, not global; multiply by maxConnections for the in-flight ceiling), 0 for Go default (250)" validate:"gte=0"` + // MaxConnections caps concurrent TCP connections via netutil.LimitListener. 0 means unlimited. + // + // It counts CONNECTIONS, not requests. Under HTTP/1.1 a connection carries one request + // at a time so the two roughly coincide, but under HTTP/2 a single connection multiplexes + // many concurrent streams — so this is NOT a concurrency limit. + // + // Past the limit netutil.LimitListener stops calling Accept, so further connections + // wait in the kernel backlog until the client gives up: nothing is logged and nothing + // is rejected. + MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited" validate:"gte=0"` + TLS TLSConfig `confx:"tls"` + Security SecurityConfig `confx:",squash"` } type TLSConfig struct { diff --git a/httpx/example/config.yaml b/httpx/example/config.yaml index 562730ab..d718871c 100644 --- a/httpx/example/config.yaml +++ b/httpx/example/config.yaml @@ -5,6 +5,23 @@ http: readHeaderTimeout: "4s" writeTimeout: "20s" idleTimeout: "120s" + # 0 = unlimited. Without it a single oversized body is read wholly into memory. + # Left at 0 by default on purpose: a sane cap depends entirely on the service + # (an upload endpoint may legitimately need hundreds of MiB), so the library + # cannot pick one for you. Set it per service, and prefer capping at the + # gateway too (Envoy requestBuffer / nginx proxy-body-size). + maxRequestBodySize: 0 + # 0 = unlimited. Counts TCP CONNECTIONS, not requests: under HTTP/1.1 the two + # roughly coincide, under HTTP/2 one connection multiplexes many requests and + # they fully decouple. Guards fd exhaustion only. + maxConnections: 0 + # 0 = Go's default (250). PER CONNECTION, not global. + # Multiply by maxConnections for the in-flight ceiling. On its own it bounds + # nothing — a client just opens more connections. Behind a gateway that already + # caps concurrency (Envoy's maxParallelRequests), lowering it buys no extra + # protection and costs multiplexing; leave it at 0 unless you need the ceiling + # to be arithmetically knowable. + maxConcurrentStreams: 0 tls: enabled: false certBase64: "" diff --git a/httpx/server.go b/httpx/server.go index 241ce3b8..d8b6f3e1 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -12,14 +12,25 @@ import ( "github.com/pkg/errors" "github.com/qor5/x/v3/netx" "github.com/theplant/inject/lifecycle" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/netutil" ) type Listener net.Listener func SetupListener(lc *lifecycle.Lifecycle, conf *ServerConfig) (Listener, error) { - return netx.SetupListenerFactory("http-listener", conf.Address)(lc) + listener, err := netx.SetupListenerFactory("http-listener", conf.Address)(lc) + if err != nil { + return nil, err + } + // A connection cap guards against fd exhaustion; it is NOT a concurrency + // limit, since one HTTP/2 connection carries many streams. Bound concurrency + // upstream (the gateway's circuit breaker) or with an in-flight middleware. + // Past the limit Accept blocks — connections queue in the kernel backlog + // rather than being rejected. + if conf.MaxConnections > 0 { + listener = netutil.LimitListener(listener, conf.MaxConnections) + } + return listener, nil } func SetupServerFactory(name string, handler http.Handler) func(ctx context.Context, lc *lifecycle.Lifecycle, conf *ServerConfig, listener Listener) (*http.Server, error) { @@ -80,6 +91,12 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { handler = http.StripPrefix(pathPrefix, handler) } + // Outermost, so the body cap applies before routing and before any + // business handler gets to read. + if conf.MaxRequestBodySize > 0 { + handler = http.MaxBytesHandler(handler, conf.MaxRequestBodySize) + } + srv := &http.Server{ ReadTimeout: conf.ReadTimeout, ReadHeaderTimeout: conf.ReadHeaderTimeout, @@ -87,6 +104,45 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { IdleTimeout: conf.IdleTimeout, Handler: handler, } + + // HTTP/2 on both paths: negotiated via ALPN under TLS, h2c in cleartext. + // + // This replaces the h2c.NewHandler that used to live in the `else` branch. + // x/net marks it Deprecated ("Set the http.Server Protocols field to use + // unencrypted HTTP/2 instead"), and to support HTTP/1.1 Upgrade it reads + // the FIRST request on an h2c connection entirely into memory (its own doc + // asks callers to wrap it in MaxBytesHandler; we never did). The stdlib + // implementation only peeks 24 bytes for the PRI preface — prior-knowledge + // mode only — so that memory amplification does not exist here. + // + // Behaviour change: a client relying on the `Upgrade: h2c` header no longer + // upgrades. It falls back to HTTP/1.1 silently — the request is still served + // normally (verified: 200 OK rather than 101 Switching Protocols), so this + // is a downgrade in protocol, not a failure. Envoy (with appProtocol h2c) + // and gRPC clients both use prior-knowledge and are unaffected. + // + // Also verified unchanged: http.Server.IdleTimeout still governs h2c + // connections. The old code forwarded it explicitly via + // &http2.Server{IdleTimeout: ...}; the stdlib path inherits it, and both + // close an idle connection at the same moment. + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + srv.Protocols = protocols + + // Heads-up for anyone auditing this: through Go 1.25 the doc comment on + // http.Server.HTTP2 still reads "This field does not yet have any effect" + // (go.dev/issue/67813). That comment is wrong, and has been since the field + // landed — h2_bundle.go's configFromServer has always fed it through + // fillNetHTTPConfig. Measured by reading the server's SETTINGS frame, + // MaxConcurrentStreams: 42 is advertised as 42 on go1.24.1, 1.24.11, 1.25.1, + // 1.25.6, 1.25.12 and 1.26.3 alike (250 when unset). Go 1.26 finally dropped + // the stale comment. server_test.go pins this so it cannot silently regress. + if conf.MaxConcurrentStreams > 0 { + srv.HTTP2 = &http.HTTP2Config{MaxConcurrentStreams: conf.MaxConcurrentStreams} + } + if conf.TLS.Enabled { cert, err := loadTLSCertificate(conf.TLS.CertBase64, conf.TLS.KeyBase64) if err != nil { @@ -95,10 +151,6 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { srv.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, } - } else { - srv.Handler = h2c.NewHandler(srv.Handler, &http2.Server{ - IdleTimeout: srv.IdleTimeout, - }) } return srv, nil } diff --git a/httpx/server_test.go b/httpx/server_test.go new file mode 100644 index 00000000..8b3d1241 --- /dev/null +++ b/httpx/server_test.go @@ -0,0 +1,320 @@ +package httpx_test + +import ( + "bufio" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/qor5/confx" + "github.com/stretchr/testify/require" + "github.com/theplant/inject/lifecycle" + "golang.org/x/net/http2" + + "github.com/qor5/x/v3/httpx" +) + +// serve starts a server on a random port and returns its address. +func serve(t *testing.T, conf *httpx.ServerConfig, handler http.Handler) string { + t.Helper() + + srv, err := httpx.NewServer(conf, handler) + require.NoError(t, err) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + + return ln.Addr().String() +} + +// h2cClient talks to a cleartext port in prior-knowledge mode (it sends the +// HTTP/2 preface straight away), which is exactly what Envoy and gRPC clients +// do when appProtocol is h2c. +func h2cClient() *http.Client { + return &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + }, + } +} + +// Cleartext HTTP/2 must keep working after the move to http.Server.Protocols. +// This is the behaviour most at risk when replacing the deprecated +// h2c.NewHandler, so it is asserted directly. +func TestNewServer_H2C(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + resp, err := h2cClient().Get("http://" + addr) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "HTTP/2.0", string(body)) +} + +// The same server must still serve HTTP/1.1. +func TestNewServer_HTTP1StillWorks(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + resp, err := http.Get("http://" + addr) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "HTTP/1.1", string(body)) +} + +// Reads MAX_CONCURRENT_STREAMS as the server advertises it in the SETTINGS +// frame. This is the only way to prove http.Server.HTTP2 is honoured: the +// field's doc comment in Go 1.25 still says "does not yet have any effect", +// which is stale, but nothing short of measuring it says so. +func advertisedMaxStreams(t *testing.T, addr string) uint32 { + t.Helper() + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, http2.ClientPreface) + require.NoError(t, err) + + fr := http2.NewFramer(conn, conn) + require.NoError(t, fr.WriteSettings()) + + for range 5 { + f, err := fr.ReadFrame() + require.NoError(t, err) + sf, ok := f.(*http2.SettingsFrame) + if !ok { + continue + } + if v, ok := sf.Value(http2.SettingMaxConcurrentStreams); ok { + return v + } + } + t.Fatal("server never advertised MAX_CONCURRENT_STREAMS") + return 0 +} + +func TestNewServer_MaxConcurrentStreams(t *testing.T) { + noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + t.Run("configured value is advertised", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxConcurrentStreams: 42}, noop) + require.Equal(t, uint32(42), advertisedMaxStreams(t, addr)) + }) + + t.Run("zero falls back to the Go default", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, noop) + require.Equal(t, uint32(250), advertisedMaxStreams(t, addr)) + }) +} + +func TestNewServer_MaxRequestBodySize(t *testing.T) { + const limit = 16 + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); err != nil { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + + t.Run("under the limit passes", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader("short")) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("over the limit is rejected", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + }) + + t.Run("zero means unlimited", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) +} + +// Migrating off h2c.NewHandler drops HTTP/1.1 Upgrade-based h2c (the stdlib +// only speaks prior-knowledge). The contract we must keep is that such a +// request still gets served — a protocol downgrade, never an error. +// +// Old behaviour: 101 Switching Protocols. New: 200 OK over HTTP/1.1. +func TestNewServer_H2CUpgradeFallsBackToHTTP1(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: x\r\n"+ + "Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+ + "HTTP2-Settings: AAMAAABkAARAAAAAAAIAAAAA\r\n\r\n") + require.NoError(t, err) + + status, err := bufio.NewReader(conn).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", + "an Upgrade: h2c request must still be served, just without upgrading") + require.NotContains(t, status, "101") +} + +// The old code forwarded IdleTimeout explicitly (&http2.Server{IdleTimeout: …}). +// The stdlib path has to inherit it from http.Server, or h2c connections would +// silently start living forever. +func TestNewServer_IdleTimeoutAppliesToH2C(t *testing.T) { + const idle = 500 * time.Millisecond + + addr := serve(t, &httpx.ServerConfig{Address: ":0", IdleTimeout: idle}, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, http2.ClientPreface) + require.NoError(t, err) + fr := http2.NewFramer(conn, conn) + require.NoError(t, fr.WriteSettings()) + + // An idle h2c connection must be shut down; the server signals that with + // GOAWAY (and then closes), so any read eventually stops succeeding. + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + f, err := fr.ReadFrame() + if err != nil { + return // connection closed — IdleTimeout did its job + } + if _, ok := f.(*http2.GoAwayFrame); ok { + return + } + } + t.Fatal("idle h2c connection was never closed — IdleTimeout is not reaching HTTP/2") +} + +// MaxConnections caps concurrent TCP connections. It takes effect in +// SetupListener (netutil.LimitListener), not in NewServer, so this test goes +// through the real wiring rather than the bare net.Listen used elsewhere. +// +// LimitListener enforces the cap by not Accept-ing past it — connections sit +// in the kernel backlog rather than being refused — so the observable effect +// is that a second connection gets no response while the first is held open. +func TestNewServer_MaxConnections(t *testing.T) { + conf := &httpx.ServerConfig{Address: "127.0.0.1:0", MaxConnections: 1} + + lc := lifecycle.New() + listener, err := httpx.SetupListener(lc, conf) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + srv, err := httpx.NewServer(conf, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + require.NoError(t, err) + go func() { _ = srv.Serve(listener) }() + t.Cleanup(func() { _ = srv.Close() }) + + addr := listener.Addr().String() + + // First connection: served normally, then held open via keep-alive so it + // keeps occupying the single slot. + held, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = held.Close() }() + require.NoError(t, held.SetDeadline(time.Now().Add(5*time.Second))) + _, err = io.WriteString(held, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + require.NoError(t, err) + status, err := bufio.NewReader(held).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", "the first connection must be served") + + // Second connection: the TCP handshake still completes (kernel backlog), + // but the server never Accepts it, so no response arrives. + blocked, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = blocked.Close() }() + require.NoError(t, blocked.SetDeadline(time.Now().Add(700*time.Millisecond))) + _, err = io.WriteString(blocked, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + require.NoError(t, err) + _, err = bufio.NewReader(blocked).ReadString('\n') + require.Error(t, err, "a second connection must not be served while the cap is taken") + + // Releasing the slot lets the queued connection through — the cap blocks, + // it does not permanently reject. (It is the already-queued one that gets + // Accept-ed next, so re-use `blocked` rather than dialling afresh.) + require.NoError(t, held.Close()) + require.NoError(t, blocked.SetDeadline(time.Now().Add(5*time.Second))) + status, err = bufio.NewReader(blocked).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", "the slot must be reusable once freed") +} + +// ReadTimeout == 0 means no read deadline, so it is not an upper bound for +// ReadHeaderTimeout. A plain `ltefield=ReadTimeout` rejected a config that set +// only a header timeout — a reasonable minimal hardening — and stopped the +// service from starting. The stop_if in front of it is what fixes that. +func TestReadHeaderTimeoutAgainstReadTimeout(t *testing.T) { + suite := confx.NewValidationSuite(t) + cfg := func(header, read time.Duration) *httpx.ServerConfig { + return &httpx.ServerConfig{Address: ":0", ReadHeaderTimeout: header, ReadTimeout: read} + } + suite.RunTests([]confx.ExpectedValidation{ + { + Name: "read deadline set, header within it", + Config: cfg(5*time.Second, 10*time.Second), + ExpectedErrors: nil, + }, + { + Name: "read deadline set, header beyond it", + Config: cfg(15*time.Second, 10*time.Second), + ExpectedErrors: []confx.ExpectedValidationError{ + {Path: "ReadHeaderTimeout", Tag: "ltefield"}, + }, + }, + { + Name: "no read deadline, header only", + Config: cfg(10*time.Second, 0), + ExpectedErrors: nil, + }, + }) +}