Added implementation

This commit is contained in:
Turbootzz committed 2025-10-17 13:32:58 +02:00
1 parent 6ae6201dad
commit 5f937f6213
15 files changed
+1454

No files matched your search

+49
View File
@@ -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
+26
View File
@@ -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 <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
+61
View File
@@ -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
+57
View File
@@ -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"]
+88
View File
@@ -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
+392
View File
@@ -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**
+117
View File
@@ -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,
})
}
}
+81
View File
@@ -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
+21
View File
@@ -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
)
+31
View File
@@ -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=
+54
View File
@@ -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 <API_KEY>"
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
}
+93
View File
@@ -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
}
+66
View File
@@ -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",
})
}
+269
View File
@@ -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)
}
}
}
+49
View File
@@ -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...)
}