mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61dae6c605 | |||
| b49c634f17 | |||
| 5910961825 | |||
| 7a4e187745 | |||
| ebfdad8898 | |||
| c3db42bd76 | |||
| d36f8915ac | |||
| 6c6a02bcac | |||
| 42261b325b | |||
| 09e485572a | |||
| 491e083bb4 | |||
| 7f636a451c | |||
| 4656d1bce9 | |||
| 983e6e5fa9 | |||
| 244752c343 | |||
| f6e46d4a46 | |||
| 067e00b474 | |||
| b747e4dee2 |
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -59,4 +59,5 @@ data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
backend/docs/
|
||||
backend/docs/
|
||||
config.yaml
|
||||
+12
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://nodejs.org/)
|
||||
[](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**
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+569
-739
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export interface ObjectMetadata {
|
||||
export interface AccessKey {
|
||||
accessKeyId: string;
|
||||
name: string;
|
||||
secretKey?: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: 'active' | 'inactive';
|
||||
|
||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](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.
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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: {}
|
||||
Reference in New Issue
Block a user