diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d546a38 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Git +.git +.gitignore + +# Documentation +README.md +CLAUDE.md +*.md + +# Environment files +.env +.env.* +*.env + +# Build artifacts +bin/ +dist/ +*.exe +*.dll +*.so +*.dylib + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +coverage.out +*.coverprofile +*.test + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Docker +docker-compose.yml +docker-compose.override.yml +Dockerfile + +# Make +Makefile diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c027446 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Server Configuration +API_PORT=8080 +ENVIRONMENT=development + +# Security - REQUIRED +# Generate a strong random API key (e.g., using: openssl rand -base64 32) +API_KEY=your-super-secret-api-key-here + +# Vaultwarden Configuration - REQUIRED +# URL of your Vaultwarden instance (no trailing slash) +VAULTWARDEN_URL=https://vault.yourdomain.com + +# Vaultwarden API access token +# Get this from: Vaultwarden > Settings > Security > API Key +# Or create via CLI: vaultwarden hash --preset +VAULTWARDEN_ACCESS_TOKEN=your-vaultwarden-access-token + +# Performance & Caching +# How long to cache secrets in memory (reduces API calls) +# Set to 0 to disable caching +CACHE_TTL=5m + +# Timeouts +READ_TIMEOUT=10s +WRITE_TIMEOUT=10s + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aee97bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Go build artifacts +*.o +*.a +*.test +*.out +go.work + +# Application binary +/vaultwarden-api +/cmd/api/api + +# Environment files - CRITICAL FOR SECURITY +.env +.env.local +.env.*.local +*.env + +# Secrets and credentials +secrets/ +credentials/ +*.pem +*.key +*.crt +*.p12 +*.pfx + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +coverage.out +*.coverprofile + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Docker +docker-compose.override.yml + +# Documentation artifacts +CLAUDE.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5240e2a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Set working directory +WORKDIR /build + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +# CGO_ENABLED=0 for static binary (no C dependencies) +# -ldflags="-s -w" strips debug info to reduce binary size +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w" \ + -o /build/vaultwarden-api \ + ./cmd/api + +# Final stage - minimal runtime image +FROM alpine:3.19 + +# Install CA certificates for HTTPS and wget for healthcheck +RUN apk --no-cache add ca-certificates wget + +# Create non-root user for security +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser + +# Set working directory +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /build/vaultwarden-api . + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Run the application +ENTRYPOINT ["/app/vaultwarden-api"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab35351 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +.PHONY: help build run clean test docker-build docker-run docker-push dev tidy + +# Variables +APP_NAME=vaultwarden-api +DOCKER_IMAGE=yourusername/$(APP_NAME) +VERSION?=latest + +help: ## Show this help message + @echo "Available commands:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +tidy: ## Tidy and download Go modules + go mod tidy + go mod download + +build: tidy ## Build the application binary + @echo "Building $(APP_NAME)..." + CGO_ENABLED=0 go build -ldflags="-s -w" -o ./bin/$(APP_NAME) ./cmd/api + @echo "Build complete: ./bin/$(APP_NAME)" + +run: ## Run the application locally (requires .env file) + @echo "Starting $(APP_NAME)..." + @if [ ! -f .env ]; then \ + echo "Error: .env file not found. Copy .env.example to .env and configure it."; \ + exit 1; \ + fi + @set -a && . ./.env && set +a && go run ./cmd/api/main.go + +dev: ## Run in development mode with auto-reload (requires air: go install github.com/cosmtrek/air@latest) + @if ! command -v air > /dev/null; then \ + echo "Installing air for hot reload..."; \ + go install github.com/cosmtrek/air@latest; \ + fi + @if [ ! -f .env ]; then \ + echo "Error: .env file not found. Copy .env.example to .env and configure it."; \ + exit 1; \ + fi + air + +test: ## Run tests + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +clean: ## Clean build artifacts + @echo "Cleaning..." + rm -rf ./bin + rm -f coverage.out coverage.html + go clean + +docker-build: ## Build Docker image + @echo "Building Docker image $(DOCKER_IMAGE):$(VERSION)..." + docker build -t $(DOCKER_IMAGE):$(VERSION) . + docker tag $(DOCKER_IMAGE):$(VERSION) $(DOCKER_IMAGE):latest + @echo "Docker image built successfully" + +docker-run: ## Run Docker container locally + @if [ ! -f .env ]; then \ + echo "Error: .env file not found. Copy .env.example to .env and configure it."; \ + exit 1; \ + fi + docker run --rm -it \ + --env-file .env \ + -p 8080:8080 \ + --name $(APP_NAME) \ + $(DOCKER_IMAGE):latest + +docker-push: docker-build ## Push Docker image to registry + @echo "Pushing Docker image $(DOCKER_IMAGE):$(VERSION)..." + docker push $(DOCKER_IMAGE):$(VERSION) + docker push $(DOCKER_IMAGE):latest + @echo "Docker image pushed successfully" + +docker-compose-up: ## Start services with docker-compose + @if [ ! -f .env ]; then \ + echo "Error: .env file not found. Copy .env.example to .env and configure it."; \ + exit 1; \ + fi + docker-compose up -d + +docker-compose-down: ## Stop services with docker-compose + docker-compose down + +docker-compose-logs: ## View docker-compose logs + docker-compose logs -f + +generate-api-key: ## Generate a random API key + @echo "Generated API key:" + @openssl rand -base64 32 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b543fbf --- /dev/null +++ b/README.md @@ -0,0 +1,392 @@ +# Vaultwarden API Proxy + +A secure, lightweight Go API that acts as a proxy between your local apps and Vaultwarden (self-hosted Bitwarden server). Use it as a replacement for `.env` files in development and production environments. + +## Features + +- **Secure**: API key authentication, HTTPS-ready, constant-time comparison, no sensitive logging +- **Lightweight**: ~15MB Docker image, minimal resource usage +- **Fast**: Built-in caching with configurable TTL +- **Simple**: Clean architecture, easy to deploy and maintain +- **Production-ready**: Rate limiting, health checks, graceful shutdown, security headers + +## Architecture + +``` +┌─────────────┐ HTTPS + API Key ┌──────────────────┐ +│ Your App │ ───────────────────────> │ Vaultwarden API │ +└─────────────┘ │ Proxy │ + └──────────────────┘ + │ + │ Vaultwarden API + ▼ + ┌──────────────────┐ + │ Vaultwarden │ + │ Server │ + └──────────────────┘ +``` + +## Project Structure + +``` +. +├── cmd/ +│ └── api/ +│ └── main.go # Application entry point +├── internal/ +│ ├── auth/ +│ │ └── middleware.go # API key authentication +│ ├── config/ +│ │ └── config.go # Configuration management +│ ├── handlers/ +│ │ └── handlers.go # HTTP handlers +│ └── vaultwarden/ +│ └── client.go # Vaultwarden API client +├── pkg/ +│ └── logger/ +│ └── logger.go # Secure logging +├── .env.example # Environment variables template +├── .gitignore # Git ignore rules +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Docker Compose configuration +├── go.mod # Go module definition +├── go.sum # Go module checksums +├── Makefile # Build automation +└── README.md # This file +``` + +## Quick Start + +### Prerequisites + +- Go 1.25+ (for local development) +- Docker & Docker Compose (for containerized deployment) +- Vaultwarden instance with API access + +### Local Development + +1. **Clone the repository** + ```bash + git clone https://github.com/thijsherman/vaultwarden-api.git + cd vaultwarden-api + ``` + +2. **Create environment file** + ```bash + cp .env.example .env + ``` + +3. **Configure your `.env` file** + ```bash + # Generate a strong API key + make generate-api-key + + # Edit .env and add: + # - API_KEY (generated above) + # - VAULTWARDEN_URL (your Vaultwarden instance URL) + # - VAULTWARDEN_ACCESS_TOKEN (from Vaultwarden settings) + ``` + +4. **Run the application** + ```bash + make run + ``` + +### Docker Deployment + +1. **Build the Docker image** + ```bash + make docker-build + ``` + +2. **Run with Docker Compose** + ```bash + make docker-compose-up + ``` + +## API Endpoints + +### Health Check +```bash +curl http://localhost:8080/health +``` +**Response:** +```json +{ + "status": "ok", + "service": "vaultwarden-api" +} +``` + +### Get Secret +```bash +curl -H "Authorization: Bearer YOUR_API_KEY" \ + http://localhost:8080/secret/DATABASE_URL +``` +**Response:** +```json +{ + "name": "DATABASE_URL", + "value": "postgresql://user:pass@localhost:5432/db" +} +``` + +### Refresh Cache +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_API_KEY" \ + http://localhost:8080/refresh +``` +**Response:** +```json +{ + "status": "ok", + "message": "cache cleared successfully" +} +``` + +## Configuration + +All configuration is done via environment variables: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `API_PORT` | No | `8080` | Port the API listens on | +| `ENVIRONMENT` | No | `development` | Environment (development/production) | +| `API_KEY` | **Yes** | - | API key for authentication | +| `VAULTWARDEN_URL` | **Yes** | - | Vaultwarden instance URL | +| `VAULTWARDEN_ACCESS_TOKEN` | **Yes** | - | Vaultwarden API access token | +| `CACHE_TTL` | No | `5m` | Cache duration (0 to disable) | +| `READ_TIMEOUT` | No | `10s` | HTTP read timeout | +| `WRITE_TIMEOUT` | No | `10s` | HTTP write timeout | + +## Getting Vaultwarden Access Token + +There are several ways to get an access token for Vaultwarden: + +1. **Via Web UI** (if available): + - Log into Vaultwarden + - Go to Settings → Security + - Generate a new API Key + +2. **Via CLI** (for self-hosted): + - Use the Vaultwarden CLI or API to generate a token + - Refer to Vaultwarden documentation for your specific version + +3. **Via Personal Access Token**: + - Some Vaultwarden versions support personal access tokens + - Check your instance's API documentation + +## Deployment Guide + +### Deploy to Portainer + +1. **Prepare your environment** + - Ensure Vaultwarden is accessible from the Portainer host + - Have your API credentials ready + +2. **Create a new Stack in Portainer** + - Navigate to Stacks → Add Stack + - Name it `vaultwarden-api` + - Paste the contents of `docker-compose.yml` + +3. **Set environment variables in Portainer** + - Click "Add environment variable" + - Add all required variables (see Configuration table) + - **IMPORTANT**: Never hardcode secrets in the stack file + +4. **Deploy the stack** + - Click "Deploy the stack" + - Monitor logs for any errors + +5. **Set up reverse proxy** + - Use Nginx Proxy Manager, Traefik, or similar + - Configure HTTPS with Let's Encrypt + - Point to `vaultwarden-api:8080` + +### Deploy Behind Nginx Proxy Manager + +1. **Add a new Proxy Host** + - Domain: `api.yourdomain.com` + - Forward to: `vaultwarden-api:8080` + - Enable "Block Common Exploits" + - Enable "Websockets Support" + +2. **Configure SSL** + - Request a new SSL certificate (Let's Encrypt) + - Force SSL + - Enable HTTP/2 + +3. **Add custom headers** (optional) + ``` + X-Real-IP $remote_addr + X-Forwarded-For $proxy_add_x_forwarded_for + X-Forwarded-Proto $scheme + ``` + +## Security Best Practices + +### For Public Repositories + +- ✅ Use `.env.example` (committed) for documentation +- ✅ Add `.env` to `.gitignore` (already done) +- ✅ Never commit actual credentials +- ✅ Use Portainer's environment variable feature +- ✅ Rotate API keys regularly +- ✅ Use strong, randomly generated API keys + +### For Private Repositories + +- ✅ Still use `.env.example` for documentation +- ✅ Still never commit `.env` files +- ✅ Use secret managers (e.g., Docker secrets, Vault) +- ✅ Implement additional authentication if needed +- ✅ Monitor access logs + +### Production Deployment + +1. **Always use HTTPS** via reverse proxy +2. **Restrict CORS** to specific domains (edit `main.go`) +3. **Set ENVIRONMENT=production** in `.env` +4. **Enable rate limiting** (already configured) +5. **Use read-only filesystem** (already in docker-compose) +6. **Run as non-root user** (already in Dockerfile) +7. **Monitor logs** for suspicious activity +8. **Implement IP whitelisting** if possible +9. **Use Docker secrets** instead of environment variables +10. **Regular security updates** (rebuild images monthly) + +## Usage Examples + +### From a Shell Script +```bash +#!/bin/bash + +API_KEY="your-api-key" +API_URL="https://api.yourdomain.com" + +# Fetch database URL +DB_URL=$(curl -s -H "Authorization: Bearer $API_KEY" \ + "$API_URL/secret/DATABASE_URL" | jq -r '.value') + +echo "Database URL: $DB_URL" +``` + +### From Python +```python +import requests + +API_KEY = "your-api-key" +API_URL = "https://api.yourdomain.com" + +def get_secret(name): + headers = {"Authorization": f"Bearer {API_KEY}"} + response = requests.get(f"{API_URL}/secret/{name}", headers=headers) + return response.json()["value"] + +# Usage +db_url = get_secret("DATABASE_URL") +print(f"Database URL: {db_url}") +``` + +### From Node.js +```javascript +const axios = require('axios'); + +const API_KEY = 'your-api-key'; +const API_URL = 'https://api.yourdomain.com'; + +async function getSecret(name) { + const response = await axios.get(`${API_URL}/secret/${name}`, { + headers: { 'Authorization': `Bearer ${API_KEY}` } + }); + return response.data.value; +} + +// Usage +(async () => { + const dbUrl = await getSecret('DATABASE_URL'); + console.log(`Database URL: ${dbUrl}`); +})(); +``` + +## Makefile Commands + +```bash +make help # Show available commands +make build # Build the binary +make run # Run locally (requires .env) +make dev # Run with hot reload +make test # Run tests +make clean # Clean build artifacts +make docker-build # Build Docker image +make docker-run # Run Docker container +make docker-push # Push to Docker Hub +make generate-api-key # Generate a random API key +``` + +## Troubleshooting + +### "Failed to load configuration: API_KEY is required" +- Make sure you have a `.env` file in the project root +- Copy `.env.example` to `.env` and fill in all required values + +### "Secret not found" +- Verify the secret name matches exactly (case-sensitive) +- Check that your Vaultwarden token has access to the vault +- Use the Vaultwarden web UI to confirm the secret exists + +### "Vaultwarden api returned status 401" +- Your `VAULTWARDEN_ACCESS_TOKEN` is invalid or expired +- Generate a new token from Vaultwarden settings + +### Docker build fails +- Ensure you have the latest Go version in Dockerfile +- Run `go mod tidy` before building +- Check for network issues when downloading dependencies + +## Future Enhancements + +- [ ] Support for multiple Vaultwarden instances +- [ ] JWT-based authentication (in addition to API keys) +- [ ] Token rotation mechanism +- [ ] Metrics and monitoring (Prometheus) +- [ ] Support for secret versioning +- [ ] Webhook notifications for secret changes +- [ ] Redis-based distributed cache +- [ ] Rate limiting per API key +- [ ] Audit logging +- [ ] GraphQL API + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +MIT License - see LICENSE file for details + +## Disclaimer + +This is a security-sensitive tool. Please: +- Review the code before deploying to production +- Keep your API keys and tokens secure +- Monitor access logs regularly +- Report security issues privately + +## Support + +For issues and questions: +- Open an issue on GitHub +- Check existing issues for solutions +- Provide detailed error messages and logs + +--- + +**Built with ❤️ using Go and Fiber** diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..fad12d5 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/thijsherman/vaultwarden-api/internal/auth" + "github.com/thijsherman/vaultwarden-api/internal/config" + "github.com/thijsherman/vaultwarden-api/internal/handlers" + "github.com/thijsherman/vaultwarden-api/internal/vaultwarden" + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + logger.Error.Fatalf("Failed to load configuration: %v", err) + } + + logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment) + + // Initialize Vaultwarden client + vaultClient := vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL) + + // Initialize handlers + h := handlers.NewHandler(vaultClient) + + // Create Fiber app with security configurations + app := fiber.New(fiber.Config{ + AppName: "Vaultwarden API v1.0", + DisableStartupMessage: false, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + // Disable server header for security + ServerHeader: "", + // Don't expose stack traces in production + ErrorHandler: customErrorHandler(cfg.IsProd()), + }) + + // Security middleware + app.Use(helmet.New()) // Security headers + app.Use(recover.New()) // Recover from panics + app.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + + // CORS configuration - adjust as needed + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", // Restrict this in production to specific domains + AllowMethods: "GET,POST", + AllowHeaders: "Authorization,Content-Type", + })) + + // Rate limiting to prevent abuse + app.Use(limiter.New(limiter.Config{ + Max: 100, // 100 requests per minute per IP + })) + + // Public routes (no authentication required) + app.Get("/health", h.HealthCheck) + + // Protected routes (authentication required) + api := app.Group("/", auth.Middleware(cfg.APIKey)) + api.Get("/secret/:name", h.GetSecret) + api.Post("/refresh", h.RefreshCache) + + // Graceful shutdown + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + logger.Info.Println("Shutting down gracefully...") + if err := app.Shutdown(); err != nil { + logger.Error.Printf("Error during shutdown: %v", err) + } + }() + + // Start server + addr := fmt.Sprintf(":%s", cfg.Port) + if err := app.Listen(addr); err != nil { + logger.Error.Fatalf("Failed to start server: %v", err) + } +} + +// customErrorHandler creates a custom error handler +func customErrorHandler(isProd bool) fiber.ErrorHandler { + return func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + + // Log the error + logger.Error.Printf("Request error (status %d): %v", code, err) + + // Don't expose internal errors in production + message := "Internal Server Error" + if !isProd { + message = err.Error() + } + + return c.Status(code).JSON(fiber.Map{ + "error": message, + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b738e4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +version: '3.8' + +services: + vaultwarden-api: + build: + context: . + dockerfile: Dockerfile + container_name: vaultwarden-api + restart: unless-stopped + ports: + - "8080:8080" + environment: + # Server configuration + - API_PORT=8080 + - ENVIRONMENT=production + + # Security - REQUIRED + # IMPORTANT: Change these values before deploying! + - API_KEY=${API_KEY} + + # Vaultwarden configuration + - VAULTWARDEN_URL=${VAULTWARDEN_URL} + - VAULTWARDEN_ACCESS_TOKEN=${VAULTWARDEN_ACCESS_TOKEN} + + # Performance + - CACHE_TTL=5m + - READ_TIMEOUT=10s + - WRITE_TIMEOUT=10s + + # Security: read-only filesystem (except for tmp) + read_only: true + tmpfs: + - /tmp + + # Security: drop all capabilities + cap_drop: + - ALL + + # Security: prevent privilege escalation + security_opt: + - no-new-privileges:true + + # Resource limits (adjust based on your needs) + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.25' + memory: 64M + + # Health check + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + + # Logging configuration + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Network configuration + networks: + - vaultwarden-network + +networks: + vaultwarden-network: + driver: bridge + +# For production deployment with Portainer Stack: +# 1. Copy this file to your Portainer environment +# 2. Set environment variables in Portainer's stack configuration +# 3. Or create a .env file (but NEVER commit it to git!) +# 4. Deploy behind a reverse proxy (Nginx Proxy Manager, Traefik, etc.) +# 5. Ensure HTTPS is enabled on the reverse proxy diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d614bf5 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/thijsherman/vaultwarden-api + +go 1.25.3 + +require github.com/gofiber/fiber/v2 v2.52.9 + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e95f6a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..f0681c4 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,54 @@ +package auth + +import ( + "crypto/subtle" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +// Middleware creates an authentication middleware for API key validation +func Middleware(apiKey string) fiber.Handler { + return func(c *fiber.Ctx) error { + // Get the Authorization header + authHeader := c.Get("Authorization") + + if authHeader == "" { + logger.Warn.Println("Missing Authorization header") + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "missing authorization header", + }) + } + + // Expected format: "Bearer " + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + logger.Warn.Println("Invalid Authorization header format") + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization header format", + }) + } + + providedKey := parts[1] + + // Use constant-time comparison to prevent timing attacks + if !secureCompare(providedKey, apiKey) { + logger.Warn.Printf("Invalid API key from IP: %s", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid api key", + }) + } + + // Authentication successful + return c.Next() + } +} + +// secureCompare performs a constant-time comparison of two strings +// This prevents timing attacks that could be used to guess the API key +func secureCompare(a, b string) bool { + // If lengths differ, still perform comparison to maintain constant time + // Use subtle.ConstantTimeCompare which returns 1 if equal, 0 otherwise + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a01f0dd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + "net/url" + "os" + "strings" + "time" +) + +// Config holds all application configuration +type Config struct { + // Server settings + Port string + Environment string + ReadTimeout time.Duration + WriteTimeout time.Duration + + // Security + APIKey string + + // Vaultwarden settings + VaultwardenURL string + VaultwardenToken string + + // Cache settings + CacheTTL time.Duration +} + +// Load reads configuration from environment variables +func Load() (*Config, error) { + cfg := &Config{ + Port: getEnv("API_PORT", "8080"), + Environment: getEnv("ENVIRONMENT", "development"), + APIKey: os.Getenv("API_KEY"), + + VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), + VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), + + ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")), + WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")), + CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")), + } + + // Validate required fields + if cfg.APIKey == "" { + return nil, fmt.Errorf("API_KEY is required") + } + if len(cfg.APIKey) < 16 { + return nil, fmt.Errorf("API_KEY must be at least 16 characters for security") + } + if cfg.VaultwardenURL == "" { + return nil, fmt.Errorf("VAULTWARDEN_URL is required") + } + if cfg.VaultwardenToken == "" { + return nil, fmt.Errorf("VAULTWARDEN_ACCESS_TOKEN is required") + } + + // Validate and normalize URL + parsedURL, err := url.Parse(cfg.VaultwardenURL) + if err != nil { + return nil, fmt.Errorf("VAULTWARDEN_URL is invalid: %w", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme") + } + // Remove trailing slash for consistency + cfg.VaultwardenURL = strings.TrimSuffix(cfg.VaultwardenURL, "/") + + return cfg, nil +} + +// IsProd returns true if running in production +func (c *Config) IsProd() bool { + return c.Environment == "production" +} + +// getEnv gets an environment variable with a fallback default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// parseDuration parses a duration string with a fallback +func parseDuration(s string) time.Duration { + d, err := time.ParseDuration(s) + if err != nil { + return 10 * time.Second + } + return d +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..8f4f73d --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/thijsherman/vaultwarden-api/internal/vaultwarden" + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +// Handler contains all HTTP handlers +type Handler struct { + vaultClient *vaultwarden.Client +} + +// NewHandler creates a new handler instance +func NewHandler(vaultClient *vaultwarden.Client) *Handler { + return &Handler{ + vaultClient: vaultClient, + } +} + +// HealthCheck handles GET /health +func (h *Handler) HealthCheck(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "service": "vaultwarden-api", + }) +} + +// GetSecret handles GET /secret/:name +func (h *Handler) GetSecret(c *fiber.Ctx) error { + secretName := c.Params("name") + + if secretName == "" { + logger.Warn.Println("Secret name not provided") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "secret name is required", + }) + } + + // Fetch secret from Vaultwarden + value, err := h.vaultClient.GetSecret(secretName) + if err != nil { + // Don't expose internal error details to client + logger.Error.Printf("Failed to fetch secret '%s': %v", secretName, err) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "secret not found", + }) + } + + // Return the secret value + return c.JSON(fiber.Map{ + "name": secretName, + "value": value, + }) +} + +// RefreshCache handles POST /refresh +func (h *Handler) RefreshCache(c *fiber.Ctx) error { + h.vaultClient.ClearCache() + + logger.Info.Println("Cache refresh requested") + return c.JSON(fiber.Map{ + "status": "ok", + "message": "cache cleared successfully", + }) +} diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go new file mode 100644 index 0000000..0c3b601 --- /dev/null +++ b/internal/vaultwarden/client.go @@ -0,0 +1,269 @@ +package vaultwarden + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +// Client handles communication with Vaultwarden API +type Client struct { + baseURL string + token string + httpClient *http.Client + cache *secretCache +} + +// secretCache provides a simple in-memory cache with TTL +type secretCache struct { + mu sync.RWMutex + items map[string]*cacheItem + ttl time.Duration + enabled bool +} + +type cacheItem struct { + value string + expiresAt time.Time +} + +// CipherResponse represents a Bitwarden/Vaultwarden cipher (item) +type CipherResponse struct { + Data []struct { + ID string `json:"id"` + Type int `json:"type"` // 1 = Login, 2 = Note, 3 = Card, 4 = Identity + Name string `json:"name"` + Login *struct { + Username string `json:"username"` + Password string `json:"password"` + URIs []struct { + URI string `json:"uri"` + } `json:"uris"` + } `json:"login,omitempty"` + Notes string `json:"notes,omitempty"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + Type int `json:"type"` // 0 = Text, 1 = Hidden, 2 = Boolean + } `json:"fields,omitempty"` + } `json:"data"` +} + +// NewClient creates a new Vaultwarden client +func NewClient(baseURL, token string, cacheTTL time.Duration) *Client { + cache := &secretCache{ + items: make(map[string]*cacheItem), + ttl: cacheTTL, + enabled: cacheTTL > 0, + } + + // Start cache cleanup goroutine if caching is enabled + if cacheTTL > 0 { + go cache.startCleanup(cacheTTL) + } + + return &Client{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + cache: cache, + } +} + +// GetSecret retrieves a secret by name from Vaultwarden +// It first checks the cache, then queries the API if needed +func (c *Client) GetSecret(name string) (string, error) { + // Validate input + if name == "" { + return "", fmt.Errorf("secret name cannot be empty") + } + + // Check cache first + if c.cache.enabled { + if value, found := c.cache.get(name); found { + logger.Info.Printf("Cache hit for secret: %s", name) + return value, nil + } + } + + // Cache miss - fetch from API + logger.Info.Printf("Fetching secret from Vaultwarden: %s", name) + value, err := c.fetchSecret(name) + if err != nil { + return "", err + } + + // Store in cache + if c.cache.enabled { + c.cache.set(name, value) + } + + return value, nil +} + +// fetchSecret queries the Vaultwarden API for a secret +func (c *Client) fetchSecret(name string) (string, error) { + // Build the API request with context for timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + url := fmt.Sprintf("%s/api/ciphers", c.baseURL) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication header + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + req.Header.Set("Content-Type", "application/json") + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + // SECURITY: Do NOT log response body - may contain sensitive data + logger.Error.Printf("Vaultwarden API error (status %d)", resp.StatusCode) + return "", fmt.Errorf("vaultwarden api returned status %d", resp.StatusCode) + } + + // Parse response + var cipherResp CipherResponse + if err := json.NewDecoder(resp.Body).Decode(&cipherResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + // Search for the secret by name + for _, cipher := range cipherResp.Data { + if cipher.Name == name { + return c.extractSecretValue(cipher) + } + } + + return "", fmt.Errorf("secret not found: %s", name) +} + +// extractSecretValue extracts the secret value from a cipher based on its type +func (c *Client) extractSecretValue(cipher struct { + ID string `json:"id"` + Type int `json:"type"` + Name string `json:"name"` + Login *struct { + Username string `json:"username"` + Password string `json:"password"` + URIs []struct { + URI string `json:"uri"` + } `json:"uris"` + } `json:"login,omitempty"` + Notes string `json:"notes,omitempty"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + Type int `json:"type"` + } `json:"fields,omitempty"` +}) (string, error) { + // Type 1 = Login item + if cipher.Type == 1 && cipher.Login != nil { + // Return password by default for login items + if cipher.Login.Password != "" { + return cipher.Login.Password, nil + } + } + + // Type 2 = Secure note + if cipher.Type == 2 && cipher.Notes != "" { + return cipher.Notes, nil + } + + // Check custom fields + for _, field := range cipher.Fields { + if field.Name == "value" || field.Name == "secret" { + return field.Value, nil + } + } + + // If no specific field, return notes if available + if cipher.Notes != "" { + return cipher.Notes, nil + } + + return "", fmt.Errorf("could not extract secret value from cipher") +} + +// ClearCache clears all cached secrets +func (c *Client) ClearCache() { + if c.cache.enabled { + c.cache.clear() + logger.Info.Println("Cache cleared") + } +} + +// Cache methods +func (sc *secretCache) get(key string) (string, bool) { + sc.mu.RLock() + defer sc.mu.RUnlock() + + item, found := sc.items[key] + if !found { + return "", false + } + + // Check if expired + if time.Now().After(item.expiresAt) { + return "", false + } + + return item.value, true +} + +func (sc *secretCache) set(key, value string) { + sc.mu.Lock() + defer sc.mu.Unlock() + + sc.items[key] = &cacheItem{ + value: value, + expiresAt: time.Now().Add(sc.ttl), + } +} + +func (sc *secretCache) clear() { + sc.mu.Lock() + defer sc.mu.Unlock() + + sc.items = make(map[string]*cacheItem) +} + +// startCleanup runs a background goroutine to periodically remove expired cache entries +// This prevents memory leaks from accumulating expired items +func (sc *secretCache) startCleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + sc.removeExpired() + } +} + +// removeExpired removes all expired items from the cache +func (sc *secretCache) removeExpired() { + sc.mu.Lock() + defer sc.mu.Unlock() + + now := time.Now() + for key, item := range sc.items { + if now.After(item.expiresAt) { + delete(sc.items, key) + } + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..731f6c7 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,49 @@ +package logger + +import ( + "log" + "os" + "strings" +) + +var ( + Info *log.Logger + Warn *log.Logger + Error *log.Logger +) + +func init() { + Info = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + Warn = log.New(os.Stdout, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile) + Error = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) +} + +// Sanitize removes sensitive data from log messages +// This prevents accidental logging of secrets, tokens, or API keys +func Sanitize(msg string) string { + // List of sensitive keywords to redact + sensitive := []string{"token", "key", "password", "secret", "apikey", "bearer"} + + lower := strings.ToLower(msg) + for _, keyword := range sensitive { + if strings.Contains(lower, keyword) { + return "[REDACTED - Contains sensitive data]" + } + } + return msg +} + +// InfoSafe logs an info message after sanitizing it +func InfoSafe(format string, v ...interface{}) { + Info.Printf(Sanitize(format), v...) +} + +// WarnSafe logs a warning message after sanitizing it +func WarnSafe(format string, v ...interface{}) { + Warn.Printf(Sanitize(format), v...) +} + +// ErrorSafe logs an error message after sanitizing it +func ErrorSafe(format string, v ...interface{}) { + Error.Printf(Sanitize(format), v...) +}