Added implementation
This commit is contained in:
1 parent
6ae6201dad
commit
5f937f6213
15 files changed
+1454
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
Reference in new issue
Block a user