From f1eeca60bf99d39ce56da94a866854175c1aac54 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:27:43 +0200 Subject: [PATCH] feat: add garage.toml support (#30) --- README.md | 35 ++++ backend/go.mod | 10 +- backend/go.sum | 117 ++----------- backend/internal/auth/token_test.go | 29 +++ backend/internal/config/config.go | 63 ++++++- backend/internal/config/config_test.go | 124 +++++++++++++ backend/internal/config/garagetoml.go | 105 +++++++++++ backend/internal/config/garagetoml_test.go | 165 ++++++++++++++++++ backend/internal/handlers/auth.go | 46 +++++ backend/internal/handlers/auth_test.go | 133 ++++++++++++++ backend/internal/middleware/auth.go | 28 +-- backend/internal/routes/routes.go | 7 +- backend/main.go | 29 ++- config.yaml.example | 6 + .../src/components/auth/TokenLoginForm.tsx | 71 ++++++++ frontend/src/lib/api.ts | 8 + frontend/src/pages/Login.tsx | 26 ++- frontend/src/store/auth-store.ts | 33 +++- frontend/src/types/auth.ts | 3 + 19 files changed, 907 insertions(+), 131 deletions(-) create mode 100644 backend/internal/auth/token_test.go create mode 100644 backend/internal/config/garagetoml.go create mode 100644 backend/internal/config/garagetoml_test.go create mode 100644 frontend/src/components/auth/TokenLoginForm.tsx diff --git a/README.md b/README.md index 32b1a3f..48aafea 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,41 @@ helm install garage-ui garage-ui/garage-ui \ --set garage.adminToken=your-token ``` +Access at http://localhost:8080 + +### Quick Start with garage.toml + +If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed: + +```bash +./garage-ui --garage-toml /etc/garage.toml +``` + +Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config. + +**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside containers this won't work -- override the endpoint explicitly with environment variables or a config file. + +**Docker:** + +```bash +docker run -d -p 8080:8080 \ + -v /etc/garage.toml:/etc/garage.toml:ro \ + -e GARAGE_UI_GARAGE_TOML=/etc/garage.toml \ + -e GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 \ + -e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 \ + noooste/garage-ui:latest +``` + +The endpoint overrides are needed because the container cannot reach `127.0.0.1` on the host. + +**Combining flags:** Use `--garage-toml` for Garage connection values and `--config` for everything else (auth, CORS, logging, etc.): + +```bash +./garage-ui --garage-toml /etc/garage.toml --config config.yaml +``` + +**Precedence order** (highest wins): built-in defaults < `garage.toml` < `config.yaml` < environment variables. + ## Configuration Minimum required config: diff --git a/backend/go.mod b/backend/go.mod index 469491e..d8710f9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,9 +10,9 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/minio/minio-go/v7 v7.0.100 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/rs/zerolog v1.35.0 github.com/spf13/viper v1.21.0 - github.com/swaggo/swag v1.16.6 golang.org/x/oauth2 v0.36.0 ) @@ -36,7 +36,6 @@ require ( github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/swag v0.26.0 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect @@ -48,27 +47,24 @@ require ( github.com/gofiber/schema v1.7.1 // indirect github.com/gofiber/utils/v2 v2.0.3 // indirect github.com/google/gopacket v1.1.19 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.70.0 // indirect @@ -80,5 +76,5 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.44.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 069425a..83161cf 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,8 +2,6 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ= -github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs= github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U= github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk= github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs= @@ -12,80 +10,57 @@ github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FY github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE= github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk= github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM= -github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE= -github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4= github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE= github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4= -github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88= -github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko= github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA= github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= -github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= 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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE= github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA= github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= @@ -94,22 +69,19 @@ github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFu github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= -github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= -github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= -github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= -github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI= github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -118,19 +90,13 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ= +github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= @@ -139,63 +105,40 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= -github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -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/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= +github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= -github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 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.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -212,78 +155,52 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA= github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/auth/token_test.go b/backend/internal/auth/token_test.go new file mode 100644 index 0000000..80b278d --- /dev/null +++ b/backend/internal/auth/token_test.go @@ -0,0 +1,29 @@ +package auth + +import ( + "crypto/subtle" + "testing" +) + +func TestValidateAdminToken(t *testing.T) { + tests := []struct { + name string + configured string + provided string + want bool + }{ + {"correct token", "my-secret-token", "my-secret-token", true}, + {"wrong token", "my-secret-token", "wrong-token", false}, + {"empty provided", "my-secret-token", "", false}, + {"empty configured", "", "any-token", false}, + {"both empty", "", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := subtle.ConstantTimeCompare([]byte(tc.configured), []byte(tc.provided)) == 1 + if got != tc.want { + t.Errorf("ValidateAdminToken(%q, %q) = %v, want %v", tc.configured, tc.provided, got, tc.want) + } + }) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 77baad6..82de209 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -46,6 +46,7 @@ type GarageConfig struct { type AuthConfig struct { Admin AdminAuthConfig `mapstructure:"admin"` OIDC OIDCConfig `mapstructure:"oidc"` + Token TokenAuthConfig `mapstructure:"token"` JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) } @@ -56,6 +57,12 @@ type AdminAuthConfig struct { Password string `mapstructure:"password"` } +// TokenAuthConfig contains admin token authentication settings. +// When enabled, users can log in using the Garage admin token. +type TokenAuthConfig struct { + Enabled bool `mapstructure:"enabled"` +} + // OIDCConfig contains OIDC authentication settings type OIDCConfig struct { Enabled bool `mapstructure:"enabled"` @@ -95,8 +102,28 @@ type LoggingConfig struct { Format string `mapstructure:"format"` } +// LoadOption configures optional behaviour of Load. +type LoadOption func(*loadOptions) + +type loadOptions struct { + garageTomlPath string +} + +// WithGarageToml tells Load to parse a garage.toml file and use its values as +// lowest-priority defaults (below YAML, below env vars). +func WithGarageToml(path string) LoadOption { + return func(o *loadOptions) { + o.garageTomlPath = path + } +} + // Load reads the configuration from the specified file -func Load(configPath string) (*Config, error) { +func Load(configPath string, opts ...LoadOption) (*Config, error) { + var lo loadOptions + for _, fn := range opts { + fn(&lo) + } + // Set default config file name if not specified if configPath == "" { configPath = "config.yaml" @@ -106,6 +133,28 @@ func Load(configPath string) (*Config, error) { viper.SetConfigFile(configPath) viper.SetConfigType("yaml") + // Built-in defaults (lowest priority) + viper.SetDefault("server.host", "0.0.0.0") + viper.SetDefault("server.port", 8080) + viper.SetDefault("server.environment", "production") + viper.SetDefault("garage.force_path_style", true) + viper.SetDefault("logging.level", "info") + viper.SetDefault("logging.format", "text") + + // If garage.toml path is provided, parse it and set values as viper + // defaults. Defaults sit below config-file and env-var values in viper's + // priority order, so YAML and env vars will still win. + if lo.garageTomlPath != "" { + tomlResult, err := ParseGarageToml(lo.garageTomlPath) + if err != nil { + return nil, fmt.Errorf("error parsing garage.toml: %w", err) + } + viper.SetDefault("garage.endpoint", tomlResult.Endpoint) + viper.SetDefault("garage.admin_endpoint", tomlResult.AdminEndpoint) + viper.SetDefault("garage.admin_token", tomlResult.AdminToken) + viper.SetDefault("garage.region", tomlResult.Region) + } + // Allow environment variables to override config values // Environment variables take precedence over config file viper.AutomaticEnv() @@ -165,6 +214,9 @@ func bindEnvVars() { viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD") viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY") + // Token auth config + viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED") + // OIDC config viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED") viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME") @@ -250,6 +302,15 @@ func (c *Config) Validate() error { return nil } +// ResolveTokenAuth auto-enables token auth when no other auth method is +// configured, unless it was explicitly set. This ensures the app never +// starts without a login wall. +func (c *Config) ResolveTokenAuth() { + if !c.Auth.Admin.Enabled && !c.Auth.OIDC.Enabled && !c.Auth.Token.Enabled { + c.Auth.Token.Enabled = true + } +} + // GetAddress returns the full server address (host:port) func (c *Config) GetAddress() string { return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index dfa3b35..af96ecf 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -374,6 +374,130 @@ func TestIsDevelopment(t *testing.T) { } } +func writeToml(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "garage.toml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write toml: %v", err) + } + return path +} + +const testGarageToml = ` +[admin] +api_bind_addr = "[::]:3903" +admin_token = "toml-token" + +[s3_api] +api_bind_addr = "[::]:3900" +s3_region = "garage" +` + +func TestLoad_GarageTomlOnly(t *testing.T) { + resetViper(t) + tomlPath := writeToml(t, testGarageToml) + missingYaml := filepath.Join(t.TempDir(), "nope.yaml") + + cfg, err := Load(missingYaml, WithGarageToml(tomlPath)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Garage.AdminToken != "toml-token" { + t.Errorf("AdminToken = %q, want toml-token", cfg.Garage.AdminToken) + } + if cfg.Garage.Endpoint != "http://127.0.0.1:3900" { + t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", cfg.Garage.Endpoint) + } + if cfg.Garage.AdminEndpoint != "http://127.0.0.1:3903" { + t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", cfg.Garage.AdminEndpoint) + } + if cfg.Garage.Region != "garage" { + t.Errorf("Region = %q, want garage", cfg.Garage.Region) + } +} + +func TestLoad_YAMLOverridesToml(t *testing.T) { + resetViper(t) + tomlPath := writeToml(t, testGarageToml) + yaml := ` +server: + host: "0.0.0.0" + port: 8080 +garage: + endpoint: http://custom:3900 + admin_endpoint: http://custom:3903 + admin_token: yaml-wins +` + yamlPath := writeConfigFile(t, yaml) + + cfg, err := Load(yamlPath, WithGarageToml(tomlPath)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Garage.AdminToken != "yaml-wins" { + t.Errorf("AdminToken = %q, want yaml-wins (yaml overrides toml)", cfg.Garage.AdminToken) + } + if cfg.Garage.Endpoint != "http://custom:3900" { + t.Errorf("Endpoint = %q, want http://custom:3900", cfg.Garage.Endpoint) + } +} + +func TestLoad_EnvOverridesToml(t *testing.T) { + resetViper(t) + tomlPath := writeToml(t, testGarageToml) + missingYaml := filepath.Join(t.TempDir(), "nope.yaml") + t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins") + + cfg, err := Load(missingYaml, WithGarageToml(tomlPath)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Garage.AdminToken != "env-wins" { + t.Errorf("AdminToken = %q, want env-wins (env overrides toml)", cfg.Garage.AdminToken) + } +} + +func TestValidate_TokenAuthAutoEnabled(t *testing.T) { + cfg := validBaseConfig() + cfg.ResolveTokenAuth() + if !cfg.Auth.Token.Enabled { + t.Error("expected token auth to be auto-enabled when no other auth is configured") + } +} + +func TestValidate_TokenAuthNotAutoEnabledWhenAdminEnabled(t *testing.T) { + cfg := validBaseConfig() + cfg.Auth.Admin.Enabled = true + cfg.Auth.Admin.Username = "u" + cfg.Auth.Admin.Password = "p" + cfg.ResolveTokenAuth() + if cfg.Auth.Token.Enabled { + t.Error("expected token auth to stay disabled when admin auth is configured") + } +} + +func TestValidate_TokenAuthNotAutoEnabledWhenOIDCEnabled(t *testing.T) { + cfg := validBaseConfig() + applyValidOIDC(&cfg) + cfg.ResolveTokenAuth() + if cfg.Auth.Token.Enabled { + t.Error("expected token auth to stay disabled when OIDC is configured") + } +} + +func TestValidate_TokenAuthExplicitlyEnabled(t *testing.T) { + cfg := validBaseConfig() + cfg.Auth.Admin.Enabled = true + cfg.Auth.Admin.Username = "u" + cfg.Auth.Admin.Password = "p" + cfg.Auth.Token.Enabled = true + cfg.ResolveTokenAuth() + if !cfg.Auth.Token.Enabled { + t.Error("expected token auth to stay enabled when explicitly set") + } +} + func TestIsProduction(t *testing.T) { tests := []struct { env string diff --git a/backend/internal/config/garagetoml.go b/backend/internal/config/garagetoml.go new file mode 100644 index 0000000..73c2e22 --- /dev/null +++ b/backend/internal/config/garagetoml.go @@ -0,0 +1,105 @@ +package config + +import ( + "fmt" + "net" + "os" + "strings" + + toml "github.com/pelletier/go-toml/v2" +) + +// GarageTomlResult holds the values extracted from a garage.toml file. +type GarageTomlResult struct { + Endpoint string + AdminEndpoint string + AdminToken string + Region string +} + +// garageTomlFile represents the subset of garage.toml we care about. +type garageTomlFile struct { + S3API struct { + APIBindAddr string `toml:"api_bind_addr"` + S3Region string `toml:"s3_region"` + } `toml:"s3_api"` + Admin struct { + APIBindAddr string `toml:"api_bind_addr"` + AdminToken string `toml:"admin_token"` + } `toml:"admin"` +} + +// ParseGarageToml reads a garage.toml file and extracts the values needed +// for garage-ui configuration. +func ParseGarageToml(path string) (*GarageTomlResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading garage.toml: %w", err) + } + + var f garageTomlFile + if err := toml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing garage.toml: %w", err) + } + + if f.Admin.AdminToken == "" { + return nil, fmt.Errorf("garage.toml: [admin].admin_token is required") + } + if f.Admin.APIBindAddr == "" { + return nil, fmt.Errorf("garage.toml: [admin].api_bind_addr is required") + } + if f.S3API.APIBindAddr == "" { + return nil, fmt.Errorf("garage.toml: [s3_api].api_bind_addr is required") + } + + adminEndpoint, err := convertBindAddr(f.Admin.APIBindAddr) + if err != nil { + return nil, fmt.Errorf("garage.toml: converting admin api_bind_addr: %w", err) + } + + s3Endpoint, err := convertBindAddr(f.S3API.APIBindAddr) + if err != nil { + return nil, fmt.Errorf("garage.toml: converting s3_api api_bind_addr: %w", err) + } + + region := f.S3API.S3Region + if region == "" { + region = "garage" + } + + return &GarageTomlResult{ + Endpoint: s3Endpoint, + AdminEndpoint: adminEndpoint, + AdminToken: f.Admin.AdminToken, + Region: region, + }, nil +} + +// convertBindAddr converts a bind address like "[::]:3900" into an HTTP +// endpoint like "http://127.0.0.1:3900". Wildcard hosts (::, 0.0.0.0, empty) +// are replaced with 127.0.0.1. +func convertBindAddr(bindAddr string) (string, error) { + if bindAddr == "" { + return "", fmt.Errorf("bind address is empty") + } + + host, port, err := net.SplitHostPort(bindAddr) + if err != nil { + return "", fmt.Errorf("invalid bind address %q: %w", bindAddr, err) + } + + if port == "" { + return "", fmt.Errorf("bind address %q has no port", bindAddr) + } + + switch host { + case "", "::", "0.0.0.0": + host = "127.0.0.1" + } + + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + + return fmt.Sprintf("http://%s:%s", host, port), nil +} diff --git a/backend/internal/config/garagetoml_test.go b/backend/internal/config/garagetoml_test.go new file mode 100644 index 0000000..4628339 --- /dev/null +++ b/backend/internal/config/garagetoml_test.go @@ -0,0 +1,165 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTomlFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "garage.toml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write toml: %v", err) + } + return path +} + +const validGarageToml = ` +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage" + +[s3_web] +bind_addr = "[::]:3902" +root_domain = ".web.garage" +index = "index.html" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "my-secret-admin-token" +` + +func TestParseGarageToml_ValidFile(t *testing.T) { + path := writeTomlFile(t, validGarageToml) + result, err := ParseGarageToml(path) + if err != nil { + t.Fatalf("ParseGarageToml: %v", err) + } + if result.AdminToken != "my-secret-admin-token" { + t.Errorf("AdminToken = %q, want my-secret-admin-token", result.AdminToken) + } + if result.AdminEndpoint != "http://127.0.0.1:3903" { + t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", result.AdminEndpoint) + } + if result.Endpoint != "http://127.0.0.1:3900" { + t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", result.Endpoint) + } + if result.Region != "garage" { + t.Errorf("Region = %q, want garage", result.Region) + } +} + +func TestParseGarageToml_MissingAdminToken(t *testing.T) { + toml := ` +[admin] +api_bind_addr = "[::]:3903" +[s3_api] +api_bind_addr = "[::]:3900" +` + path := writeTomlFile(t, toml) + _, err := ParseGarageToml(path) + if err == nil { + t.Fatal("expected error for missing admin_token, got nil") + } +} + +func TestParseGarageToml_MissingAdminBindAddr(t *testing.T) { + toml := ` +[admin] +admin_token = "tok" +[s3_api] +api_bind_addr = "[::]:3900" +` + path := writeTomlFile(t, toml) + _, err := ParseGarageToml(path) + if err == nil { + t.Fatal("expected error for missing admin api_bind_addr, got nil") + } +} + +func TestParseGarageToml_MissingS3BindAddr(t *testing.T) { + toml := ` +[admin] +admin_token = "tok" +api_bind_addr = "[::]:3903" +[s3_api] +s3_region = "garage" +` + path := writeTomlFile(t, toml) + _, err := ParseGarageToml(path) + if err == nil { + t.Fatal("expected error for missing s3_api api_bind_addr, got nil") + } +} + +func TestParseGarageToml_DefaultRegion(t *testing.T) { + toml := ` +[admin] +admin_token = "tok" +api_bind_addr = "[::]:3903" +[s3_api] +api_bind_addr = "[::]:3900" +` + path := writeTomlFile(t, toml) + result, err := ParseGarageToml(path) + if err != nil { + t.Fatalf("ParseGarageToml: %v", err) + } + if result.Region != "garage" { + t.Errorf("Region = %q, want garage (default)", result.Region) + } +} + +func TestParseGarageToml_FileNotFound(t *testing.T) { + _, err := ParseGarageToml("/nonexistent/garage.toml") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestConvertBindAddr(t *testing.T) { + tests := []struct { + name string + bindAddr string + want string + wantErr bool + }{ + {"ipv6 wildcard", "[::]:3900", "http://127.0.0.1:3900", false}, + {"ipv4 wildcard", "0.0.0.0:3900", "http://127.0.0.1:3900", false}, + {"localhost", "127.0.0.1:3900", "http://127.0.0.1:3900", false}, + {"specific ipv4", "192.168.1.1:3900", "http://192.168.1.1:3900", false}, + {"ipv6 localhost", "[::1]:3900", "http://[::1]:3900", false}, + {"specific ipv6", "[2001:db8::1]:3900", "http://[2001:db8::1]:3900", false}, + {"empty host", ":3900", "http://127.0.0.1:3900", false}, + {"empty string", "", "", true}, + {"no port", "127.0.0.1", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := convertBindAddr(tc.bindAddr) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("convertBindAddr(%q) = %q, want %q", tc.bindAddr, got, tc.want) + } + }) + } +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index d5fe994..b67d0b6 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -1,6 +1,8 @@ package handlers import ( + "crypto/subtle" + "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" @@ -38,6 +40,9 @@ func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error { "oidc": fiber.Map{ "enabled": h.cfg.Auth.OIDC.Enabled, }, + "token": fiber.Map{ + "enabled": h.cfg.Auth.Token.Enabled, + }, } // Add provider name if OIDC is enabled @@ -108,6 +113,47 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error { }) } +// LoginTokenRequest represents the token auth login request +type LoginTokenRequest struct { + Token string `json:"token" validate:"required"` +} + +// LoginToken handles admin token authentication login +func (h *AuthHandler) LoginToken(c fiber.Ctx) error { + var req LoginTokenRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"), + ) + } + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(h.cfg.Garage.AdminToken), []byte(req.Token)) != 1 { + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid admin token"), + ) + } + + userInfo := &auth.UserInfo{ + Username: "admin-token", + } + + sessionToken, err := h.authService.GenerateSessionToken(userInfo) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"), + ) + } + + return c.JSON(fiber.Map{ + "success": true, + "token": sessionToken, + "user": fiber.Map{ + "username": userInfo.Username, + }, + }) +} + // GetMe returns the current authenticated user's information // // @Summary Get current user diff --git a/backend/internal/handlers/auth_test.go b/backend/internal/handlers/auth_test.go index 66b0188..ea41ed6 100644 --- a/backend/internal/handlers/auth_test.go +++ b/backend/internal/handlers/auth_test.go @@ -250,6 +250,139 @@ func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) { } } +func TestLoginToken_Success(t *testing.T) { + cfg := &config.Config{ + Garage: config.GarageConfig{ + AdminToken: "test-admin-token", + Endpoint: "http://g:3900", + AdminEndpoint: "http://g:3903", + }, + Auth: config.AuthConfig{ + Token: config.TokenAuthConfig{Enabled: true}, + }, + } + svc := newAuthTestService(t, cfg.Auth.Admin) + h := NewAuthHandler(cfg, svc) + app := fiber.New() + app.Post("/auth/login-token", h.LoginToken) + + body := `{"token":"test-admin-token"}` + req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw) + } + + var decoded struct { + Success bool `json:"success"` + Token string `json:"token"` + User struct { + Username string `json:"username"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if !decoded.Success { + t.Error("success = false") + } + if decoded.Token == "" { + t.Error("token empty") + } + if decoded.User.Username != "admin-token" { + t.Errorf("username = %q, want admin-token", decoded.User.Username) + } +} + +func TestLoginToken_WrongToken(t *testing.T) { + cfg := &config.Config{ + Garage: config.GarageConfig{ + AdminToken: "test-admin-token", + Endpoint: "http://g:3900", + AdminEndpoint: "http://g:3903", + }, + Auth: config.AuthConfig{ + Token: config.TokenAuthConfig{Enabled: true}, + }, + } + svc := newAuthTestService(t, cfg.Auth.Admin) + h := NewAuthHandler(cfg, svc) + app := fiber.New() + app.Post("/auth/login-token", h.LoginToken) + + body := `{"token":"wrong-token"}` + req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestLoginToken_MalformedJSONReturns400(t *testing.T) { + cfg := &config.Config{ + Garage: config.GarageConfig{ + AdminToken: "test-admin-token", + Endpoint: "http://g:3900", + AdminEndpoint: "http://g:3903", + }, + Auth: config.AuthConfig{ + Token: config.TokenAuthConfig{Enabled: true}, + }, + } + svc := newAuthTestService(t, cfg.Auth.Admin) + h := NewAuthHandler(cfg, svc) + app := fiber.New() + app.Post("/auth/login-token", h.LoginToken) + + req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestGetAuthConfig_TokenEnabled(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Token: config.TokenAuthConfig{Enabled: true}, + }, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + var body struct { + Token struct { + Enabled bool `json:"enabled"` + } `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.Token.Enabled { + t.Error("token.enabled = false, want true") + } +} + func TestGetMe_OIDCUserInfoLocal(t *testing.T) { cfg := &config.Config{Auth: config.AuthConfig{}} app, h := newAuthTestApp(t, cfg) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 7259cb5..c3343d8 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -1,6 +1,8 @@ package middleware import ( + "strings" + "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" @@ -17,14 +19,14 @@ import ( func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler { return func(c fiber.Ctx) error { // If no auth is enabled, allow all requests. - if !cfg.Admin.Enabled && !cfg.OIDC.Enabled { + if !cfg.Admin.Enabled && !cfg.OIDC.Enabled && !cfg.Token.Enabled { return c.Next() } authHeader := c.Get("Authorization") - // Try admin auth if enabled and header is present. - if cfg.Admin.Enabled && authHeader != "" { + // Try bearer token auth (works for admin, token, or any JWT session) + if (cfg.Admin.Enabled || cfg.Token.Enabled) && authHeader != "" { if len(authHeader) > 7 && authHeader[:7] == "Bearer " { token := authHeader[7:] userInfo, err := authService.ValidateSessionToken(token) @@ -80,14 +82,18 @@ func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) { } func authMethodsEnabled(cfg *config.AuthConfig) string { - switch { - case cfg.Admin.Enabled && cfg.OIDC.Enabled: - return "admin+oidc" - case cfg.Admin.Enabled: - return "admin" - case cfg.OIDC.Enabled: - return "oidc" - default: + methods := []string{} + if cfg.Admin.Enabled { + methods = append(methods, "admin") + } + if cfg.OIDC.Enabled { + methods = append(methods, "oidc") + } + if cfg.Token.Enabled { + methods = append(methods, "token") + } + if len(methods) == 0 { return "none" } + return strings.Join(methods, "+") } diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index fd504c3..e9079bf 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -152,8 +152,13 @@ func SetupRoutes( app.Post("/auth/login", authHandler.LoginAdmin) } + // Token auth login endpoint (only if token auth is enabled) + if cfg.Auth.Token.Enabled { + app.Post("/auth/login-token", authHandler.LoginToken) + } + // Auth "me" endpoint (if any auth is enabled) - if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled { + if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled || cfg.Auth.Token.Enabled { app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe) } diff --git a/backend/main.go b/backend/main.go index e67d621..98d146f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -64,15 +64,32 @@ var version = "dev" func main() { // Parse command-line flags configPath := flag.String("config", "config.yaml", "Path to configuration file") + garageTomlPath := flag.String("garage-toml", "", "Path to garage.toml file (extracts Garage connection values)") flag.Parse() + // Env var fallback for --garage-toml + if *garageTomlPath == "" { + if envPath := os.Getenv("GARAGE_UI_GARAGE_TOML"); envPath != "" { + *garageTomlPath = envPath + } + } + + // Build load options + var loadOpts []config.LoadOption + if *garageTomlPath != "" { + loadOpts = append(loadOpts, config.WithGarageToml(*garageTomlPath)) + } + // Load configuration first (before initializing logger) - cfg, err := config.Load(*configPath) + cfg, err := config.Load(*configPath, loadOpts...) if err != nil { // If config fails to load, use default logger to report the error logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration") } + // Auto-enable token auth if no other auth method is configured + cfg.ResolveTokenAuth() + // Initialize logger with configuration from config file logger.Init(logger.Config{ Level: cfg.Logging.Level, @@ -87,6 +104,13 @@ func main() { Str("environment", cfg.Server.Environment). Msg("Starting Garage UI Backend") + if *garageTomlPath != "" { + logger.Warn(). + Str("s3_endpoint", cfg.Garage.Endpoint). + Str("admin_endpoint", cfg.Garage.AdminEndpoint). + Msg("Endpoints inferred from garage.toml bind addresses — override with GARAGE_UI_GARAGE_ENDPOINT / GARAGE_UI_GARAGE_ADMIN_ENDPOINT for remote/container setups") + } + // Initialize services logger.Info().Msg("Detecting Garage API version") adminResult, err := services.NewAdminService(&cfg.Garage, cfg.Logging.Level) @@ -107,6 +131,9 @@ func main() { if cfg.Auth.OIDC.Enabled { authMethods = append(authMethods, "oidc") } + if cfg.Auth.Token.Enabled { + authMethods = append(authMethods, "token") + } if len(authMethods) == 0 { authMethods = append(authMethods, "none") } diff --git a/config.yaml.example b/config.yaml.example index f3186ff..24df37a 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -40,6 +40,12 @@ auth: username: "admin" password: "changeme" + # Admin Token Authentication + # When enabled, users can log in using the Garage admin token + # Auto-enabled when no other auth method is configured (zero-config fallback) + token: + enabled: false # Set to true to explicitly enable, or leave all auth disabled for auto-enable + # OIDC Configuration # NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects # The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback diff --git a/frontend/src/components/auth/TokenLoginForm.tsx b/frontend/src/components/auth/TokenLoginForm.tsx new file mode 100644 index 0000000..eca0752 --- /dev/null +++ b/frontend/src/components/auth/TokenLoginForm.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export function TokenLoginForm() { + const [token, setToken] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginToken } = useAuthStore(); + + const returnUrl = searchParams.get('returnUrl') || '/'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + await loginToken(token); + navigate(decodeURIComponent(returnUrl)); + } catch (error) { + console.error('Login failed:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ Garage Logo +
+ + Welcome to Garage UI + +
+ +
+
+ + setToken(e.target.value)} + required + disabled={isLoading} + autoComplete="off" + /> +
+ +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 901d15d..b5ff6ed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -133,6 +133,7 @@ export const authApi = { const response = await authApiClient.get<{ admin: { enabled: boolean }; oidc: { enabled: boolean; provider?: string }; + token: { enabled: boolean }; }>('/config'); return response; }, @@ -145,6 +146,13 @@ export const authApi = { return response; }, + loginToken: async (token: string) => { + const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login-token', { + token, + }); + return response; + }, + me: async () => { const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me'); return response; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 535bf72..9db2079 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthStore } from '@/store/auth-store'; import { BasicLoginForm } from '@/components/auth/BasicLoginForm'; import { OIDCLoginView } from '@/components/auth/OIDCLoginView'; +import { TokenLoginForm } from '@/components/auth/TokenLoginForm'; import { LoadingSpinner } from '@/components/auth/LoadingSpinner'; export function Login() { @@ -14,9 +15,7 @@ export function Login() { const returnUrl = searchParams.get('returnUrl') || '/'; useEffect(() => { - // Handle OIDC callback if (loginSuccess === 'success') { - // OIDC login successful, re-initialize auth to fetch user initialize().then(() => { navigate(decodeURIComponent(returnUrl)); }); @@ -24,7 +23,6 @@ export function Login() { }, [loginSuccess, initialize, navigate, returnUrl]); useEffect(() => { - // If already authenticated, redirect to return URL if (isAuthenticated && !loginSuccess) { navigate(decodeURIComponent(returnUrl)); } @@ -35,16 +33,27 @@ export function Login() { } // No auth enabled, redirect to dashboard immediately - if (config && !config.admin.enabled && !config.oidc.enabled) { + if (config && !config.admin.enabled && !config.oidc.enabled && !config.token.enabled) { navigate('/'); return null; } - // Show login options based on what's enabled const showAdmin = config?.admin.enabled || false; const showOIDC = config?.oidc.enabled || false; + const showToken = config?.token.enabled || false; - // If both are enabled, show both options in single modal + // Token-only auth (zero-config fallback) + if (showToken && !showAdmin && !showOIDC) { + return ( +
+
+ +
+
+ ); + } + + // Both admin and OIDC enabled if (showAdmin && showOIDC) { return (
@@ -55,12 +64,12 @@ export function Login() { ); } - // Show only OIDC if enabled + // Only OIDC if (showOIDC) { return ; } - // Show only admin if enabled + // Only admin if (showAdmin) { return (
@@ -71,6 +80,5 @@ export function Login() { ); } - // Still loading config return ; } diff --git a/frontend/src/store/auth-store.ts b/frontend/src/store/auth-store.ts index 72ab794..ddf156a 100644 --- a/frontend/src/store/auth-store.ts +++ b/frontend/src/store/auth-store.ts @@ -16,6 +16,7 @@ interface AuthStore extends AuthState { // Async actions initialize: () => Promise; loginAdmin: (username: string, password: string) => Promise; + loginToken: (token: string) => Promise; loginOIDC: () => void; logout: () => Promise; } @@ -45,7 +46,7 @@ export const useAuthStore = create()( set({ config }); // If no auth is enabled, mark as authenticated immediately - if (!config.admin.enabled && !config.oidc.enabled) { + if (!config.admin.enabled && !config.oidc.enabled && !config.token.enabled) { set({ isAuthenticated: true, isLoading: false, @@ -113,6 +114,36 @@ export const useAuthStore = create()( } }, + loginToken: async (token) => { + try { + set({ isLoading: true, error: null }); + + const response = await authApi.loginToken(token); + const { token: sessionToken, user } = response.data; + + localStorage.setItem('auth-token', sessionToken); + + set({ + user, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } catch (error) { + const errorMessage = + (error as { response?: { data?: { error?: { message?: string } } } }) + .response?.data?.error?.message || + (error instanceof Error ? error.message : 'Login failed'); + set({ + error: errorMessage, + isLoading: false, + isAuthenticated: false, + user: null, + }); + throw error; + } + }, + loginOIDC: () => { // Redirect to OIDC login endpoint window.location.href = '/auth/oidc/login'; diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 94f3740..70af0ec 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -6,6 +6,9 @@ export interface AuthConfig { enabled: boolean; provider?: string; }; + token: { + enabled: boolean; + }; } export interface AuthUser {