Compare commits

...

18 Commits

Author SHA1 Message Date
Noste 61dae6c605 feat: implement admin and OIDC authentication methods; add login and user info endpoints
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:42:02 +01:00
Noste b49c634f17 bump version to 0.1.3 and update appVersion to 0.0.7; refine region comment in config example
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:54:06 +01:00
Noste 5910961825 docs: update README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:42:18 +01:00
Noste 7a4e187745 feat: trim protocol from MinIO endpoint and set SSL flag
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:29:19 +01:00
Noste ebfdad8898 feat: add region configuration to MinIO client initialization
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:24:15 +01:00
Noste c3db42bd76 bump version to 0.1.2 in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 14:09:02 +01:00
Noste d36f8915ac feat: add support for using Kubernetes secrets for admin token management
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 14:08:33 +01:00
Noste 6c6a02bcac feat: enhance Docker build process with multi-platform support and caching
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 13:19:08 +01:00
Noste 42261b325b bump version to 0.1.1 and appVersion to v0.0.6 in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 13:09:12 +01:00
Noste 09e485572a fix: update Docker image tag patterns in build.yml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:44:23 +01:00
Noste 491e083bb4 bump appVersion to v0.0.5
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:28:26 +01:00
Noste 7f636a451c feat: add functionality to create and reveal secret access keys for users
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:25:46 +01:00
Noste 4656d1bce9 feat: add functionality to create and reveal secret access keys for users
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:24:53 +01:00
Noste 983e6e5fa9 fix: update appVersion to v0.0.4 in Chart.yaml 2025-12-20 10:19:54 +01:00
Noste 244752c343 feat: add release workflow for Helm charts 2025-12-20 10:18:10 +01:00
Noooste f6e46d4a46 Add packaged Helm chart 2025-12-19 19:29:10 +01:00
Noooste 067e00b474 feat: add initial Helm chart for Garage UI deployment
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2025-12-19 19:24:40 +01:00
Noooste b747e4dee2 feat: implement JWT authentication service with state management 2025-12-19 16:17:25 +01:00
48 changed files with 4815 additions and 1126 deletions
+114
View File
@@ -0,0 +1,114 @@
name: Docker Build and Push
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ secrets.DOCKER_REGISTRY }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ secrets.DOCKER_REGISTRY }}:${{ steps.meta.outputs.version }}
-52
View File
@@ -1,52 +0,0 @@
name: Docker Build and Push
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
+37
View File
@@ -0,0 +1,37 @@
name: Release Charts
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
+2 -1
View File
@@ -59,4 +59,5 @@ data/
meta/
garage.toml
backend/docs/
backend/docs/
config.yaml
+12 -5
View File
@@ -4,7 +4,8 @@ WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY frontend/ .
@@ -14,17 +15,23 @@ FROM golang:1.25.4-alpine3.22 AS backend-builder
WORKDIR /app
RUN go install github.com/swaggo/swag/cmd/swag@latest
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/swaggo/swag/cmd/swag@latest
COPY backend/go.mod backend/go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY backend .
RUN swag init
RUN --mount=type=cache,target=/root/.cache/go-build \
swag init
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
FROM alpine:3.22
+673
View File
@@ -0,0 +1,673 @@
# Garage UI
A modern, full-stack web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems. Built with React, TypeScript, Go, and Fiber for production-ready S3-compatible storage management.
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
Garage UI provides a comprehensive dashboard for managing your Garage S3 storage cluster, featuring bucket management, object operations, user access control, and real-time cluster monitoring—all through an intuitive web interface.
---
## Features
- **Bucket Management** - Create, configure, and delete S3 buckets with visual controls
- **Object Operations** - Upload, download, and manage objects with drag-and-drop support
- **User & Access Control** - Manage access keys, permissions, and user credentials
- **Cluster Monitoring** - Real-time cluster health metrics, node status, and performance statistics
- **Multi-Auth Support** - Flexible authentication with None, Basic Auth, and OIDC/OAuth2 modes
- **Modern UI/UX** - Responsive React interface with dark mode, built on Tailwind CSS and shadcn/ui
- **Production Ready** - Docker/Kubernetes deployment, health checks, and Prometheus metrics
- **API-First Design** - RESTful API with Swagger/OpenAPI documentation
---
## Quick Start
### Prerequisites
- **Garage S3** storage cluster running (v2.0.0+)
- **Docker** & Docker Compose (recommended) OR
- **Go** 1.25+ and **Node.js** 25+ (for development)
### Using Docker Compose (Recommended)
1. **Clone the repository:**
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
```
2. **Configure the application:**
```bash
cp config.yaml.example config.yaml
# Edit config.yaml with your Garage endpoints and credentials
```
3. **Start the services:**
```bash
docker-compose up -d
```
4. **Access the UI:**
Open your browser to [http://localhost:8080](http://localhost:8080)
> The Docker Compose setup includes both Garage and Garage UI services for quick local development.
### Using Docker
```bash
docker run -d \
-p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://your-garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://your-garage:3903 \
noooste/garage-ui:latest
```
---
## Installation
### Docker Deployment
**Pull the latest image:**
```bash
docker pull noooste/garage-ui:latest
```
The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`.
### Kubernetes Deployment with Helm
```bash
# Add the Helm repository (if available)
helm repo add garage-ui https://helm.noste.dev/
# Install the chart
helm install garage-ui garage-ui/garage-ui \
--set garage.endpoint=http://garage:3900 \
--set garage.adminEndpoint=http://garage:3903 \
--set garage.adminToken=your-admin-token
```
Or use the local Helm chart:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
### Building from Source
**Backend:**
```bash
cd backend
go mod download
swag init # Generate API docs
go build -o garage-ui .
```
**Frontend:**
```bash
cd frontend
npm install
npm run build
```
**Run:**
```bash
# From project root
./backend/garage-ui --config config.yaml
```
---
## Configuration
Garage UI can be configured via YAML file or environment variables.
### Configuration File
Create a `config.yaml` based on the provided example:
```yaml
# Server configuration
server:
host: "0.0.0.0"
port: 8080
environment: "production"
# Garage S3 Configuration
garage:
endpoint: "garage:3900"
region: "eu-west-1" # Ensure to match Garage region from garage.toml
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token-here"
# Authentication Configuration
# You can enable one or both authentication methods
auth:
# Admin authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "secure-password"
# OIDC authentication (Keycloak, Auth0, etc.)
oidc:
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
```
See [`config.yaml.example`](config.yaml.example) for all available options.
### Environment Variables
All configuration can be set via environment variables with the prefix `GARAGE_UI_`:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
GARAGE_UI_AUTH_ADMIN_ENABLED=true
GARAGE_UI_AUTH_ADMIN_USERNAME=admin
GARAGE_UI_AUTH_ADMIN_PASSWORD=password
GARAGE_UI_AUTH_OIDC_ENABLED=false
GARAGE_UI_LOGGING_LEVEL=info
```
**Configuration Priority:** Environment variables override YAML file settings.
---
## Project Structure
```
garage-ui/
├── backend/ # Go backend (Fiber framework)
│ ├── internal/
│ │ ├── auth/ # Authentication services (JWT, OIDC)
│ │ ├── config/ # Configuration loading
│ │ ├── handlers/ # HTTP request handlers
│ │ ├── middleware/ # CORS, auth middleware
│ │ ├── models/ # Data models and types
│ │ ├── routes/ # API route definitions
│ │ └── services/ # Business logic (S3, Garage Admin)
│ ├── pkg/ # Shared packages
│ ├── main.go # Application entry point
│ └── go.mod # Go dependencies
├── frontend/ # React + TypeScript frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── buckets/ # Bucket-specific components
│ │ │ ├── charts/ # Data visualization
│ │ │ ├── layout/ # App layout and navigation
│ │ │ └── ui/ # shadcn/ui component library
│ │ ├── hooks/ # Custom React hooks
│ │ ├── lib/ # API client and utilities
│ │ ├── pages/ # Page components (Dashboard, Buckets, etc.)
│ │ ├── types/ # TypeScript type definitions
│ │ └── App.tsx # Main application component
│ ├── package.json # Node dependencies
│ └── vite.config.ts # Vite build configuration
├── helm/ # Kubernetes Helm chart
│ └── garage-ui/
│ ├── Chart.yaml # Chart metadata
│ ├── values.yaml # Default values
│ └── templates/ # K8s resource templates
├── .github/workflows/ # CI/CD pipelines
│ ├── build.yml # Docker build and push
│ └── release.yml # Helm chart releases
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Local development stack
└── config.yaml.example # Configuration template
```
**Key Directories:**
- **`backend/internal/handlers/`** - REST API endpoints for buckets, objects, users, cluster, and monitoring
- **`frontend/src/pages/`** - Main application views (Dashboard, Buckets, Cluster, Access Control)
- **`backend/internal/services/`** - Core business logic interfacing with Garage Admin API and S3
- **`frontend/src/lib/api.ts`** - Axios-based API client with automatic error handling
---
## Development
### Local Development Setup
**1. Start Garage (using Docker Compose):**
```bash
docker-compose up garage -d
```
**2. Run the backend:**
```bash
cd backend
go run main.go --config ../config.yaml
```
The backend will start on `http://localhost:8080` with hot-reload (use [air](https://github.com/cosmtrek/air) for auto-reload).
**3. Run the frontend:**
```bash
cd frontend
npm install
npm run dev
```
The frontend dev server starts on `http://localhost:3000` with API proxy to backend.
### Available Scripts
**Backend:**
```bash
go run main.go # Start server
go test ./... # Run tests
swag init # Regenerate API docs
go build -o garage-ui . # Build binary
```
**Frontend:**
```bash
npm run dev # Development server (Vite)
npm run build # Production build
npm run lint # ESLint checks
npm run preview # Preview production build
```
### Testing
```bash
# Backend tests
cd backend && go test ./internal/...
# Frontend tests (if available)
cd frontend && npm test
```
### Building Docker Image
```bash
docker build -t garage-ui:dev .
```
The Dockerfile uses a multi-stage build:
1. **Frontend build** - Node.js Alpine to compile React app
2. **Backend build** - Go Alpine to compile binary
3. **Runtime** - Minimal Alpine with ca-certificates
---
## API Documentation
Once the backend is running, access the Swagger UI documentation at:
**http://localhost:8080/api/v1/**
### API Endpoints Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check endpoint |
| `/api/v1/buckets` | GET | List all buckets |
| `/api/v1/buckets` | POST | Create a new bucket |
| `/api/v1/buckets/{name}` | GET | Get bucket details |
| `/api/v1/buckets/{name}` | DELETE | Delete a bucket |
| `/api/v1/buckets/{name}/objects` | GET | List objects in bucket |
| `/api/v1/objects/upload` | POST | Upload object to bucket |
| `/api/v1/objects/download` | GET | Download object |
| `/api/v1/objects/delete` | DELETE | Delete object |
| `/api/v1/users` | GET | List access keys |
| `/api/v1/users` | POST | Create access key |
| `/api/v1/users/{id}` | DELETE | Delete access key |
| `/api/v1/cluster/status` | GET | Cluster node status |
| `/api/v1/cluster/health` | GET | Cluster health metrics |
| `/api/v1/cluster/statistics` | GET | Storage statistics |
| `/api/v1/metrics` | GET | Prometheus metrics |
**Authentication:** Bearer token in `Authorization` header when using OIDC or session-based auth.
---
## Authentication
Garage UI supports flexible authentication with the ability to enable one or both methods simultaneously:
### No Authentication
If neither admin nor OIDC authentication is enabled, the application runs without authentication - suitable for private networks or development.
```yaml
auth:
admin:
enabled: false
oidc:
enabled: false
```
### Admin Authentication
Simple username/password authentication using JWT tokens.
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: false
```
### OIDC (OpenID Connect) Authentication
Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc.
```yaml
auth:
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400 # 24 hours
cookie_secure: true # Enable in production with HTTPS
```
### Both Authentication Methods
You can enable both admin and OIDC authentication simultaneously. Users will be presented with both options on the login page:
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400
cookie_secure: true
```
**Role-Based Access (Optional):**
```yaml
auth:
oidc:
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
```
---
## Deployment Best Practices
### Production Considerations
1. **Use HTTPS** - Always enable TLS in production:
```yaml
auth:
oidc:
cookie_secure: true
```
2. **Set Strong Admin Token** - Generate a secure token for Garage Admin API:
```bash
openssl rand -base64 32
```
3. **Configure CORS** - Restrict allowed origins:
```yaml
cors:
allowed_origins:
- "https://garage-ui.yourdomain.com"
```
4. **Enable Monitoring** - Expose metrics endpoint for Prometheus:
```yaml
# Metrics available at /api/v1/metrics
```
5. **Resource Limits** - Set appropriate CPU/memory limits in Kubernetes/Docker
### Health Checks
The application provides a health check endpoint:
```bash
curl http://localhost:8080/health
# Response: {"status":"ok","version":"0.1.0"}
```
Docker health check is configured automatically (every 30s).
### Kubernetes Deployment
The Helm chart includes:
- **Deployment** with configurable replicas
- **Service** (ClusterIP/LoadBalancer)
- **Ingress** with TLS support
- **ConfigMap** for configuration
- **NetworkPolicy** for security
- **ServiceMonitor** for Prometheus (optional)
Example custom values:
```yaml
# custom-values.yaml
replicaCount: 2
image:
repository: noooste/garage-ui
tag: v0.0.4
garage:
endpoint: "http://garage.storage.svc.cluster.local:3900"
adminEndpoint: "http://garage.storage.svc.cluster.local:3903"
adminToken: "your-secure-token"
ingress:
enabled: true
className: nginx
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
config:
auth:
admin:
enabled: false
oidc:
enabled: true
client_id: garage-ui
client_secret: secret
issuer_url: https://auth.example.com/realms/master
```
Install with:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
---
## Technology Stack
### Backend
- **[Go](https://go.dev/)** 1.25+ - High-performance backend runtime
- **[Fiber v3](https://gofiber.io/)** - Express-inspired web framework
- **[Viper](https://github.com/spf13/viper)** - Configuration management
- **[Minio Go SDK](https://github.com/minio/minio-go)** - S3 client library
- **[go-oidc](https://github.com/coreos/go-oidc)** - OpenID Connect support
- **[Zerolog](https://github.com/rs/zerolog)** - Structured logging
- **[Swagger/Swag](https://github.com/swaggo/swag)** - API documentation
### Frontend
- **[React](https://react.dev/)** 19 - UI framework
- **[TypeScript](https://www.typescriptlang.org/)** 5.9+ - Type safety
- **[Vite](https://vitejs.dev/)** 7 - Build tool and dev server
- **[React Router](https://reactrouter.com/)** 7 - Client-side routing
- **[TanStack Query](https://tanstack.com/query)** - Server state management
- **[Zustand](https://github.com/pmndrs/zustand)** - Client state management
- **[React Hook Form](https://react-hook-form.com/)** + [Zod](https://zod.dev/) - Form validation
- **[Tailwind CSS](https://tailwindcss.com/)** 4 - Utility-first styling
- **[shadcn/ui](https://ui.shadcn.com/)** - Component library
- **[Lucide Icons](https://lucide.dev/)** - Icon library
- **[Recharts](https://recharts.org/)** - Data visualization
- **[Axios](https://axios-http.com/)** - HTTP client
---
## Contributing
Contributions are welcome! Whether it's bug reports, feature requests, or code contributions, your input helps improve Garage UI.
### How to Contribute
1. **Fork the repository**
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
3. **Make your changes**
4. **Commit your changes** (`git commit -m 'Add amazing feature'`)
5. **Push to the branch** (`git push origin feature/amazing-feature`)
6. **Open a Pull Request**
### Development Guidelines
- Follow existing code style (use `gofmt` for Go, ESLint for TypeScript)
- Add tests for new features when possible
- Update documentation for API changes
- Keep commits atomic and descriptive
- Ensure all tests pass before submitting PR
### Reporting Issues
Please open an issue on GitHub with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Docker version, etc.)
---
## Troubleshooting
### Common Issues
**Issue: "Failed to connect to Garage Admin API"**
- **Solution:** Verify `garage.admin_endpoint` and `garage.admin_token` in config.yaml
- Check Garage Admin API is accessible: `curl http://garage:3903/status -H "Authorization: Bearer your-token"`
**Issue: "CORS errors in browser console"**
- **Solution:** Configure CORS in config.yaml to allow frontend origin:
```yaml
cors:
allowed_origins:
- "http://localhost:3000" # For dev
```
**Issue: "401 Unauthorized with OIDC"**
- **Solution:** Verify OIDC configuration, especially `issuer_url`, `client_id`, and `client_secret`
- Check provider configuration allows the redirect URL: `http://your-app/auth/callback`
**Issue: "Objects not appearing after upload"**
- **Solution:** Check S3 endpoint connectivity and bucket permissions
- Verify Garage bucket exists: `garage bucket list`
### Debug Mode
Enable debug logging for troubleshooting:
```yaml
logging:
level: "debug"
format: "json"
```
Or via environment variable:
```bash
GARAGE_UI_LOGGING_LEVEL=debug
```
---
## License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
---
## Acknowledgments
- [Garage](https://garagehq.deuxfleurs.fr/) - The lightweight, geo-distributed S3 storage system
- [shadcn/ui](https://ui.shadcn.com/) - Beautiful, accessible React components
- [Fiber](https://gofiber.io/) - Fast and lightweight Go web framework
- All open-source contributors who made this project possible
---
## Support
- **Documentation:** [GitHub Wiki](https://github.com/Noooste/garage-ui/wiki) (coming soon)
- **Issues:** [GitHub Issues](https://github.com/Noooste/garage-ui/issues)
- **Discussions:** [GitHub Discussions](https://github.com/Noooste/garage-ui/discussions)
---
**Made with ❤️ for the Garage community**
+34 -27
View File
@@ -15,7 +15,8 @@ import (
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
authConfig *config.AuthConfig
serverConfig *config.ServerConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
@@ -31,19 +32,20 @@ type UserInfo struct {
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
jwtService, err := NewJWTService()
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
service := &AuthService{
config: cfg,
jwtService: jwtService,
authConfig: authCfg,
serverConfig: serverCfg,
jwtService: jwtService,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if authCfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
@@ -57,7 +59,7 @@ func (a *AuthService) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
@@ -66,18 +68,23 @@ func (a *AuthService) initOIDC() error {
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
ClientID: a.authConfig.OIDC.ClientID,
SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Construct redirect URL from server config
// Use root_url if set, otherwise construct from protocol/domain
redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback"
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
ClientID: a.authConfig.OIDC.ClientID,
ClientSecret: a.authConfig.OIDC.ClientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
Scopes: a.authConfig.OIDC.Scopes,
}
return nil
@@ -88,12 +95,12 @@ func (a *AuthService) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
[]byte(a.config.Basic.Username),
[]byte(a.authConfig.Admin.Username),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
[]byte(a.authConfig.Admin.Password),
) == 1
return usernameMatch && passwordMatch
@@ -171,14 +178,14 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
// Extract user information using configured attributes
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
@@ -207,14 +214,14 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
@@ -222,12 +229,12 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
if role == a.authConfig.OIDC.AdminRole {
return true
}
}
@@ -320,7 +327,7 @@ func (a *AuthService) ValidateAndConsumeState(token string) bool {
// GenerateSessionToken generates a JWT session token for the user
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
}
// ValidateSessionToken validates a JWT session token and returns user info
+163
View File
@@ -0,0 +1,163 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTService struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
stateStore *StateStore
}
type StateStore struct {
mu sync.RWMutex
states map[string]StateData
}
type StateData struct {
Created time.Time
ExpiresAt time.Time
}
type SessionClaims struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
func NewJWTService() (*JWTService, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
}
return &JWTService{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
stateStore: &StateStore{
states: make(map[string]StateData),
},
}, nil
}
func (j *JWTService) GenerateStateToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("failed to generate state token: %w", err)
}
token := base64.URLEncoding.EncodeToString(tokenBytes)
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
now := time.Now()
j.stateStore.states[token] = StateData{
Created: now,
ExpiresAt: now.Add(10 * time.Minute),
}
go j.cleanupExpiredStates()
return token, nil
}
func (j *JWTService) ValidateAndConsumeState(token string) bool {
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
state, exists := j.stateStore.states[token]
if !exists {
return false
}
if time.Now().After(state.ExpiresAt) {
delete(j.stateStore.states, token)
return false
}
delete(j.stateStore.states, token)
return true
}
func (j *JWTService) cleanupExpiredStates() {
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
now := time.Now()
for token, state := range j.stateStore.states {
if now.After(state.ExpiresAt) {
delete(j.stateStore.states, token)
}
}
}
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
now := time.Now()
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
claims := SessionClaims{
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(j.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return tokenString, nil
}
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
if claims, ok := token.Claims.(*SessionClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (j *JWTService) GetPublicKeyPEM() (string, error) {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: pubKeyBytes,
})
return string(pubKeyPEM), nil
}
+24 -20
View File
@@ -23,6 +23,9 @@ type ServerConfig struct {
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
}
// GarageConfig contains Garage S3 connection settings
@@ -37,13 +40,13 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
// AdminAuthConfig contains admin authentication settings
type AdminAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
@@ -139,6 +142,9 @@ func bindEnvVars() {
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN")
viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL")
viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL")
// Garage config
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
@@ -149,9 +155,9 @@ func bindEnvVars() {
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
// Auth config
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED")
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
@@ -208,28 +214,26 @@ func (c *Config) Validate() error {
return fmt.Errorf("garage admin_token is required")
}
// Validate auth mode
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
}
// Validate basic auth if enabled
if c.Auth.Mode == "basic" {
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
// Validate admin auth if enabled
if c.Auth.Admin.Enabled {
if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" {
return fmt.Errorf("admin auth username and password are required when admin auth is enabled")
}
}
// Validate OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.Enabled {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
return fmt.Errorf("oidc client_id is required when oidc is enabled")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
return fmt.Errorf("oidc issuer_url is required when oidc is enabled")
}
if c.Server.RootURL == "" {
return fmt.Errorf("server.root_url is required when oidc is enabled")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *auth.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
}
}
// GetAuthConfig returns the current authentication configuration
// @Summary Get authentication configuration
// @Description Returns the current auth configuration (admin and/or OIDC)
// @Tags auth
// @Produce json
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
// @Router /auth/config [get]
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
response := fiber.Map{
"admin": fiber.Map{
"enabled": h.cfg.Auth.Admin.Enabled,
},
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
}
// Add provider name if OIDC is enabled
if h.cfg.Auth.OIDC.Enabled {
provider := h.cfg.Auth.OIDC.ProviderName
if provider == "" {
provider = "OIDC Provider"
}
response["oidc"].(fiber.Map)["provider"] = provider
}
return c.JSON(response)
}
// LoginBasicRequest represents the basic auth login request
type LoginBasicRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
// LoginAdmin handles admin authentication login
// @Summary Admin auth login
// @Description Authenticate with admin username and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body LoginBasicRequest true "Login credentials"
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
// @Failure 400 {object} models.APIResponse "Invalid request"
// @Failure 401 {object} models.APIResponse "Invalid credentials"
// @Router /auth/login [post]
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Parse request body
var req LoginBasicRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Validate credentials against admin config
if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Create user info object
userInfo := &auth.UserInfo{
Username: req.Username,
}
// Generate JWT session 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
// @Description Returns information about the currently authenticated user
// @Tags auth
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} object{success=bool,user=object} "User information"
// @Failure 401 {object} models.APIResponse "Not authenticated"
// @Router /auth/me [get]
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
// Try to get user info from OIDC context
userInfoInterface := c.Locals("userInfo")
if userInfoInterface != nil {
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": userInfo.Username,
"email": userInfo.Email,
"name": userInfo.Name,
},
})
}
}
// Try to get username from basic auth context
usernameInterface := c.Locals("username")
if usernameInterface != nil {
username, ok := usernameInterface.(string)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": username,
},
})
}
}
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"),
)
}
+2 -15
View File
@@ -111,25 +111,12 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
)
}
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if bucketInfo != nil {
return c.Status(fiber.StatusConflict).JSON(
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
)
}
// Create the bucket
createBucketReq := models.CreateBucketAdminRequest{
GlobalAlias: &req.Name,
}
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
+35
View File
@@ -251,6 +251,41 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(userInfo))
}
// GetUserSecretKey retrieves the secret key for a specific user/access key
//
// @Summary Get user secret key
// @Description Retrieves the secret access key for a specific user/access key
// @Tags Users
// @Produce json
// @Param access_key path string true "Access key of the user to retrieve secret for"
// @Success 200 {object} models.APIResponse{data=map[string]string} "Secret key retrieved successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get secret key"
// @Router /api/v1/users/{access_key}/secret [get]
func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
// Get key information WITH secret key
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
)
}
// Return only the secret key
return c.JSON(models.SuccessResponse(map[string]string{
"secretKey": *keyInfo.SecretAccessKey,
}))
}
// UpdateUserPermissions updates user permissions
//
// @Summary Update user permissions
+40 -71
View File
@@ -9,94 +9,63 @@ import (
)
// AuthMiddleware returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, and OIDC
// It handles multiple auth methods: admin and OIDC
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// If auth mode is "none", allow all requests
if cfg.Mode == "none" {
// If no auth is enabled, allow all requests
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
// Get Authorization header
authHeader := c.Get("Authorization")
// Try admin auth if enabled and header is present
if cfg.Admin.Enabled && authHeader != "" {
// Check if it's a Bearer token (JWT from admin login)
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(token)
if err == nil {
// Valid admin token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
if userInfo.Email != "" {
c.Locals("email", userInfo.Email)
}
return c.Next()
}
}
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
// Try OIDC auth if enabled
if cfg.OIDC.Enabled {
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
if sessionCookie != "" {
// Validate JWT session token from cookie
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err == nil {
// Valid OIDC token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
}
}
// Unknown auth mode - deny access
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
)
}
}
// handleBasicAuth validates basic authentication credentials
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
// Get Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
)
}
// Parse basic auth credentials
username, password, ok := auth.ParseBasicAuth(authHeader)
if !ok {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
)
}
// Validate credentials
if !authService.ValidateBasicAuth(username, password) {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Store username in context for later use
c.Locals("username", username)
return c.Next()
}
// handleOIDCAuth validates OIDC session/token
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
// Get session cookie
sessionCookie := c.Cookies(oidcCfg.CookieName)
if sessionCookie == "" {
// No valid authentication found
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
)
}
// Store user info in context for handlers to use
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
+1 -1
View File
@@ -194,7 +194,7 @@ type ClusterHealth struct {
KnownNodes int `json:"knownNodes"`
ConnectedNodes int `json:"connectedNodes"`
StorageNodes int `json:"storageNodes"`
StorageNodesUp int `json:"storageNodesUp"`
StorageNodesUp int `json:"storageNodesOk"`
Partitions int `json:"partitions"`
PartitionsQuorum int `json:"partitionsQuorum"`
PartitionsAllOk int `json:"partitionsAllOk"`
+27 -26
View File
@@ -39,6 +39,12 @@ func SetupRoutes(
// Swagger documentation endpoint (no auth required)
app.Get("/docs/*", swagger.HandlerDefault)
// Create auth handler
authHandler := handlers.NewAuthHandler(cfg, authService)
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// API v1 group
api := app.Group("/api/v1")
@@ -74,6 +80,7 @@ func SetupRoutes(
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
@@ -96,12 +103,22 @@ func SetupRoutes(
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// Admin auth login endpoint (only if admin is enabled)
if cfg.Auth.Admin.Enabled {
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
if cfg.Auth.OIDC.Enabled {
oidcRoutes := app.Group("/auth/oidc")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
oidcRoutes.Get("/login", func(c fiber.Ctx) error {
state, err := authService.GenerateStateToken()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -119,7 +136,7 @@ func SetupRoutes(
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
oidcRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get and validate state token
state := c.Query("state")
if !authService.ValidateAndConsumeState(state) {
@@ -179,14 +196,12 @@ func SetupRoutes(
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
// Redirect to frontend with success indicator
return c.Redirect().To("/?login=success")
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
oidcRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
@@ -199,33 +214,19 @@ func SetupRoutes(
"message": "Logged out successfully",
})
})
// User info endpoint
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
userInfo := c.Locals("userInfo")
if userInfo == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Not authenticated",
})
}
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
}
}
cfg.Server.FrontendPath = "./frontend/dist"
// Check if frontend path exists
if _, err := os.Stat(cfg.Server.FrontendPath); err == nil {
fmt.Println("Serving frontend from:", cfg.Server.FrontendPath)
// SPA fallback - serve index.html for all non-API routes
app.Use(func(c fiber.Ctx) error {
path := c.Path()
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
fmt.Println("API or health check route, skipping SPA fallback:", path)
+25 -11
View File
@@ -3,6 +3,7 @@ package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
"context"
"encoding/json"
"fmt"
@@ -31,17 +32,30 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
}
}
// doRequest performs an HTTP request to the Admin API
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
return s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var reqErr error
resp, reqErr = s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
return reqErr
})
if err != nil {
return nil, err
}
return resp, nil
}
// decodeResponse decodes a JSON response into the target structure
@@ -210,7 +224,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
if err = decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
+84 -17
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
"Noooste/garage-ui/internal/config"
@@ -24,9 +26,20 @@ type S3Service struct {
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
}
if strings.HasPrefix(cfg.Endpoint, "https://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
cfg.UseSSL = true
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
if err != nil {
panic(fmt.Errorf("failed to create MinIO client: %w", err))
@@ -99,6 +112,7 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
client, err := minio.New(s.config.Endpoint, &minio.Options{
Creds: creds,
Secure: s.config.UseSSL,
Region: s.config.Region,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client for bucket %s: %w", bucketName, err)
@@ -109,8 +123,15 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call MinIO ListBuckets API
bucketInfos, err := s.client.ListBuckets(ctx)
var bucketInfos []minio.BucketInfo
// Call MinIO ListBuckets API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var listErr error
bucketInfos, listErr = s.client.ListBuckets(ctx)
return listErr
})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
}
@@ -137,9 +158,12 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO MakeBucket API
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
// Call MinIO MakeBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
})
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
@@ -155,8 +179,11 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveBucket API
err = client.RemoveBucket(ctx, bucketName)
// Call MinIO RemoveBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.RemoveBucket(ctx, bucketName)
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
}
@@ -260,8 +287,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
ContentType: contentType,
}
// Call MinIO PutObject API
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
var info minio.UploadInfo
// Call MinIO PutObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var uploadErr error
info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts)
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
@@ -277,8 +311,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Call MinIO GetObject API
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
var object *minio.Object
// Call MinIO GetObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
return getErr
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
@@ -304,8 +345,11 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call MinIO RemoveObject API
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
// Call MinIO RemoveObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
}
@@ -321,7 +365,15 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var statErr error
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
_, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
// Check if error is "object not found"
errResponse := minio.ToErrorResponse(err)
@@ -341,7 +393,15 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var stat minio.ObjectInfo
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var statErr error
stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
}
@@ -402,8 +462,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Generate presigned GET URL
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
var presignedURL *url.URL
// Generate presigned GET URL with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var presignErr error
presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
return presignErr
})
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
+13 -5
View File
@@ -25,9 +25,6 @@ import (
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email support@garage-ui.io
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
@@ -81,8 +78,19 @@ func main() {
log.Println("Initializing S3 service...")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
// Determine enabled auth methods for logging
authMethods := []string{}
if cfg.Auth.Admin.Enabled {
authMethods = append(authMethods, "admin")
}
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
}
+104
View File
@@ -0,0 +1,104 @@
package utils
import (
"context"
"errors"
"fmt"
"net"
"syscall"
"time"
)
// RetryConfig configures retry behavior
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 100 * time.Millisecond,
MaxBackoff: 5 * time.Second,
BackoffFactor: 2.0,
}
}
// IsConnectionRefused checks if the error is a connection refused error
func IsConnectionRefused(err error) bool {
if err == nil {
return false
}
// Check for connection refused error
var opErr *net.OpError
if errors.As(err, &opErr) {
// Check if it's a connection refused error
if opErr.Op == "dial" || opErr.Op == "read" {
var syscallErr *syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
return *syscallErr == syscall.ECONNREFUSED
}
}
}
// Also check for "connection refused" in error message as fallback
if errors.Is(err, syscall.ECONNREFUSED) {
return true
}
return false
}
// RetryWithBackoff executes a function with exponential backoff on connection refused errors
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Execute the function
err := fn()
if err == nil {
return nil
}
lastErr = err
// If it's not a connection refused error, don't retry
if !IsConnectionRefused(err) {
return err
}
// If this was the last attempt, return the error
if attempt == config.MaxRetries {
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err)
}
// Calculate backoff duration with exponential increase
backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt)))
if backoff > config.MaxBackoff {
backoff = config.MaxBackoff
}
// Wait with context cancellation support
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
case <-time.After(backoff):
// Continue to next attempt
}
}
return lastErr
}
// pow is a simple integer exponentiation helper
func pow(base float64, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
+12 -8
View File
@@ -5,29 +5,33 @@ server:
host: "0.0.0.0"
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
protocol: "http" # Protocol for internal communication (http/https)
root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production)
# Garage S3 Configuration
garage:
endpoint: "http://localhost:3900" # Garage S3 API endpoint
region: "eu-west-1" # S3 region (can be any value for Garage)
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
# Garage Admin API configuration
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
admin_token: "changeme" # Admin API bearer token
# Authentication Configuration
# You can enable one or both authentication methods
auth:
# Auth mode: "none", "basic", or "oidc"
mode: "none"
# Basic Authentication (only used when mode = "basic")
basic:
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "changeme"
# OIDC Configuration (only used when mode = "oidc")
# 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
oidc:
enabled: true
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
+1 -1
View File
@@ -26,7 +26,7 @@ services:
- garage
environment:
# Garage S3 Configuration
GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900"
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
# Server Configuration
+569 -739
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {QueryClientProvider} from '@tanstack/react-query';
import {ThemeProvider, useTheme} from '@/components/theme-provider';
@@ -6,8 +7,12 @@ import {Dashboard} from '@/pages/Dashboard';
import {Buckets} from '@/pages/Buckets';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Login} from '@/pages/Login';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
import {ProtectedRoute} from '@/components/auth/ProtectedRoute';
import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
function ThemedToaster() {
const { theme } = useTheme();
@@ -16,12 +21,31 @@ function ThemedToaster() {
}
function App() {
const { initialize, isLoading } = useAuthStore();
useEffect(() => {
initialize();
}, [initialize]);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="cluster" element={<Cluster />} />
@@ -0,0 +1,118 @@
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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Lock, LogIn } from 'lucide-react';
import type { AuthConfig } from '@/types/auth';
interface BasicLoginFormProps {
showOIDC?: boolean;
config?: AuthConfig | null;
}
export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginAdmin, loginOIDC } = useAuthStore();
const returnUrl = searchParams.get('returnUrl') || '/';
const providerName = config?.oidc?.provider || 'OIDC Provider';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await loginAdmin(username, password);
// Navigate to return URL on success
navigate(decodeURIComponent(returnUrl));
} catch (error) {
// Error is already handled by the store and toast
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Lock className="h-6 w-6 text-primary" />
</div>
</div>
<CardTitle className="text-2xl text-center">
{showOIDC ? 'Sign in to Garage UI' : 'Admin Login'}
</CardTitle>
<CardDescription className="text-center">
{showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">Username</label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isLoading}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">Password</label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !username || !password}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
{showOIDC && (
<div className="mt-4">
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">Or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={loginOIDC}
>
<LogIn className="mr-2 h-4 w-4" />
Sign in with {providerName}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react';
export function LoadingSpinner() {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
export function OIDCLoginView() {
const { config, loginOIDC } = useAuthStore();
const providerName = config?.oidc.provider || 'OIDC Provider';
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<LogIn className="h-6 w-6 text-primary" />
</div>
</div>
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
<CardDescription className="text-center">
Authenticate using your {providerName} account
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={loginOIDC}
className="w-full"
size="lg"
>
<LogIn className="mr-2 h-5 w-5" />
Continue with {providerName}
</Button>
<p className="mt-4 text-center text-xs text-muted-foreground">
You will be redirected to {providerName} to complete the sign-in process
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,29 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { LoadingSpinner } from './LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, config } = useAuthStore();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
// If no auth is enabled, always allow access
if (config && !config.admin.enabled && !config.oidc.enabled) {
return <>{children}</>;
}
// If not authenticated, redirect to login with return URL
if (!isAuthenticated) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
}
return <>{children}</>;
}
+32 -12
View File
@@ -1,6 +1,8 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
import {useAuthStore} from '@/store/auth-store';
import {Button} from '@/components/ui/button';
interface NavItem {
title: string;
@@ -38,6 +40,11 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
return (
<div
@@ -76,17 +83,30 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
);
})}
</nav>
{/*<div className="border-t p-4">*/}
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
{/* AD*/}
{/* </div>*/}
{/* <div className="flex-1 overflow-hidden">*/}
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
{/* </div>*/}
{/* </div>*/}
{/*</div>*/}
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
)}
</div>
);
}
+73
View File
@@ -15,6 +15,7 @@ import type {
S3Object,
StorageMetrics,
} from '@/types';
import type { AuthUser } from '@/types/auth';
const api = axios.create({
baseURL: '/api',
@@ -23,6 +24,14 @@ const api = axios.create({
},
});
// Separate axios instance for auth endpoints (which are not under /api)
const authApiClient = axios.create({
baseURL: '/auth',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
@@ -31,6 +40,14 @@ api.interceptors.request.use((config) => {
return config;
});
authApiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
// If response has success=false in data, treat it as an error
@@ -50,6 +67,19 @@ api.interceptors.response.use(
return response;
},
(error) => {
// Handle 401 Unauthorized - redirect to login
if (error.response?.status === 401) {
// Clear auth token
localStorage.removeItem('auth-token');
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(error);
}
// Handle axios errors
if (error.response) {
// Server responded with error status
@@ -84,6 +114,44 @@ api.interceptors.response.use(
}
);
// Auth API
export const authApi = {
getConfig: async () => {
const response = await authApiClient.get<{
admin: { enabled: boolean };
oidc: { enabled: boolean; provider?: string };
}>('/config');
return response;
},
loginAdmin: async (username: string, password: string) => {
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', {
username,
password,
});
return response;
},
me: async () => {
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
return response;
},
logoutAdmin: async () => {
// For admin, just clear local storage (no server logout needed)
return Promise.resolve();
},
logoutOIDC: async () => {
const response = await authApiClient.post('/oidc/logout');
return response;
},
loginOIDC: () => {
window.location.href = '/auth/oidc/login';
},
};
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
@@ -222,6 +290,11 @@ export const accessApi = {
return response.data.data;
},
getSecretKey: async (accessKey: string): Promise<string> => {
const response = await api.get(`/v1/users/${accessKey}/secret`);
return response.data.data.secretKey;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
const response = await api.post('/v1/users', { name, permissions });
+312 -114
View File
@@ -46,6 +46,7 @@ export function AccessControl() {
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<AccessKey | null>(null);
// Edit permissions state
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
@@ -69,6 +70,11 @@ export function AccessControl() {
const [expirationDate, setExpirationDate] = useState<string>('');
const [neverExpires, setNeverExpires] = useState(true);
// Secret key dialog state
const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false);
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
useEffect(() => {
const fetchKeys = async () => {
try {
@@ -120,13 +126,8 @@ export function AccessControl() {
}
}
setCreateDialogOpen(false);
setNewKeyName('');
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
setCreateGrantPermissions(false);
// Store the newly created key to show the secret key
setNewlyCreatedKey(newKey);
// Refresh keys list
const data = await accessApi.listKeys();
@@ -138,6 +139,17 @@ export function AccessControl() {
}
};
const handleCloseCreateDialog = () => {
setCreateDialogOpen(false);
setNewKeyName('');
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
setCreateGrantPermissions(false);
setNewlyCreatedKey(null);
};
const handleOpenCreateDialog = async () => {
setCreateDialogOpen(true);
setNewKeyName('');
@@ -222,6 +234,23 @@ export function AccessControl() {
}
};
const handleRevealSecretKey = async (key: AccessKey) => {
setSelectedKey(key);
setIsLoadingSecretKey(true);
setSecretKeyDialogOpen(true);
setRevealedSecretKey('');
try {
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
setRevealedSecretKey(secretKey);
} catch (error) {
console.error('Failed to fetch secret key:', error);
setSecretKeyDialogOpen(false);
} finally {
setIsLoadingSecretKey(false);
}
};
const handleOpenEditPermissions = async (key: AccessKey) => {
setEditingKey(key);
setEditPermissionsDialogOpen(true);
@@ -466,6 +495,11 @@ export function AccessControl() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleRevealSecretKey(key)}>
<Key className="h-4 w-4" />
View Secret Key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
<Edit className="h-4 w-4" />
Edit Permissions
@@ -525,122 +559,200 @@ export function AccessControl() {
</div>
{/* Create Key Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key with optional bucket permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<Input
placeholder="My Application Key"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
A friendly name to identify this API key
</p>
</div>
{/* Optional: Grant permissions during creation */}
<div className="space-y-3 border-t pt-4">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="grant-permissions-on-create"
checked={createGrantPermissions}
onCheckedChange={(checked) => {
setCreateGrantPermissions(checked as boolean);
if (!checked) {
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
}
}}
/>
<span className="text-sm font-medium">Grant bucket permissions now</span>
</label>
<p className="text-xs text-muted-foreground">
You can also grant permissions later from the Edit Permissions menu
</p>
{createGrantPermissions && (
<div className="space-y-4 pl-6 pt-2">
{/* Bucket Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Bucket</label>
<Select
value={createSelectedBucket}
onChange={(value) => setCreateSelectedBucket(value)}
{newlyCreatedKey ? (
// Success state - show the secret key
<>
<DialogHeader>
<DialogTitle>API Key Created Successfully</DialogTitle>
<DialogDescription>
Save your secret access key now. You won't be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<div className="text-sm text-muted-foreground">{newlyCreatedKey.name}</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
{newlyCreatedKey.accessKeyId}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
<SelectOption value="">-- Select a bucket --</SelectOption>
{createAvailableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
{newlyCreatedKey.secretKey}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (newlyCreatedKey.secretKey) {
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
<div className="flex gap-2">
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Important: Save This Key Now
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
If you lose it, you'll need to create a new key.
</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseCreateDialog}>
Done
</Button>
</DialogFooter>
</>
) : (
// Creation form
<>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key with optional bucket permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<Input
placeholder="My Application Key"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
A friendly name to identify this API key
</p>
</div>
{/* Permissions */}
{createSelectedBucket && (
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-2 border rounded-lg p-3">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-read"
checked={createPermissionRead}
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Read</span>
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
</div>
</label>
{/* Optional: Grant permissions during creation */}
<div className="space-y-3 border-t pt-4">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="grant-permissions-on-create"
checked={createGrantPermissions}
onCheckedChange={(checked) => {
setCreateGrantPermissions(checked as boolean);
if (!checked) {
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
}
}}
/>
<span className="text-sm font-medium">Grant bucket permissions now</span>
</label>
<p className="text-xs text-muted-foreground">
You can also grant permissions later from the Edit Permissions menu
</p>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-write"
checked={createPermissionWrite}
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Write</span>
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-owner"
checked={createPermissionOwner}
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Owner</span>
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
</div>
</label>
{createGrantPermissions && (
<div className="space-y-4 pl-6 pt-2">
{/* Bucket Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Bucket</label>
<Select
value={createSelectedBucket}
onChange={(value) => setCreateSelectedBucket(value)}
>
<SelectOption value="">-- Select a bucket --</SelectOption>
{createAvailableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
</div>
{/* Permissions */}
{createSelectedBucket && (
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-2 border rounded-lg p-3">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-read"
checked={createPermissionRead}
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Read</span>
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-write"
checked={createPermissionWrite}
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Write</span>
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-owner"
checked={createPermissionOwner}
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Owner</span>
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
</div>
</label>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName}>
Create Key
</Button>
</DialogFooter>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreateDialog}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName}>
Create Key
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
@@ -665,6 +777,92 @@ export function AccessControl() {
</DialogContent>
</Dialog>
{/* Secret Key Dialog */}
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Secret Access Key</DialogTitle>
<DialogDescription>
Copy your secret access key now. For security reasons, it cannot be viewed again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<div className="text-sm text-muted-foreground">{selectedKey?.name}</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
{selectedKey?.accessKeyId}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (selectedKey?.accessKeyId) {
navigator.clipboard.writeText(selectedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
{isLoadingSecretKey ? (
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading secret key...</span>
</div>
) : (
<>
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
{revealedSecretKey}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (revealedSecretKey) {
navigator.clipboard.writeText(revealedSecretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
disabled={!revealedSecretKey}
>
<Copy className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
<div className="flex gap-2">
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Security Warning
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
Keep this secret key secure. Anyone with access to it can perform operations on your behalf.
</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setSecretKeyDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Key Settings Dialog */}
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
<DialogContent className="max-w-lg">
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
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 { LoadingSpinner } from '@/components/auth/LoadingSpinner';
export function Login() {
const { config, isLoading, initialize, isAuthenticated } = useAuthStore();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const loginSuccess = searchParams.get('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));
});
}
}, [loginSuccess, initialize, navigate, returnUrl]);
useEffect(() => {
// If already authenticated, redirect to return URL
if (isAuthenticated && !loginSuccess) {
navigate(decodeURIComponent(returnUrl));
}
}, [isAuthenticated, navigate, returnUrl, loginSuccess]);
if (isLoading || loginSuccess === 'success') {
return <LoadingSpinner />;
}
// No auth enabled, redirect to dashboard immediately
if (config && !config.admin.enabled && !config.oidc.enabled) {
navigate('/');
return null;
}
// Show login options based on what's enabled
const showAdmin = config?.admin.enabled || false;
const showOIDC = config?.oidc.enabled || false;
// If both are enabled, show both options in single modal
if (showAdmin && showOIDC) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold">Sign in to Garage UI</h1>
<p className="text-muted-foreground mt-2">Enter your credentials to continue</p>
</div>
<BasicLoginForm showOIDC={true} config={config} />
</div>
</div>
);
}
// Show only OIDC if enabled
if (showOIDC) {
return <OIDCLoginView />;
}
// Show only admin if enabled
if (showAdmin) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<BasicLoginForm />
</div>
</div>
);
}
// Still loading config
return <LoadingSpinner />;
}
+154
View File
@@ -0,0 +1,154 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AuthConfig, AuthUser, AuthState } from '@/types/auth';
import { authApi } from '@/lib/api';
interface AuthStore extends AuthState {
config: AuthConfig | null;
// Actions
setUser: (user: AuthUser | null) => void;
setConfig: (config: AuthConfig) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setAuthenticated: (authenticated: boolean) => void;
// Async actions
initialize: () => Promise<void>;
loginAdmin: (username: string, password: string) => Promise<void>;
loginOIDC: () => void;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
user: null,
config: null,
isAuthenticated: false,
isLoading: true,
error: null,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setConfig: (config) => set({ config }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
initialize: async () => {
try {
set({ isLoading: true, error: null });
// Fetch auth configuration
const configResponse = await authApi.getConfig();
const config = configResponse.data as AuthConfig;
set({ config });
// If no auth is enabled, mark as authenticated immediately
if (!config.admin.enabled && !config.oidc.enabled) {
set({
isAuthenticated: true,
isLoading: false,
user: { username: 'guest' }
});
return;
}
// Try to get current user (check if already authenticated)
try {
const userResponse = await authApi.me();
const user = userResponse.data.user;
set({
user,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
// Not authenticated - this is okay
set({
user: null,
isAuthenticated: false,
isLoading: false
});
}
} catch (error) {
console.error('Failed to initialize auth:', error);
set({
error: 'Failed to initialize authentication',
isLoading: false,
isAuthenticated: false
});
}
},
loginAdmin: async (username, password) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.loginAdmin(username, password);
const { token, user } = response.data;
// Store token in localStorage
localStorage.setItem('auth-token', token);
// Update state
set({
user,
isAuthenticated: true,
isLoading: false,
error: null
});
} catch (error: any) {
const errorMessage = error.response?.data?.error?.message || 'Login failed';
set({
error: errorMessage,
isLoading: false,
isAuthenticated: false,
user: null
});
throw error; // Re-throw for form handling
}
},
loginOIDC: () => {
// Redirect to OIDC login endpoint
window.location.href = '/auth/oidc/login';
},
logout: async () => {
const { config } = get();
try {
// Call logout endpoint for OIDC mode
if (config?.oidc.enabled) {
await authApi.logoutOIDC();
} else if (config?.admin.enabled) {
await authApi.logoutAdmin();
}
} catch (error) {
console.error('Logout API call failed:', error);
}
// Clear local storage
localStorage.removeItem('auth-token');
// Clear state
set({
user: null,
isAuthenticated: false,
error: null
});
// Redirect to login page
window.location.href = '/login';
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
// Don't persist config, isLoading, or error
}),
}
)
);
+22
View File
@@ -0,0 +1,22 @@
export interface AuthConfig {
admin: {
enabled: boolean;
};
oidc: {
enabled: boolean;
provider?: string;
};
}
export interface AuthUser {
username: string;
email?: string;
name?: string;
}
export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
+1
View File
@@ -60,6 +60,7 @@ export interface ObjectMetadata {
export interface AccessKey {
accessKeyId: string;
name: string;
secretKey?: string;
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
+4
View File
@@ -18,6 +18,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v2
name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
type: application
version: 0.1.3
appVersion: "v0.0.7"
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
home: https://github.com/Noooste/garage-ui
sources:
- https://github.com/Noooste/garage-ui
maintainers:
- name: Noooste
kubeVersion: ">=1.19.0-0"
+887
View File
@@ -0,0 +1,887 @@
# Garage UI Helm Chart
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.1.1-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.0.6-green.svg)](Chart.yaml)
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Configuration](#configuration)
- [Examples](#examples)
- [Upgrading](#upgrading)
- [Accessing the Application](#accessing-the-application)
- [Monitoring](#monitoring)
- [Troubleshooting](#troubleshooting)
- [Uninstalling](#uninstalling)
## Overview
Garage UI provides an intuitive web interface for managing your Garage S3 storage cluster, featuring:
- **Bucket Management** - Create, delete, and configure S3 buckets
- **Object Operations** - Upload, download, and delete objects through the UI
- **User Access Control** - Manage users, access keys, and permissions
- **Cluster Monitoring** - View cluster health, status, and statistics
- **Authentication** - Support for no auth, basic auth, and OIDC/SSO
## Prerequisites
Before installing this chart, ensure you have:
- **Kubernetes** `1.19+` or later
- **Helm** `3.0+` or later
- **Garage S3** instance running and accessible
- **Garage Admin Token** - Required for administrative operations
### Required Information
You will need the following information from your Garage installation:
1. **Garage S3 Endpoint** - The Garage S3 API endpoint (default port: `3900`)
2. **Garage Admin Endpoint** - The Garage Admin API endpoint (default port: `3903`)
3. **Admin Token** - Bearer token for authenticating with the Admin API
To find your admin token, check your Garage server's configuration file.
## Quick Start
The fastest way to get started with Garage UI:
### Step 1: Create a values file
Create a file named `my-values.yaml` with your Garage configuration:
```yaml
config:
garage:
endpoint: "http://garage:3900" # Your Garage S3 endpoint
admin_endpoint: "http://garage:3903" # Your Garage Admin endpoint
admin_token: "YOUR_ADMIN_TOKEN_HERE" # Your admin token
region: "garage" # S3 region (can be any value)
```
### Step 2: Install the chart
```bash
helm install garage-ui ./helm/garage-ui -f my-values.yaml
```
### Step 3: Access the UI
```bash
# Forward port to access locally
kubectl port-forward svc/garage-ui 8080:80
# Open in your browser
open http://localhost:8080
```
That's it! You should now have Garage UI running and accessible.
## Installation
### Installing from local chart
If you've cloned the repository:
```bash
helm install garage-ui ./helm/garage-ui -f my-values.yaml
```
### Installing with inline values
You can also set values directly on the command line:
```bash
helm install garage-ui ./helm/garage-ui \
--set config.garage.endpoint=http://garage:3900 \
--set config.garage.admin_endpoint=http://garage:3903 \
--set config.garage.admin_token=your-token-here
```
### Verify installation
Check that the pod is running:
```bash
kubectl get pods -l app.kubernetes.io/name=garage-ui
```
View the logs:
```bash
kubectl logs -l app.kubernetes.io/name=garage-ui
```
## Configuration
### Configuration Structure
The chart uses a structured `config` section that maps directly to the application's configuration file. You can override any value using Helm's standard methods.
### Minimal Configuration
The absolute minimum configuration requires only the Garage endpoints and admin token:
```yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
```
### Common Configuration Options
#### Server Settings
```yaml
config:
server:
port: 8080 # Application port
environment: production # Environment (production/development/staging)
```
#### Authentication Configuration
**No Authentication** (default, suitable for private networks):
```yaml
config:
auth:
admin:
enabled: false
oidc:
enabled: false
```
**Admin Authentication** (username/password with JWT):
```yaml
config:
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: false
```
**OIDC/SSO** (recommended for production):
```yaml
config:
auth:
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-secret"
issuer_url: "https://auth.example.com/realms/master"
# ... additional OIDC settings
```
**Both Authentication Methods** (admin and OIDC simultaneously):
```yaml
config:
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-secret"
issuer_url: "https://auth.example.com/realms/master"
# ... additional OIDC settings
```
#### CORS Configuration
```yaml
config:
cors:
enabled: true
allowed_origins:
- "https://garage-ui.example.com" # Recommended: specific origins
# - "*" # Or: allow all origins (less secure)
```
#### Logging
```yaml
config:
logging:
level: info # debug, info, warn, error
format: json # json (recommended for production), text
```
### Kubernetes Resource Configuration
#### Ingress
Enable external access with Ingress:
```yaml
ingress:
enabled: true
className: nginx
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
```
With TLS:
```yaml
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
```
#### Resource Limits
```yaml
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
```
#### Replicas and High Availability
```yaml
replicaCount: 3
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- garage-ui
topologyKey: kubernetes.io/hostname
```
### Complete Parameters Reference
For a complete list of all available parameters, see the [values.yaml](values.yaml) file which includes detailed comments for every configuration option.
## Examples
### Example 1: Basic Installation (In-Cluster Garage)
For Garage running in the same Kubernetes cluster:
```yaml
# values-basic.yaml
config:
garage:
endpoint: "http://garage.garage-system.svc.cluster.local:3900"
admin_endpoint: "http://garage.garage-system.svc.cluster.local:3903"
admin_token: "GgJLd9J...your-token-here"
region: "garage"
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-basic.yaml
```
### Example 2: External Access with Ingress
```yaml
# values-ingress.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-ingress.yaml
```
### Example 3: With Admin Authentication
```yaml
# values-admin-auth.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
auth:
admin:
enabled: true
username: "admin"
password: "super-secret-password-change-me"
oidc:
enabled: false
ingress:
enabled: true
className: nginx
hosts:
- host: garage.local
paths:
- path: /
pathType: Prefix
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-admin-auth.yaml
```
### Example 4: Production Setup with OIDC (Keycloak)
```yaml
# values-production.yaml
replicaCount: 3
config:
server:
environment: production
garage:
endpoint: "https://s3.garage.example.com"
admin_endpoint: "https://admin.garage.example.com"
admin_token: "your-admin-token"
region: "us-east-1"
auth:
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-client-secret"
scopes:
- openid
- email
- profile
issuer_url: "https://auth.example.com/realms/production"
auth_url: "https://auth.example.com/realms/production/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/production/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/production/protocol/openid-connect/userinfo"
cookie_secure: true
cookie_http_only: true
cookie_same_site: "lax"
cors:
enabled: true
allowed_origins:
- "https://garage-ui.example.com"
logging:
level: info
format: json
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 200m
memory: 256Mi
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- garage-ui
topologyKey: kubernetes.io/hostname
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-production.yaml
```
### Example 5: With Prometheus Monitoring
```yaml
# values-monitoring.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
serviceMonitor:
enabled: true
interval: 30s
labels:
prometheus: kube-prometheus
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-monitoring.yaml
```
### Example 6: Using Kubernetes Secrets for Admin Token (Recommended)
For improved security, store the admin token in a Kubernetes secret instead of in values files:
**Option A: Use an existing secret**
First, create a Kubernetes secret:
```bash
kubectl create secret generic garage-admin-token \
--from-literal=admin-token='your-admin-token-here'
```
Then configure the chart to use it:
```yaml
# values-with-secret.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
# Leave admin_token empty when using existingSecret
admin_token: ""
# Reference the existing secret
existingSecret:
name: "garage-admin-token"
key: "admin-token"
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-with-secret.yaml
```
**Option B: Let the chart create the secret**
If you provide `admin_token` in values but don't configure `existingSecret`, the chart will automatically create a secret for you:
```yaml
# values-auto-secret.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
# Chart will create a secret with this value
admin_token: "your-admin-token"
```
This approach keeps the token out of the ConfigMap while still allowing you to manage it through Helm values.
## Upgrading
### Upgrade to a new version
```bash
helm upgrade garage-ui ./helm/garage-ui -f my-values.yaml
```
### Upgrade with new values
```bash
helm upgrade garage-ui ./helm/garage-ui \
--set config.logging.level=debug \
--reuse-values
```
### Check upgrade history
```bash
helm history garage-ui
```
### Rollback to previous version
```bash
helm rollback garage-ui
```
## Accessing the Application
### Method 1: Port Forward (Development)
Quick access for testing:
```bash
kubectl port-forward svc/garage-ui 8080:80
```
Then open: http://localhost:8080
### Method 2: Ingress (Production)
If you've enabled Ingress with a domain:
```bash
# Access via your configured domain
open https://garage-ui.example.com
```
### Method 3: NodePort (Alternative)
Change service type to NodePort:
```yaml
service:
type: NodePort
port: 80
```
Find the assigned port:
```bash
kubectl get svc garage-ui
```
### Method 4: LoadBalancer (Cloud Environments)
For cloud providers with LoadBalancer support:
```yaml
service:
type: LoadBalancer
port: 80
```
Get the external IP:
```bash
kubectl get svc garage-ui
```
## Monitoring
### Prometheus ServiceMonitor
Enable Prometheus metrics scraping (requires Prometheus Operator):
```yaml
serviceMonitor:
enabled: true
interval: 30s
path: /api/v1/monitoring/metrics
labels:
prometheus: kube-prometheus
```
### Metrics Endpoint
The application exposes metrics at:
- Path: `/api/v1/monitoring/metrics`
- Format: Prometheus format (proxies Garage Admin API metrics)
### Health Checks
Health endpoint available at:
- Path: `/health`
- Response: `{"status": "ok", "version": "0.1.0"}`
Used by Kubernetes liveness and readiness probes.
## Troubleshooting
### Pods Not Starting
Check pod status and logs:
```bash
kubectl get pods -l app.kubernetes.io/name=garage-ui
kubectl describe pod -l app.kubernetes.io/name=garage-ui
kubectl logs -l app.kubernetes.io/name=garage-ui
```
Common issues:
- **Missing admin token**: Ensure `config.garage.admin_token` is set or `config.garage.existingSecret.name` points to a valid secret
- **Secret not found**: If using `existingSecret`, verify the secret exists: `kubectl get secret <secret-name>`
- **Unreachable Garage**: Verify endpoints are accessible from within the cluster
- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.oidc.enabled: true`
### Cannot Access the UI
For Ingress issues:
```bash
kubectl get ingress
kubectl describe ingress garage-ui
```
Check Ingress controller logs:
```bash
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx
```
Common issues:
- **DNS not configured**: Ensure your domain points to the Ingress controller
- **Certificate issues**: Check cert-manager logs if using automatic TLS
- **Ingress class mismatch**: Verify `ingress.className` matches your controller
### Configuration Not Updating
The deployment includes a ConfigMap checksum annotation that automatically triggers pod restarts when configuration changes. If it's not working:
```bash
kubectl rollout restart deployment/garage-ui
```
### Connection to Garage Fails
Test connectivity from within the pod:
```bash
kubectl exec -it deployment/garage-ui -- sh
# Inside the pod:
curl http://garage:3900
curl http://garage:3903/health
```
### Authentication Issues
**Admin Auth:**
- Verify username/password in `config.auth.admin`
- Ensure `config.auth.admin.enabled` is set to `true`
- Check browser developer tools for 401 errors
- Verify JWT token is being sent in Authorization header
**OIDC:**
- Ensure `config.auth.oidc.enabled` is set to `true`
- Verify all OIDC URLs are accessible
- Check OIDC provider logs
- Ensure redirect URI is registered: `https://your-domain/auth/oidc/callback`
- Verify client ID and secret
### View Application Logs
```bash
# Follow logs
kubectl logs -f -l app.kubernetes.io/name=garage-ui
# View recent logs
kubectl logs --tail=100 -l app.kubernetes.io/name=garage-ui
# Export logs
kubectl logs -l app.kubernetes.io/name=garage-ui > garage-ui.log
```
### Debug Mode
Enable debug logging:
```yaml
config:
logging:
level: debug
```
## Uninstalling
### Remove the release
```bash
helm uninstall garage-ui
```
This removes all Kubernetes components associated with the chart.
### Clean up completely
If you want to remove all associated resources:
```bash
# Uninstall the release
helm uninstall garage-ui
# Remove any remaining ConfigMaps
kubectl delete configmap -l app.kubernetes.io/name=garage-ui
# Remove any remaining Secrets
kubectl delete secret -l app.kubernetes.io/name=garage-ui
```
## Advanced Configuration
### Using Secrets for Sensitive Data
The chart supports using Kubernetes secrets for sensitive data to avoid storing credentials in values files.
#### Admin Token from Secret
The chart has built-in support for storing the admin token in a Kubernetes secret:
**Method 1: Use an existing secret** (recommended)
```bash
# Create the secret
kubectl create secret generic garage-admin-token \
--from-literal=admin-token='your-admin-token-here'
```
Configure in values:
```yaml
config:
garage:
existingSecret:
name: "garage-admin-token"
key: "admin-token"
```
**Method 2: Let the chart create the secret**
Simply provide the `admin_token` in values, and the chart will automatically create a secret:
```yaml
config:
garage:
admin_token: "your-admin-token"
```
The chart will:
1. Create a secret named `<release-name>-admin-token` with the token
2. Remove the token from the ConfigMap
3. Inject it into the pod via environment variable
This keeps sensitive data out of ConfigMaps while maintaining easy Helm-based management.
#### OIDC Client Secret
For OIDC credentials, you can similarly use secrets (requires manual template modification):
```bash
kubectl create secret generic garage-oidc \
--from-literal=client-secret='your-oidc-secret'
```
### Network Policies
Enable network policies for enhanced security:
```yaml
networkPolicy:
enabled: true
policyTypes:
- Ingress
- Egress
```
This restricts network traffic to/from the pods. Requires a CNI that supports NetworkPolicy (e.g., Calico, Cilium).
### Custom Container Images
Use a custom or private registry:
```yaml
image:
repository: myregistry.example.com/garage-ui
tag: "v1.0.0"
pullPolicy: Always
imagePullSecrets:
- name: myregistrykey
```
## Version Compatibility
| Chart Version | App Version | Kubernetes | Garage |
|--------------|-------------|------------|--------|
| 0.1.1 | v0.0.6 | 1.19+ | 0.8+ |
## Additional Resources
- **GitHub Repository**: https://github.com/Noooste/garage-ui
- **Garage Documentation**: https://garagehq.deuxfleurs.fr/
- **Issue Tracker**: https://github.com/Noooste/garage-ui/issues
- **Helm Documentation**: https://helm.sh/docs/
## Support
For help and support:
1. **Documentation**: Check this README and the [values.yaml](values.yaml) file
2. **GitHub Issues**: Report bugs or request features at https://github.com/Noooste/garage-ui/issues
3. **Garage Community**: Visit https://garagehq.deuxfleurs.fr/ for Garage-specific questions
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
This Helm chart is open source and distributed under the same license as Garage UI.
+27
View File
@@ -0,0 +1,27 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "garage-ui.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "garage-ui.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "garage-ui.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "garage-ui.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
2. To view logs:
kubectl logs -f deployment/{{ include "garage-ui.fullname" . }} -n {{ .Release.Namespace }}
For more information, see the README at https://github.com/Noooste/garage-ui/blob/main/helm/garage-ui/README.md
+51
View File
@@ -0,0 +1,51 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "garage-ui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "garage-ui.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "garage-ui.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "garage-ui.labels" -}}
helm.sh/chart: {{ include "garage-ui.chart" . }}
{{ include "garage-ui.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "garage-ui.selectorLabels" -}}
app.kubernetes.io/name: {{ include "garage-ui.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "garage-ui.fullname" . }}-config
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
data:
config.yaml: |
{{- $config := deepCopy .Values.config }}
{{- $_ := unset $config.garage "admin_token" }}
{{- $config | toYaml | nindent 4 }}
+91
View File
@@ -0,0 +1,91 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "garage-ui.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.config.server.port }}
protocol: TCP
env:
- name: GARAGE_UI_GARAGE_ADMIN_TOKEN
valueFrom:
secretKeyRef:
{{- if .Values.config.garage.existingSecret.name }}
name: {{ .Values.config.garage.existingSecret.name }}
key: {{ .Values.config.garage.existingSecret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-admin-token
key: admin-token
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
httpGet:
path: {{ .Values.livenessProbe.httpGet.path }}
port: {{ .Values.livenessProbe.httpGet.port }}
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
httpGet:
path: {{ .Values.readinessProbe.httpGet.path }}
port: {{ .Values.readinessProbe.httpGet.port }}
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: config
configMap:
name: {{ include "garage-ui.fullname" . }}-config
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
+41
View File
@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "garage-ui.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,26 @@
{{- if .Values.networkPolicy.enabled -}}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
policyTypes:
{{- toYaml .Values.networkPolicy.policyTypes | nindent 4 }}
ingress:
- from: []
ports:
- protocol: TCP
port: 8080
egress:
- to: []
ports:
- protocol: TCP
port: 53
- protocol: UDP
port: 53
{{- end }}
+11
View File
@@ -0,0 +1,11 @@
{{- if and (not .Values.config.garage.existingSecret.name) .Values.config.garage.admin_token }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "garage-ui.fullname" . }}-admin-token
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
type: Opaque
data:
admin-token: {{ .Values.config.garage.admin_token | b64enc | quote }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "garage-ui.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,19 @@
{{- if .Values.serviceMonitor.enabled -}}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
path: {{ .Values.serviceMonitor.path }}
interval: {{ .Values.serviceMonitor.interval }}
{{- end }}
+567
View File
@@ -0,0 +1,567 @@
# Default values for garage-ui Helm chart
# This file contains configuration values for deploying Garage UI
# Customize the values below according to your deployment environment
# Number of replica pods to run
# Increase for high availability (recommended: 2-3 for production)
replicaCount: 1
# Docker image configuration
image:
# Container registry and image name
# Default uses the official Garage UI image from Docker Hub
repository: noooste/garage-ui
# Image pull policy
# Options: Always, IfNotPresent, Never
# IfNotPresent: Only pull if image is not already present locally
pullPolicy: IfNotPresent
# Image tag to use (defaults to chart appVersion if empty)
# Example: "1.0.0" or "latest"
tag: ""
# Credentials for accessing private container registries
# Example:
# imagePullSecrets:
# - name: regcred
imagePullSecrets: []
# Override the default chart name in resource names
# Leave empty to use the chart name
nameOverride: ""
# Override the full resource name (includes release name)
# Leave empty to use the default naming convention
fullnameOverride: ""
# ============================================================================
# APPLICATION CONFIGURATION
# ============================================================================
# This section contains the main application configuration
# Customize according to your Garage deployment and security requirements
#
# TIP: You can now easily override specific values using --set:
# --set config.server.port=9090
# --set config.garage.admin_token=your-token
# --set config.auth.mode=basic
config:
# ========================================
# Server Configuration
# ========================================
server:
# Network interface to bind to
# "0.0.0.0" listens on all interfaces (recommended for containers)
host: "0.0.0.0"
# Port the application listens on
# Default: 8080
port: 8080
# Deployment environment
# Options: "production", "development", "staging"
environment: "production"
# ========================================
# Garage S3 Storage Configuration
# ========================================
garage:
# Garage S3 API endpoint
# Format: http(s)://hostname:port
# Default port: 3900
# Example: "http://garage.example.com:3900" or "http://garage:3900" for in-cluster
endpoint: "http://garage:3900"
# S3 region name
# For Garage, this can be any arbitrary value (Garage ignores regions)
# Default: "garage"
region: "garage"
# Garage Admin API endpoint
# This is used for administrative operations like bucket and key management
# Default port: 3903
# Example: "http://garage.example.com:3903" or "http://garage:3903" for in-cluster
admin_endpoint: "http://garage:3903"
# Admin API bearer token
# REQUIRED: Obtain this from your Garage server configuration
# This token grants administrative access - keep it secure!
# To generate: See Garage documentation for admin token setup
# NOTE: If existingSecret is configured, this value will be ignored
admin_token: ""
# Use an existing Kubernetes secret for the admin token (recommended for production)
# When configured, this takes precedence over the admin_token value above
# The secret should contain a key with the admin token value
existingSecret:
# Name of the existing secret containing the admin token
# Leave empty to use the admin_token value above instead
# Example: "garage-admin-token"
name: ""
# Key within the secret that contains the admin token value
# Default: "admin-token"
key: "admin-token"
# ========================================
# Authentication Configuration
# ========================================
auth:
# Authentication mode
# Options:
# "none" - No authentication (open access - not recommended for production)
# "basic" - Simple username/password authentication
# "oidc" - OpenID Connect integration (recommended for production)
mode: "none"
# Basic Authentication Settings
# Only used when mode = "basic"
# Provides simple username/password protection
basic:
# Username for basic auth login
username: "admin"
# Password for basic auth login
# IMPORTANT: Change this default password immediately!
password: "changeme"
# OpenID Connect (OIDC) Configuration
# Only used when mode = "oidc"
# Integrates with identity providers like Keycloak, Auth0, Okta, etc.
oidc:
# Enable/disable OIDC (must be true when mode = "oidc")
enabled: false
# Display name of your OIDC provider
# Examples: "Keycloak", "Auth0", "Okta", "Azure AD"
provider_name: "Keycloak"
# OAuth2 client ID registered with your OIDC provider
# Obtain this from your OIDC provider's application settings
client_id: "garage-ui"
# OAuth2 client secret registered with your OIDC provider
# IMPORTANT: Keep this secret secure! Consider using Kubernetes secrets
client_secret: "your-client-secret"
# OAuth2/OIDC scopes to request during authentication
# Standard scopes: openid (required), email, profile
# Add custom scopes as needed by your provider
scopes:
- openid
- email
- profile
# OIDC Provider Endpoints
# Replace "keycloak.example.com" and realm with your actual values
# For Keycloak: https://your-keycloak/realms/your-realm
# For Auth0: https://your-tenant.auth0.com
# For Okta: https://your-domain.okta.com
# OIDC issuer URL (base URL for OIDC discovery)
issuer_url: "https://keycloak.example.com/realms/master"
# Authorization endpoint URL
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
# Token endpoint URL
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
# User info endpoint URL
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
# Token Validation Settings
# Set to true only if you have issuer mismatch issues (not recommended)
skip_issuer_check: false
# Set to true to skip token expiry validation (not recommended for production)
skip_expiry_check: false
# User Attribute Mappings
# Map OIDC claims to application user attributes
# Adjust these based on your OIDC provider's claim structure
# Claim containing user's email address
email_attribute: "email"
# Claim containing user's username/login ID
username_attribute: "preferred_username"
# Claim containing user's display name
name_attribute: "name"
# Role-Based Access Control (Optional)
# Path to roles in the OIDC token claims
# Example for Keycloak: "resource_access.garage-ui.roles"
# Example for Auth0: "https://your-app/roles"
role_attribute_path: "resource_access.garage-ui.roles"
# Role name that grants admin privileges
# Users with this role will have full administrative access
admin_role: "admin"
# TLS/SSL Configuration
# Skip TLS certificate verification (only for testing - never in production!)
tls_skip_verify: false
# Session Management
# How long user sessions remain valid without activity
# Value in seconds (86400 = 24 hours)
session_max_age: 86400
# Name of the session cookie
cookie_name: "garage_session"
# Only send cookie over HTTPS connections
# Set to false only for local development without HTTPS
cookie_secure: true
# Prevent JavaScript access to the cookie (security feature)
# Should remain true for security
cookie_http_only: true
# SameSite cookie attribute for CSRF protection
# Options: "lax" (recommended), "strict", "none"
# Use "lax" for most cases, "strict" for maximum security
cookie_same_site: "lax"
# ========================================
# CORS (Cross-Origin Resource Sharing)
# ========================================
# Configure which origins can access the API from browsers
cors:
# Enable or disable CORS
# Disable if the frontend and backend are served from the same origin
enabled: true
# List of allowed origins
# "*" allows all origins (convenient but less secure)
# For production, specify exact origins:
# Example:
# - "https://garage-ui.example.com"
# - "https://app.example.com"
allowed_origins:
- "*"
# HTTP methods that are allowed in CORS requests
# Include all methods your API uses
allowed_methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
# HTTP headers that are allowed in CORS requests
# Add any custom headers your application requires
allowed_headers:
- Origin
- Content-Type
- Accept
- Authorization
# Allow credentials (cookies, authorization headers) in CORS requests
# Set to true if your frontend needs to send authentication cookies
# NOTE: When true, allowed_origins cannot be "*"
allow_credentials: false
# How long browsers can cache CORS preflight responses (in seconds)
# 3600 = 1 hour
max_age: 3600
# ========================================
# Logging Configuration
# ========================================
logging:
# Log verbosity level
# Options:
# "debug" - Verbose logging, useful for troubleshooting
# "info" - Standard logging (recommended for production)
# "warn" - Only warnings and errors
# "error" - Only errors
level: "info"
# Log output format
# Options:
# "json" - Structured JSON format (recommended for production/log aggregation)
# "text" - Human-readable text format (useful for development)
format: "json"
# ============================================================================
# KUBERNETES POD CONFIGURATION
# ============================================================================
# Annotations to add to the pod
# Use for integrations with service meshes, monitoring, etc.
# Example:
# podAnnotations:
# prometheus.io/scrape: "true"
# prometheus.io/port: "8080"
podAnnotations: {}
# Pod-level security context
# Defines security settings for all containers in the pod
podSecurityContext:
# Run containers as non-root user (security best practice)
runAsNonRoot: true
# User ID to run containers as
# Default: 1000 (non-privileged user)
runAsUser: 1000
# Group ID for filesystem access
# Files created by the pod will have this group ownership
fsGroup: 1000
# Container-level security context
# Security settings specific to the application container
securityContext:
# Prevent privilege escalation (security best practice)
# Ensures the container cannot gain more privileges than its parent
allowPrivilegeEscalation: false
# Drop all Linux capabilities and only add what's needed
# This follows the principle of least privilege
capabilities:
drop:
- ALL
# Allow write access to the root filesystem
# Set to true for read-only root filesystem (more secure but may require adjustments)
readOnlyRootFilesystem: false
# ============================================================================
# KUBERNETES SERVICE
# ============================================================================
service:
# Service type determines how the service is exposed
# Options:
# ClusterIP - Internal only (default, recommended when using Ingress)
# NodePort - Expose on each node's IP at a static port
# LoadBalancer - Expose using a cloud provider's load balancer
type: ClusterIP
# Port the service listens on
# This is the port other services/ingress will connect to
# Default: 80
port: 80
# ============================================================================
# INGRESS CONFIGURATION
# ============================================================================
# Ingress exposes HTTP/HTTPS routes from outside the cluster to the service
ingress:
# Enable or disable ingress
# Set to true to make the application accessible from outside the cluster
enabled: false
# Ingress class name
# Specifies which ingress controller to use
# Common values: "nginx", "traefik", "alb" (AWS)
# Ensure the specified controller is installed in your cluster
className: "nginx"
# Additional annotations for the ingress
# Use for configuring ingress controller behavior, SSL, authentication, etc.
# Examples:
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # For automatic SSL certificates
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# nginx.ingress.kubernetes.io/proxy-body-size: "100m"
annotations: {}
# Hostname and path configuration
hosts:
# Replace "garage-ui.local" with your actual domain
- host: garage-ui.local
paths:
# Path where the application will be accessible
- path: /
# pathType options: Prefix, Exact, ImplementationSpecific
pathType: Prefix
# TLS/SSL configuration
# Uncomment and configure to enable HTTPS
# Requires a TLS certificate stored in a Kubernetes secret
tls: []
# - secretName: garage-ui-tls
# hosts:
# - garage-ui.local
#
# To use cert-manager for automatic certificates:
# 1. Install cert-manager in your cluster
# 2. Create a ClusterIssuer (e.g., letsencrypt-prod)
# 3. Add annotation: cert-manager.io/cluster-issuer: "letsencrypt-prod"
# 4. Uncomment the tls section above
# ============================================================================
# RESOURCE LIMITS AND REQUESTS
# ============================================================================
# Configure CPU and memory allocation for the pods
# Requests: Guaranteed resources (used for scheduling)
# Limits: Maximum resources the container can use
resources:
limits:
# Maximum CPU cores the container can use
# 500m = 0.5 CPU cores
# Increase for high-traffic deployments
cpu: 500m
# Maximum memory the container can use
# Container will be killed if it exceeds this limit
memory: 512Mi
requests:
# Guaranteed CPU allocated to the container
# 100m = 0.1 CPU cores
# Cluster scheduler ensures this amount is available
cpu: 100m
# Guaranteed memory allocated to the container
# Cluster scheduler ensures this amount is available
memory: 128Mi
# ============================================================================
# HEALTH CHECKS
# ============================================================================
# Liveness Probe
# Kubernetes restarts the container if this probe fails
# Detects if the application is running but in a broken state
livenessProbe:
# Enable or disable the liveness probe
enabled: true
# HTTP endpoint to check
httpGet:
# Health check endpoint path
path: /health
# Port name (defined in the container spec)
port: http
# Wait time before starting liveness checks after container starts
# Give the application time to initialize
initialDelaySeconds: 30
# How often to perform the probe (in seconds)
periodSeconds: 10
# Maximum time to wait for the probe to complete
timeoutSeconds: 3
# Number of consecutive failures before restarting the container
failureThreshold: 3
# Readiness Probe
# Kubernetes stops sending traffic if this probe fails
# Determines when the container is ready to accept traffic
readinessProbe:
# Enable or disable the readiness probe
enabled: true
# HTTP endpoint to check
httpGet:
# Readiness check endpoint path
path: /health
# Port name (defined in the container spec)
port: http
# Wait time before starting readiness checks after container starts
initialDelaySeconds: 10
# How often to perform the probe (in seconds)
periodSeconds: 5
# Maximum time to wait for the probe to complete
timeoutSeconds: 3
# Number of consecutive failures before marking as not ready
failureThreshold: 3
# ============================================================================
# MONITORING AND OBSERVABILITY
# ============================================================================
# ServiceMonitor for Prometheus Operator
# Automatically configure Prometheus to scrape metrics from the application
# Requires Prometheus Operator to be installed in the cluster
serviceMonitor:
# Enable or disable ServiceMonitor creation
# Set to true if using Prometheus Operator for monitoring
enabled: false
# How often Prometheus should scrape metrics
# Format: duration string (e.g., "30s", "1m", "5m")
interval: 30s
# Metrics endpoint path
# The application exposes metrics at this path
path: /api/v1/monitoring/metrics
# Additional labels for the ServiceMonitor
# Use to match Prometheus scrape configurations
# Example:
# labels:
# prometheus: kube-prometheus
labels: {}
# ============================================================================
# NETWORK POLICY
# ============================================================================
# Controls network traffic to/from the pods
# Requires a network plugin that supports NetworkPolicy (e.g., Calico, Cilium)
networkPolicy:
# Enable or disable NetworkPolicy creation
# Provides network-level security by restricting pod communication
enabled: false
# Types of policies to enforce
# Ingress: Controls incoming traffic to the pod
# Egress: Controls outgoing traffic from the pod
policyTypes:
- Ingress
- Egress
# Note: When enabled, by default only allows necessary traffic
# Customize the NetworkPolicy template if you need specific rules
# ============================================================================
# POD SCHEDULING
# ============================================================================
# Node Selector
# Schedule pods only on nodes with specific labels
# Useful for deploying to specific node pools or hardware types
# Example:
# nodeSelector:
# disktype: ssd
# environment: production
nodeSelector: {}
# Tolerations
# Allow pods to be scheduled on nodes with matching taints
# Useful for dedicated node pools or special hardware
# Example:
# tolerations:
# - key: "dedicated"
# operator: "Equal"
# value: "garage-ui"
# effect: "NoSchedule"
tolerations: []
# Affinity Rules
# Advanced pod scheduling constraints
# Controls which nodes pods can be scheduled on and pod co-location
# Example for pod anti-affinity (spread pods across nodes):
# affinity:
# podAntiAffinity:
# preferredDuringSchedulingIgnoredDuringExecution:
# - weight: 100
# podAffinityTerm:
# labelSelector:
# matchExpressions:
# - key: app.kubernetes.io/name
# operator: In
# values:
# - garage-ui
# topologyKey: kubernetes.io/hostname
affinity: {}