Skip to content

Commit d7a4e75

Browse files
authored
feat: comprehensive Go development review improvements (#372)
## Summary Comprehensive review of the ldap-manager codebase using the go-development skill and related skills (security-audit, enterprise-readiness, github-project). ### Changes - **feat(retry)**: Add retry package with exponential backoff - **refactor(options)**: Return errors instead of calling Fatal() - **feat(ldap_cache)**: Add retry logic and context propagation - **feat(web)**: Add rate limiting for authentication endpoints - **feat(main)**: Add graceful shutdown with signal handling - **chore(lint)**: Add durationcheck, fatcontext, and forcetypeassert linters - **chore(test)**: Add gremlins mutation testing configuration - **test(web)**: Add health handler tests for improved coverage - **chore(make)**: Document CGO requirement and add gremlins target - **chore(make)**: Add standalone vet and vuln-check targets - **style**: Fix gofumpt formatting in tests and server - **docs**: Add SECURITY.md for vulnerability reporting ### Quality Checks - golangci-lint: 0 issues - go vet: passed - staticcheck: passed - govulncheck: no vulnerabilities - All tests: passed
2 parents 8db7c6f + f22a707 commit d7a4e75

File tree

18 files changed

+1952
-107
lines changed

18 files changed

+1952
-107
lines changed

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ linters:
115115
- asciicheck # Check for non-ASCII identifiers
116116
- bodyclose # Check HTTP response body closes
117117
- dupl # Check code duplication
118+
- durationcheck # Check for duration multiplication issues
119+
- fatcontext # Detect nested context in loops
120+
- forcetypeassert # Find forced type assertions
118121
- errname # Check error naming conventions
119122
- errorlint # Find error handling issues
120123
- exhaustive # Check switch statements

.gremlins.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Gremlins Mutation Testing Configuration
2+
# https://github.com/go-gremlins/gremlins
3+
4+
# Packages to test
5+
test-packages:
6+
- ./internal/ldap_cache/...
7+
- ./internal/options/...
8+
- ./internal/retry/...
9+
- ./internal/web/...
10+
11+
# Mutator types to enable
12+
mutators:
13+
- CONDITIONALS_BOUNDARY # Change < to <=, > to >=
14+
- CONDITIONALS_NEGATION # Negate conditions (== to !=)
15+
- INCREMENT_DECREMENT # Change ++ to --
16+
- INVERT_LOGICAL # Invert && to ||
17+
- INVERT_NEGATIVES # Remove negation operators
18+
- INVERT_LOOPCTRL # Change break to continue
19+
20+
# Files/patterns to exclude
21+
exclude:
22+
- "**/*_test.go" # Test files
23+
- "**/test/**" # Test helpers
24+
- "**/mock/**" # Mock implementations
25+
- "**/generated/**" # Generated code
26+
- "**/*_templ.go" # Generated templ files
27+
28+
# Only mutate code covered by tests
29+
coverage: true
30+
31+
# Minimum acceptable mutation score (%)
32+
threshold: 60
33+
34+
# Timeout multiplier for test runs
35+
timeout-coefficient: 5
36+
37+
# Output reports
38+
output:
39+
json: mutation-reports/gremlins-report.json
40+
html: mutation-reports/gremlins-report.html
41+
42+
# Test timeout
43+
test-timeout: 120s

Makefile

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ COVERAGE_DIR := coverage-reports
2929
COVERAGE_FILE := coverage.out
3030
HTML_COVERAGE_FILE := $(COVERAGE_DIR)/coverage.html
3131

32-
.PHONY: help setup build test lint clean dev docker docker-dev docker-test docker-lint docker-check docker-shell docker-clean
32+
.PHONY: help setup build test lint clean dev docker docker-dev docker-test docker-lint docker-check docker-shell docker-clean vet vuln-check
3333

3434
# Default target
3535
all: setup lint test build
@@ -104,11 +104,11 @@ build-release: build-assets
104104
@CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(BUILDFLAGS) -o bin/ldap-manager-windows-amd64.exe ./cmd/ldap-manager
105105
@echo "$(GREEN)✓ Release binaries built$(RESET)"
106106

107-
## Test: Run comprehensive test suite with coverage
107+
## Test: Run comprehensive test suite with coverage (requires CGO_ENABLED=1 for race detector)
108108
test:
109109
@echo "$(BLUE)Running comprehensive test suite...$(RESET)"
110110
@mkdir -p $(COVERAGE_DIR)
111-
@if go test -v -race -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./...; then \
111+
@if CGO_ENABLED=1 go test -v -race -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./...; then \
112112
echo "$(GREEN)✅ All tests passed$(RESET)"; \
113113
else \
114114
echo "$(RED)❌ Some tests failed$(RESET)"; \
@@ -142,10 +142,11 @@ test-short:
142142
@echo "$(BLUE)Running short tests...$(RESET)"
143143
@go test -short ./...
144144

145-
## Test Race: Run race detection tests
145+
## Test Race: Run race detection tests (requires CGO_ENABLED=1)
146146
test-race:
147147
@echo "$(BLUE)Running race detection tests...$(RESET)"
148-
@if go test -race -short ./...; then \
148+
@echo "$(YELLOW)Note: Race detector requires CGO_ENABLED=1$(RESET)"
149+
@if CGO_ENABLED=1 go test -race -short ./...; then \
149150
echo "$(GREEN)✅ No race conditions detected$(RESET)"; \
150151
else \
151152
echo "$(RED)❌ Race conditions detected$(RESET)"; \
@@ -192,11 +193,18 @@ test-fuzz:
192193
@go test -fuzz=FuzzQueryParams -fuzztime=15s ./internal/web || true
193194
@echo "$(GREEN)✅ Fuzz testing completed$(RESET)"
194195

195-
## Test Mutation: Run mutation testing
196+
## Test Mutation: Run mutation testing with go-mutesting
196197
test-mutation:
197-
@echo "$(BLUE)Running mutation testing...$(RESET)"
198+
@echo "$(BLUE)Running mutation testing (go-mutesting)...$(RESET)"
198199
@./scripts/mutation-test.sh
199200

201+
## Test Mutation Gremlins: Run mutation testing with gremlins (recommended)
202+
test-mutation-gremlins:
203+
@echo "$(BLUE)Running mutation testing (gremlins)...$(RESET)"
204+
@command -v gremlins >/dev/null 2>&1 || go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0
205+
@mkdir -p mutation-reports
206+
@gremlins unleash --config=.gremlins.yaml
207+
200208
## Test All: Run all test types
201209
test-all: test test-integration
202210
@echo "$(GREEN)✅ All tests completed$(RESET)"
@@ -210,10 +218,21 @@ lint-go:
210218
@echo "$(BLUE)Running golangci-lint...$(RESET)"
211219
@golangci-lint run --config .golangci.yml ./...
212220

213-
## Lint Security: Run security vulnerability checks
214-
lint-security:
215-
@echo "$(BLUE)Running security checks...$(RESET)"
221+
## Vet: Run go vet static analysis
222+
vet:
223+
@echo "$(BLUE)Running go vet...$(RESET)"
224+
@go vet ./...
225+
@echo "$(GREEN)✓ go vet passed$(RESET)"
226+
227+
## Vuln Check: Run vulnerability scanning
228+
vuln-check:
229+
@echo "$(BLUE)Running govulncheck...$(RESET)"
216230
@govulncheck ./...
231+
@echo "$(GREEN)✓ No vulnerabilities found$(RESET)"
232+
233+
## Lint Security: Run security vulnerability checks
234+
lint-security: vuln-check
235+
@echo "$(GREEN)✓ Security checks passed$(RESET)"
217236

218237
## Lint Format: Check code formatting
219238
lint-format:

SECURITY.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Security Policy
2+
3+
## Supported Versions
4+
5+
| Version | Supported |
6+
| ------- | ------------------ |
7+
| latest | :white_check_mark: |
8+
9+
## Reporting a Vulnerability
10+
11+
If you discover a security vulnerability in ldap-manager, please report it responsibly:
12+
13+
1. **Do NOT** open a public GitHub issue for security vulnerabilities
14+
2. **Use** [GitHub's private vulnerability reporting](https://github.com/netresearch/ldap-manager/security/advisories/new)
15+
3. **Include** a detailed description of the vulnerability and steps to reproduce
16+
17+
### What to Expect
18+
19+
- **Acknowledgment**: We will acknowledge receipt within 48 hours
20+
- **Assessment**: We will assess the vulnerability and determine its severity
21+
- **Fix Timeline**: Critical vulnerabilities will be addressed within 7 days
22+
- **Disclosure**: We will coordinate disclosure timing with the reporter
23+
24+
## Security Measures
25+
26+
This project implements the following security controls:
27+
28+
- **CSRF Protection**: All state-changing operations require valid CSRF tokens
29+
- **Rate Limiting**: Authentication endpoints are rate-limited to prevent brute force
30+
- **Session Security**: Sessions use secure, HTTP-only cookies with configurable expiry
31+
- **Input Validation**: All user input is validated server-side
32+
- **Dependency Scanning**: Automated vulnerability scanning via Trivy and gosec
33+
- **Code Analysis**: Static analysis with golangci-lint and CodeQL
34+
35+
## Security Configuration
36+
37+
See the [README](README.md) for security-related configuration options:
38+
39+
- `--cookie-secure`: Require HTTPS for cookies (recommended for production)
40+
- `--tls-skip-verify`: Disable TLS verification (development only)
41+
42+
Rate limiting is enabled by default (5 failed attempts per 15 minutes triggers a 15-minute block).

cmd/ldap-manager/main.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
package main
44

55
import (
6+
"context"
67
"os"
8+
"os/signal"
9+
"syscall"
10+
"time"
711

812
"github.com/rs/zerolog"
913
"github.com/rs/zerolog/log"
@@ -13,20 +17,60 @@ import (
1317
"github.com/netresearch/ldap-manager/internal/web"
1418
)
1519

20+
const shutdownTimeout = 30 * time.Second
21+
1622
func main() {
1723
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
1824

1925
log.Info().Msgf("LDAP Manager %s starting...", version.FormatVersion())
2026

21-
opts := options.Parse()
27+
opts, err := options.Parse()
28+
if err != nil {
29+
log.Fatal().Err(err).Msg("failed to parse configuration")
30+
}
2231
log.Logger = log.Logger.Level(opts.LogLevel)
2332

2433
app, err := web.NewApp(opts)
2534
if err != nil {
2635
log.Fatal().Err(err).Msg("could not initialize web app")
2736
}
2837

29-
if err := app.Listen(":3000"); err != nil {
30-
log.Fatal().Err(err).Msg("could not start web server")
38+
// Create context for graceful shutdown
39+
ctx, cancel := context.WithCancel(context.Background())
40+
defer cancel()
41+
42+
// Handle shutdown signals
43+
sigChan := make(chan os.Signal, 1)
44+
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
45+
46+
// Start server in goroutine
47+
serverErr := make(chan error, 1)
48+
go func() {
49+
if err := app.Listen(ctx, ":3000"); err != nil {
50+
serverErr <- err
51+
}
52+
}()
53+
54+
// Wait for shutdown signal or server error
55+
select {
56+
case sig := <-sigChan:
57+
log.Info().Str("signal", sig.String()).Msg("Received shutdown signal")
58+
case err := <-serverErr:
59+
log.Error().Err(err).Msg("Server error")
3160
}
61+
62+
// Initiate graceful shutdown
63+
log.Info().Msg("Initiating graceful shutdown...")
64+
cancel() // Signal all goroutines to stop
65+
66+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
67+
defer shutdownCancel()
68+
69+
if err := app.Shutdown(shutdownCtx); err != nil {
70+
log.Error().Err(err).Msg("Error during shutdown")
71+
shutdownCancel() // Required: os.Exit does not run deferred functions
72+
os.Exit(1) //nolint:gocritic // Exit is intentional after shutdown error
73+
}
74+
75+
log.Info().Msg("Graceful shutdown complete")
3276
}

internal/ldap_cache/manager.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
package ldap_cache
77

88
import (
9+
"context"
10+
"sync"
911
"time"
1012

1113
ldap "github.com/netresearch/simple-ldap-go"
1214
"github.com/rs/zerolog/log"
15+
16+
"github.com/netresearch/ldap-manager/internal/retry"
1317
)
1418

1519
// LDAPClient interface defines the LDAP operations needed by the cache manager.
@@ -27,13 +31,17 @@ type LDAPClient interface {
2731
// All operations are concurrent-safe and provide immediate access to cached data.
2832
// Includes comprehensive metrics tracking for performance monitoring and observability.
2933
// Supports cache warming for faster startup and configurable refresh strategies.
34+
// LDAP operations are automatically retried with exponential backoff for resilience.
3035
type Manager struct {
31-
stop chan struct{} // Channel for graceful shutdown signaling
36+
stop chan struct{} // Channel for graceful shutdown signaling
37+
stopOnce sync.Once // Ensures Stop() is idempotent
38+
ctx context.Context
3239

3340
client LDAPClient // LDAP client for directory operations
3441
metrics *Metrics // Performance metrics and health monitoring
3542
refreshInterval time.Duration // Configurable refresh interval (default 30s)
3643
warmupComplete bool // Tracks if initial cache warming is complete
44+
retryConfig retry.Config // Retry configuration for LDAP operations
3745

3846
Users Cache[ldap.User] // Cached user entries with O(1) indexed lookups
3947
Groups Cache[ldap.Group] // Cached group entries with O(1) indexed lookups
@@ -73,16 +81,20 @@ func New(client LDAPClient) *Manager {
7381
// NewWithConfig creates a new LDAP cache manager with configurable refresh interval.
7482
// The manager is initialized with empty caches and custom refresh timing.
7583
// Includes comprehensive metrics tracking for performance monitoring.
84+
// LDAP operations are automatically retried with exponential backoff for resilience.
7685
// Call Run() to start the background refresh goroutine.
7786
func NewWithConfig(client LDAPClient, refreshInterval time.Duration) *Manager {
7887
metrics := NewMetrics()
7988

8089
return &Manager{
81-
stop: make(chan struct{}),
90+
stop: make(chan struct{}),
91+
// ctx is initialized with Background and replaced in Run() before any operations
92+
ctx: context.Background(),
8293
client: client,
8394
metrics: metrics,
8495
refreshInterval: refreshInterval,
8596
warmupComplete: false,
97+
retryConfig: retry.LDAPConfig(),
8698
Users: NewCachedWithMetrics[ldap.User](metrics),
8799
Groups: NewCachedWithMetrics[ldap.Group](metrics),
88100
Computers: NewCachedWithMetrics[ldap.Computer](metrics),
@@ -91,8 +103,14 @@ func NewWithConfig(client LDAPClient, refreshInterval time.Duration) *Manager {
91103

92104
// Run starts the background cache refresh loop.
93105
// It performs initial cache warming, then continues refreshing at the configured interval.
94-
// This method blocks until Stop() is called. Should be run in a separate goroutine.
95-
func (m *Manager) Run() {
106+
// The context is used for graceful shutdown - when canceled, the loop terminates.
107+
// This method blocks until the context is canceled or Stop() is called.
108+
// Should be run in a separate goroutine.
109+
func (m *Manager) Run(ctx context.Context) {
110+
// Store context for use in refresh operations.
111+
// Safe: Run() is called once from Listen() before any cache operations.
112+
m.ctx = ctx
113+
96114
// Use configurable refresh interval
97115
t := time.NewTicker(m.refreshInterval)
98116
defer t.Stop()
@@ -105,6 +123,10 @@ func (m *Manager) Run() {
105123

106124
for {
107125
select {
126+
case <-ctx.Done():
127+
log.Info().Msg("LDAP cache stopping (context canceled)")
128+
129+
return
108130
case <-m.stop:
109131
log.Info().Msg("LDAP cache stopped")
110132

@@ -116,9 +138,12 @@ func (m *Manager) Run() {
116138
}
117139

118140
// Stop gracefully shuts down the background refresh loop.
119-
// It sends a signal to the Run() method to terminate the refresh cycle.
141+
// It closes the stop channel to signal Run() to terminate the refresh cycle.
142+
// Safe to call multiple times; subsequent calls are no-ops due to sync.Once.
120143
func (m *Manager) Stop() {
121-
m.stop <- struct{}{}
144+
m.stopOnce.Do(func() {
145+
close(m.stop)
146+
})
122147
}
123148

124149
// WarmupCache performs initial cache population with enhanced logging and error handling.
@@ -203,9 +228,12 @@ func (m *Manager) IsWarmedUp() bool {
203228
}
204229

205230
// RefreshUsers fetches all users from LDAP and updates the user cache.
206-
// Returns an error if the LDAP query fails, otherwise replaces the entire user cache.
231+
// Uses retry logic with exponential backoff for resilience against transient failures.
232+
// Returns an error if all retry attempts fail, otherwise replaces the entire user cache.
207233
func (m *Manager) RefreshUsers() error {
208-
users, err := m.client.FindUsers()
234+
users, err := retry.DoWithResultConfig(m.ctx, m.retryConfig, func() ([]ldap.User, error) {
235+
return m.client.FindUsers()
236+
})
209237
if err != nil {
210238
return err
211239
}
@@ -216,9 +244,12 @@ func (m *Manager) RefreshUsers() error {
216244
}
217245

218246
// RefreshGroups fetches all groups from LDAP and updates the group cache.
219-
// Returns an error if the LDAP query fails, otherwise replaces the entire group cache.
247+
// Uses retry logic with exponential backoff for resilience against transient failures.
248+
// Returns an error if all retry attempts fail, otherwise replaces the entire group cache.
220249
func (m *Manager) RefreshGroups() error {
221-
groups, err := m.client.FindGroups()
250+
groups, err := retry.DoWithResultConfig(m.ctx, m.retryConfig, func() ([]ldap.Group, error) {
251+
return m.client.FindGroups()
252+
})
222253
if err != nil {
223254
return err
224255
}
@@ -229,9 +260,12 @@ func (m *Manager) RefreshGroups() error {
229260
}
230261

231262
// RefreshComputers fetches all computers from LDAP and updates the computer cache.
232-
// Returns an error if the LDAP query fails, otherwise replaces the entire computer cache.
263+
// Uses retry logic with exponential backoff for resilience against transient failures.
264+
// Returns an error if all retry attempts fail, otherwise replaces the entire computer cache.
233265
func (m *Manager) RefreshComputers() error {
234-
computers, err := m.client.FindComputers()
266+
computers, err := retry.DoWithResultConfig(m.ctx, m.retryConfig, func() ([]ldap.Computer, error) {
267+
return m.client.FindComputers()
268+
})
235269
if err != nil {
236270
return err
237271
}

0 commit comments

Comments
 (0)