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 (
+
+