diff --git a/.env.example b/.env.example index 8766c59..208aacb 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,21 @@ ENVIRONMENT=development # IMPORTANT: Change to 'production' for production depl # Security - REQUIRED API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!! +# IP Whitelist (Optional but RECOMMENDED for production) +# Comma-separated list of allowed IPs or CIDR ranges +# Leave empty to allow all IPs (NOT recommended for production) +# Examples: +# Single IP: ALLOWED_IPS=203.0.113.50 +# Multiple IPs: ALLOWED_IPS=203.0.113.50,198.51.100.25 +# CIDR range: ALLOWED_IPS=192.168.1.0/24 +# Mix: ALLOWED_IPS=203.0.113.50,192.168.1.0/24,10.0.0.0/8 +ALLOWED_IPS= + +# Enable GitHub Actions IP ranges (automatically whitelists GitHub's IPs) +# Set to 'true' to allow GitHub Actions to access your API +# Updates every 24 hours automatically +ENABLE_GITHUB_IP_RANGES=false + # CORS Configuration # IMPORTANT: Update this for production deployments CORS_ALLOWED_ORIGINS=http://localhost:3000 diff --git a/cmd/api/main.go b/cmd/api/main.go index 091187c..38647c2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,6 +5,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" @@ -15,6 +16,7 @@ import ( "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/ipwhitelist" "github.com/thijsherman/vaultwarden-api/internal/vaultwarden" "github.com/thijsherman/vaultwarden-api/pkg/logger" ) @@ -46,12 +48,23 @@ func main() { logger.Warn.Println("Using provided session token (will expire)") vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL) } else { - logger.Error.Fatal("No authentication configured. Set VAULTWARDEN_CLIENT_ID+VAULTWARDEN_CLIENT_SECRET+VAULTWARDEN_PASSWORD") + logger.Error.Fatal("No authentication configured. Set either (VAULTWARDEN_CLIENT_ID + VAULTWARDEN_CLIENT_SECRET + VAULTWARDEN_PASSWORD) or VAULTWARDEN_ACCESS_TOKEN") } // Initialize handlers h := handlers.NewHandler(vaultClient) + // Initialize IP whitelist + ipWhitelist, err := ipwhitelist.New(cfg.AllowedIPs, cfg.EnableGitHubIPRanges) + if err != nil { + logger.Error.Fatalf("Failed to initialize IP whitelist: %v", err) + } + + // Start periodic GitHub IP range updates (every 24 hours) + if cfg.EnableGitHubIPRanges { + ipWhitelist.StartPeriodicUpdate(24 * time.Hour) + } + // Create Fiber app with security configurations app := fiber.New(fiber.Config{ AppName: "Vaultwarden API v1.0", @@ -66,6 +79,7 @@ func main() { app.Use(helmet.New()) app.Use(recover.New()) + app.Use(ipWhitelist.Middleware()) app.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) diff --git a/internal/config/config.go b/internal/config/config.go index 98b4e65..0a8e34a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,9 @@ type Config struct { WriteTimeout time.Duration // Security - APIKey string + APIKey string + AllowedIPs []string + EnableGitHubIPRanges bool VaultwardenURL string VaultwardenToken string @@ -44,6 +46,19 @@ func Load() (*Config, error) { WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")), CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")), CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:3000"), + + EnableGitHubIPRanges: getEnv("ENABLE_GITHUB_IP_RANGES", "false") == "true", + } + + // Parse allowed IPs + if allowedIPsStr := os.Getenv("ALLOWED_IPS"); allowedIPsStr != "" { + ips := strings.Split(allowedIPsStr, ",") + for _, ip := range ips { + trimmed := strings.TrimSpace(ip) + if trimmed != "" { + cfg.AllowedIPs = append(cfg.AllowedIPs, trimmed) + } + } } // Validate required fields diff --git a/internal/ipwhitelist/ipwhitelist.go b/internal/ipwhitelist/ipwhitelist.go new file mode 100644 index 0000000..811e741 --- /dev/null +++ b/internal/ipwhitelist/ipwhitelist.go @@ -0,0 +1,194 @@ +package ipwhitelist + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +// IPWhitelist manages IP-based access control +type IPWhitelist struct { + mu sync.RWMutex + allowedIPs map[string]bool + allowedCIDRs []*net.IPNet + githubIPRanges []*net.IPNet + enableGitHub bool + lastGitHubUpdate time.Time +} + +// GitHubMeta represents GitHub's API response for IP ranges +type GitHubMeta struct { + Actions []string `json:"actions"` +} + +// New creates a new IP whitelist +func New(allowedIPs []string, enableGitHub bool) (*IPWhitelist, error) { + wl := &IPWhitelist{ + allowedIPs: make(map[string]bool), + enableGitHub: enableGitHub, + } + + // Parse allowed IPs and CIDRs + for _, ipStr := range allowedIPs { + ipStr = strings.TrimSpace(ipStr) + if ipStr == "" { + continue + } + + // Check if it's a CIDR + if strings.Contains(ipStr, "/") { + _, cidr, err := net.ParseCIDR(ipStr) + if err != nil { + logger.Warn.Printf("Invalid CIDR '%s': %v", ipStr, err) + continue + } + wl.allowedCIDRs = append(wl.allowedCIDRs, cidr) + logger.Info.Printf("Added CIDR to whitelist: %s", ipStr) + } else { + // Single IP + ip := net.ParseIP(ipStr) + if ip == nil { + logger.Warn.Printf("Invalid IP '%s'", ipStr) + continue + } + wl.allowedIPs[ipStr] = true + logger.Info.Printf("Added IP to whitelist: %s", ipStr) + } + } + + // Fetch GitHub IP ranges if enabled + if enableGitHub { + if err := wl.updateGitHubIPRanges(); err != nil { + logger.Warn.Printf("Failed to fetch GitHub IP ranges: %v", err) + } + } + + return wl, nil +} + +// Middleware creates a Fiber middleware for IP whitelisting +func (wl *IPWhitelist) Middleware() fiber.Handler { + return func(c *fiber.Ctx) error { + // If no IPs configured and GitHub not enabled, allow all + wl.mu.RLock() + hasWhitelist := len(wl.allowedIPs) > 0 || len(wl.allowedCIDRs) > 0 || len(wl.githubIPRanges) > 0 + wl.mu.RUnlock() + + if !hasWhitelist { + return c.Next() + } + + clientIP := c.IP() + + if wl.IsAllowed(clientIP) { + logger.Info.Printf("IP allowed: %s", clientIP) + return c.Next() + } + + logger.Warn.Printf("IP blocked (not whitelisted): %s on %s %s", clientIP, c.Method(), c.Path()) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "access denied: IP not whitelisted", + }) + } +} + +// IsAllowed checks if an IP is whitelisted +func (wl *IPWhitelist) IsAllowed(ipStr string) bool { + wl.mu.RLock() + defer wl.mu.RUnlock() + + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + // Check single IPs + if wl.allowedIPs[ipStr] { + return true + } + + // Check CIDRs + for _, cidr := range wl.allowedCIDRs { + if cidr.Contains(ip) { + return true + } + } + + // Check GitHub IP ranges + for _, cidr := range wl.githubIPRanges { + if cidr.Contains(ip) { + return true + } + } + + return false +} + +// updateGitHubIPRanges fetches GitHub Actions IP ranges +func (wl *IPWhitelist) updateGitHubIPRanges() error { + logger.Info.Println("Fetching GitHub Actions IP ranges...") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("https://api.github.com/meta") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("github api returned status %d", resp.StatusCode) + } + + var meta GitHubMeta + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return err + } + + wl.mu.Lock() + defer wl.mu.Unlock() + + wl.githubIPRanges = nil + for _, cidrStr := range meta.Actions { + _, cidr, err := net.ParseCIDR(cidrStr) + if err != nil { + logger.Warn.Printf("Invalid GitHub CIDR '%s': %v", cidrStr, err) + continue + } + wl.githubIPRanges = append(wl.githubIPRanges, cidr) + } + + wl.lastGitHubUpdate = time.Now() + logger.Info.Printf("Loaded %d GitHub Actions IP ranges", len(wl.githubIPRanges)) + + return nil +} + +// StartPeriodicUpdate starts a goroutine that updates GitHub IP ranges periodically +func (wl *IPWhitelist) StartPeriodicUpdate(interval time.Duration) { + if !wl.enableGitHub { + return + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + if err := wl.updateGitHubIPRanges(); err != nil { + logger.Error.Printf("Failed to update GitHub IP ranges: %v", err) + } + } + }() + + logger.Info.Printf("Started GitHub IP range auto-update (every %v)", interval) +} diff --git a/internal/vaultwarden/init.go b/internal/vaultwarden/init.go index baeb45e..d38b66b 100644 --- a/internal/vaultwarden/init.go +++ b/internal/vaultwarden/init.go @@ -20,26 +20,69 @@ func InitializeBitwardenCLI(serverURL, clientID, clientSecret, password string) logger.Info.Println("Initializing Bitwarden CLI...") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) cmd := exec.CommandContext(ctx, "bw", "config", "server", serverURL) if output, err := cmd.CombinedOutput(); err != nil { + cancel() return "", fmt.Errorf("config server failed: %w - %s", err, output) } + cancel() - cmd = exec.CommandContext(ctx, "bw", "login", "--apikey") - cmd.Env = append(os.Environ(), - fmt.Sprintf("BW_CLIENTID=%s", clientID), - fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret), - ) - output, err := cmd.CombinedOutput() + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + cmd = exec.CommandContext(ctx, "bw", "status") + statusOutput, _ := cmd.CombinedOutput() + cancel() - outputStr := strings.TrimSpace(string(output)) - if err != nil && !strings.Contains(outputStr, "You are logged in!") { - return "", fmt.Errorf("login failed: %w - %s", err, outputStr) + statusStr := strings.TrimSpace(string(statusOutput)) + isLoggedIn := strings.Contains(statusStr, `"status":"unlocked"`) || strings.Contains(statusStr, `"status":"locked"`) + + if !isLoggedIn { + logger.Info.Println("Logging in to Bitwarden...") + maxRetries := 3 + var loginErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + backoff := time.Duration(attempt*attempt) * 5 * time.Second + logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff) + time.Sleep(backoff) + } + + ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second) + cmd = exec.CommandContext(ctx, "bw", "login", "--apikey") + cmd.Env = append(os.Environ(), + fmt.Sprintf("BW_CLIENTID=%s", clientID), + fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret), + ) + output, err := cmd.CombinedOutput() + cancel() + + outputStr := strings.TrimSpace(string(output)) + if err == nil || strings.Contains(outputStr, "You are logged in!") { + logger.Info.Println("Login successful") + loginErr = nil + break + } + + if strings.Contains(outputStr, "Rate limit") { + logger.Warn.Printf("Rate limited (attempt %d/%d)", attempt, maxRetries) + loginErr = fmt.Errorf("rate limited: %s", outputStr) + continue + } + + loginErr = fmt.Errorf("login failed: %w - %s", err, outputStr) + break + } + + if loginErr != nil { + return "", loginErr + } + } else { + logger.Info.Println("Already logged in, skipping login") } + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() cmd = exec.CommandContext(ctx, "bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw") cmd.Env = append(os.Environ(), fmt.Sprintf("BW_PASSWORD=%s", password)) sessionToken, err := cmd.CombinedOutput()