diff --git a/.golangci.yml b/.golangci.yml
index 4526cab..725d557 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -4,32 +4,53 @@ run:
timeout: 5m
modules-download-mode: readonly
+issues:
+ max-issues-per-linter: 50
+ max-same-issues: 10
+
formatters:
enable:
+ - gci
- gofmt
- goimports
settings:
+ gci:
+ sections:
+ - standard
+ - default
+ - prefix(github.com/aloks98/waygates)
+
gofmt:
simplify: true
+
goimports:
local-prefixes:
- github.com/aloks98/waygates
linters:
enable:
+ # Core linters
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- - misspell
- - unconvert
- - gocritic
+
+ # Revive - comprehensive linter
- revive
+
+ # Additional useful linters
+ - bodyclose
+ - copyloopvar
+ - durationcheck
- errorlint
+ - gocritic
+ - gosec
+ - misspell
- nilerr
- prealloc
+ - unconvert
settings:
gocritic:
@@ -39,34 +60,60 @@ linters:
disabled-checks:
- hugeParam
- unnecessaryDefer
+
revive:
+ severity: warning
+ confidence: 0.8
rules:
+ # Default revive rules
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
+ - name: empty-block
+ - name: error-naming
- name: error-return
- name: error-strings
- - name: error-naming
+ - name: errorf
- name: exported
- - name: if-return
- name: increment-decrement
- - name: var-declaration
+ - name: indent-error-flow
+ - name: package-comments
- name: range
- name: receiver-naming
+ - name: redefines-builtin-id
+ - name: superfluous-else
- name: time-naming
- name: unexported-return
- - name: indent-error-flow
- - name: errorf
+ - name: unreachable-code
+ - name: unused-parameter
+ - name: var-declaration
+ - name: var-naming
+
staticcheck:
checks:
- all
- -ST1000
- -ST1005
- -QF1003
+
misspell:
locale: US
-issues:
- max-issues-per-linter: 50
- max-same-issues: 10
+ exclusions:
+ presets:
+ - std-error-handling
+ - common-false-positives
+ rules:
+ - text: 'should have a package comment'
+ linters: [ revive ]
+ - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported'
+ linters: [ revive ]
+ - text: 'avoid meaningless package names'
+ path: 'internal/utils/'
+ linters: [ revive ]
+ - path: '_test\.go'
+ linters:
+ - bodyclose
+ - errcheck
+ - gosec
diff --git a/Dockerfile b/Dockerfile
index 7d24400..72c098b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@
# Waygates - Combined Backend + Caddy Container
# =============================================================================
# This Dockerfile creates a single container running both the Waygates backend
-# and Caddy server. The backend manages Caddy configuration via Caddyfiles.
+# and Caddy server. The backend manages Caddy configuration via JSON API.
#
# CUSTOMIZATION:
#
@@ -139,13 +139,10 @@ COPY --from=ui-builder /app/dist /app/ui
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-# Copy security snippets to /app/defaults
-# (NOT /etc/caddy which is a volume mount that gets overwritten)
-# Note: Caddyfile is generated dynamically by the backend based on CADDY_ACME_PROVIDER
-COPY conf/snippets /app/defaults/snippets
+# Note: JSON configuration (caddy.json) is generated dynamically by the backend
# Create required directories
-RUN mkdir -p /etc/caddy/sites /etc/caddy/backup /data /config
+RUN mkdir -p /etc/caddy/backup /data /config
# Expose ports
# 80 - HTTP (redirect to HTTPS)
diff --git a/Makefile b/Makefile
index cf686a3..6b2fac9 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ help:
@echo " make status - Show container status"
@echo " make clean - Remove containers, volumes, and images"
@echo " make rebuild - Clean build and restart everything"
- @echo " make validate - Validate Caddyfile syntax"
+ @echo " make validate - Validate Caddy JSON config"
@echo " make deploy - Full deployment (env-check, build, up)"
@echo ""
@echo "Backend (Go):"
@@ -102,11 +102,11 @@ clean:
# Rebuild everything from scratch
rebuild: clean build up
-# Validate Caddyfile syntax (requires running container)
+# Validate Caddy JSON config (requires running container)
validate:
- @echo "Validating Caddyfile..."
- docker compose exec waygates caddy validate --config /etc/caddy/Caddyfile
- @echo "✓ Caddyfile is valid"
+ @echo "Validating Caddy JSON config..."
+ docker compose exec waygates caddy validate --config /etc/caddy/caddy.json
+ @echo "✓ Caddy config is valid"
# Full deployment pipeline
deploy: env-check build up
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index cb780ef..b793384 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -11,6 +11,8 @@ import (
"syscall"
"time"
+ // PostgreSQL driver
+ _ "github.com/lib/pq"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -20,9 +22,6 @@ import (
"github.com/aloks98/waygates/backend/internal/database"
"github.com/aloks98/waygates/backend/internal/models"
"github.com/aloks98/waygates/backend/internal/repository"
-
- // PostgreSQL driver
- _ "github.com/lib/pq"
)
func main() {
diff --git a/backend/internal/api/handlers/acl_handler.go b/backend/internal/api/handlers/acl_handler.go
index 3239cd1..6604fb5 100644
--- a/backend/internal/api/handlers/acl_handler.go
+++ b/backend/internal/api/handlers/acl_handler.go
@@ -1271,7 +1271,7 @@ func (h *ACLHandler) ConfigureWaygatesAuth(w http.ResponseWriter, r *http.Reques
// Audit logging for Waygates auth configuration update
if h.auditService != nil {
changes := buildWaygatesAuthChanges(oldConfig, config)
- _ = h.auditService.LogACLWaygatesAuthUpdate(r.Context(), userID, groupID, group.Name, config, changes, getClientIP(r), r.UserAgent())
+ _ = h.auditService.LogACLWaygatesAuthUpdate(r.Context(), userID, groupID, group.Name, changes, getClientIP(r), r.UserAgent())
}
// Sync all proxies using this ACL group
@@ -1285,7 +1285,7 @@ func (h *ACLHandler) ConfigureWaygatesAuth(w http.ResponseWriter, r *http.Reques
// =============================================================================
// GetBranding handles GET /api/acl/branding
-func (h *ACLHandler) GetBranding(w http.ResponseWriter, r *http.Request) {
+func (h *ACLHandler) GetBranding(w http.ResponseWriter, _ *http.Request) {
branding, err := h.aclService.GetBranding()
if err != nil {
utils.InternalError(w, "Failed to get branding configuration")
@@ -1583,13 +1583,13 @@ func (h *ACLHandler) DeleteOAuthProviderRestriction(w http.ResponseWriter, r *ht
// =============================================================================
// buildACLGroupChanges builds a map of changes between old and new ACL group
-func buildACLGroupChanges(old, new *models.ACLGroup) map[string]interface{} {
+func buildACLGroupChanges(old, updated *models.ACLGroup) map[string]interface{} {
changes := make(map[string]interface{})
- if old.Name != new.Name {
+ if old.Name != updated.Name {
changes["name"] = map[string]interface{}{
"old": old.Name,
- "new": new.Name,
+ "new": updated.Name,
}
}
@@ -1598,8 +1598,8 @@ func buildACLGroupChanges(old, new *models.ACLGroup) map[string]interface{} {
if old.Description != nil {
oldDesc = *old.Description
}
- if new.Description != nil {
- newDesc = *new.Description
+ if updated.Description != nil {
+ newDesc = *updated.Description
}
if oldDesc != newDesc {
changes["description"] = map[string]interface{}{
@@ -1608,38 +1608,38 @@ func buildACLGroupChanges(old, new *models.ACLGroup) map[string]interface{} {
}
}
- if old.CombinationMode != new.CombinationMode {
+ if old.CombinationMode != updated.CombinationMode {
changes["combination_mode"] = map[string]interface{}{
"old": old.CombinationMode,
- "new": new.CombinationMode,
+ "new": updated.CombinationMode,
}
}
return changes
}
-// buildIPRuleChanges builds a map of changes between old and new IP rule
-func buildIPRuleChanges(old, new *models.ACLIPRule) map[string]interface{} {
+// buildIPRuleChanges builds a map of changes between old and updated IP rule
+func buildIPRuleChanges(old, updated *models.ACLIPRule) map[string]interface{} {
changes := make(map[string]interface{})
- if old.RuleType != new.RuleType {
+ if old.RuleType != updated.RuleType {
changes["rule_type"] = map[string]interface{}{
"old": old.RuleType,
- "new": new.RuleType,
+ "new": updated.RuleType,
}
}
- if old.CIDR != new.CIDR {
+ if old.CIDR != updated.CIDR {
changes["cidr"] = map[string]interface{}{
"old": old.CIDR,
- "new": new.CIDR,
+ "new": updated.CIDR,
}
}
- if old.Priority != new.Priority {
+ if old.Priority != updated.Priority {
changes["priority"] = map[string]interface{}{
"old": old.Priority,
- "new": new.Priority,
+ "new": updated.Priority,
}
}
@@ -1648,8 +1648,8 @@ func buildIPRuleChanges(old, new *models.ACLIPRule) map[string]interface{} {
if old.Description != nil {
oldDesc = *old.Description
}
- if new.Description != nil {
- newDesc = *new.Description
+ if updated.Description != nil {
+ newDesc = *updated.Description
}
if oldDesc != newDesc {
changes["description"] = map[string]interface{}{
@@ -1662,7 +1662,7 @@ func buildIPRuleChanges(old, new *models.ACLIPRule) map[string]interface{} {
}
// buildWaygatesAuthChanges builds a map of changes between old and new Waygates auth config
-func buildWaygatesAuthChanges(old, new *models.ACLWaygatesAuth) map[string]interface{} {
+func buildWaygatesAuthChanges(old, updated *models.ACLWaygatesAuth) map[string]interface{} {
changes := make(map[string]interface{})
// Handle nil old config (first-time configuration)
@@ -1670,74 +1670,74 @@ func buildWaygatesAuthChanges(old, new *models.ACLWaygatesAuth) map[string]inter
old = &models.ACLWaygatesAuth{}
}
- if old.Enabled != new.Enabled {
+ if old.Enabled != updated.Enabled {
changes["enabled"] = map[string]interface{}{
"old": old.Enabled,
- "new": new.Enabled,
+ "new": updated.Enabled,
}
}
- if old.Require2FA != new.Require2FA {
+ if old.Require2FA != updated.Require2FA {
changes["require_2fa"] = map[string]interface{}{
"old": old.Require2FA,
- "new": new.Require2FA,
+ "new": updated.Require2FA,
}
}
- if old.SessionTTL != new.SessionTTL {
+ if old.SessionTTL != updated.SessionTTL {
changes["session_ttl"] = map[string]interface{}{
"old": old.SessionTTL,
- "new": new.SessionTTL,
+ "new": updated.SessionTTL,
}
}
// Track allowed_roles changes
oldRoles := joinStrings(old.AllowedRoles)
- newRoles := joinStrings(new.AllowedRoles)
+ newRoles := joinStrings(updated.AllowedRoles)
if oldRoles != newRoles {
changes["allowed_roles"] = map[string]interface{}{
"old": old.AllowedRoles,
- "new": new.AllowedRoles,
+ "new": updated.AllowedRoles,
}
}
// Track allowed_domains changes
oldDomains := joinStrings(old.AllowedDomains)
- newDomains := joinStrings(new.AllowedDomains)
+ newDomains := joinStrings(updated.AllowedDomains)
if oldDomains != newDomains {
changes["allowed_domains"] = map[string]interface{}{
"old": old.AllowedDomains,
- "new": new.AllowedDomains,
+ "new": updated.AllowedDomains,
}
}
// Track allowed_providers changes
oldProviders := joinStrings(old.AllowedProviders)
- newProviders := joinStrings(new.AllowedProviders)
+ newProviders := joinStrings(updated.AllowedProviders)
if oldProviders != newProviders {
changes["allowed_providers"] = map[string]interface{}{
"old": old.AllowedProviders,
- "new": new.AllowedProviders,
+ "new": updated.AllowedProviders,
}
}
// Track allowed_emails changes
oldEmails := joinStrings(old.AllowedEmails)
- newEmails := joinStrings(new.AllowedEmails)
+ newEmails := joinStrings(updated.AllowedEmails)
if oldEmails != newEmails {
changes["allowed_emails"] = map[string]interface{}{
"old": old.AllowedEmails,
- "new": new.AllowedEmails,
+ "new": updated.AllowedEmails,
}
}
// Track allowed_users changes
oldUsers := joinStrings(old.AllowedUsers)
- newUsers := joinStrings(new.AllowedUsers)
+ newUsers := joinStrings(updated.AllowedUsers)
if oldUsers != newUsers {
changes["allowed_users"] = map[string]interface{}{
"old": old.AllowedUsers,
- "new": new.AllowedUsers,
+ "new": updated.AllowedUsers,
}
}
@@ -1745,7 +1745,7 @@ func buildWaygatesAuthChanges(old, new *models.ACLWaygatesAuth) map[string]inter
}
// buildBrandingChanges builds a map of changes between old and new branding
-func buildBrandingChanges(old, new *models.ACLBranding) map[string]interface{} {
+func buildBrandingChanges(old, updated *models.ACLBranding) map[string]interface{} {
changes := make(map[string]interface{})
// Handle nil old branding (first-time configuration)
@@ -1753,24 +1753,24 @@ func buildBrandingChanges(old, new *models.ACLBranding) map[string]interface{} {
old = &models.ACLBranding{}
}
- if old.Title != new.Title {
+ if old.Title != updated.Title {
changes["title"] = map[string]interface{}{
"old": old.Title,
- "new": new.Title,
+ "new": updated.Title,
}
}
- if old.PrimaryColor != new.PrimaryColor {
+ if old.PrimaryColor != updated.PrimaryColor {
changes["primary_color"] = map[string]interface{}{
"old": old.PrimaryColor,
- "new": new.PrimaryColor,
+ "new": updated.PrimaryColor,
}
}
- if old.BackgroundColor != new.BackgroundColor {
+ if old.BackgroundColor != updated.BackgroundColor {
changes["background_color"] = map[string]interface{}{
"old": old.BackgroundColor,
- "new": new.BackgroundColor,
+ "new": updated.BackgroundColor,
}
}
@@ -1779,8 +1779,8 @@ func buildBrandingChanges(old, new *models.ACLBranding) map[string]interface{} {
if old.Subtitle != nil {
oldSubtitle = *old.Subtitle
}
- if new.Subtitle != nil {
- newSubtitle = *new.Subtitle
+ if updated.Subtitle != nil {
+ newSubtitle = *updated.Subtitle
}
if oldSubtitle != newSubtitle {
changes["subtitle"] = map[string]interface{}{
@@ -1794,8 +1794,8 @@ func buildBrandingChanges(old, new *models.ACLBranding) map[string]interface{} {
if old.LogoURL != nil {
oldLogoURL = *old.LogoURL
}
- if new.LogoURL != nil {
- newLogoURL = *new.LogoURL
+ if updated.LogoURL != nil {
+ newLogoURL = *updated.LogoURL
}
if oldLogoURL != newLogoURL {
changes["logo_url"] = map[string]interface{}{
diff --git a/backend/internal/api/handlers/acl_handler_integration_test.go b/backend/internal/api/handlers/acl_handler_integration_test.go
index 5f5d3f4..0fe9910 100644
--- a/backend/internal/api/handlers/acl_handler_integration_test.go
+++ b/backend/internal/api/handlers/acl_handler_integration_test.go
@@ -400,15 +400,15 @@ type MockACLRepository struct {
GetExternalProviderByIDFunc func(id int) (*models.ACLExternalProvider, error)
}
-func (m *MockACLRepository) CreateGroup(group *models.ACLGroup) error { return nil }
-func (m *MockACLRepository) GetGroupByID(id int) (*models.ACLGroup, error) { return nil, nil }
-func (m *MockACLRepository) GetGroupByName(name string) (*models.ACLGroup, error) { return nil, nil }
-func (m *MockACLRepository) ListGroups(params repository.ACLGroupListParams) ([]models.ACLGroup, int64, error) {
+func (m *MockACLRepository) CreateGroup(_ *models.ACLGroup) error { return nil }
+func (m *MockACLRepository) GetGroupByID(_ int) (*models.ACLGroup, error) { return nil, nil }
+func (m *MockACLRepository) GetGroupByName(_ string) (*models.ACLGroup, error) { return nil, nil }
+func (m *MockACLRepository) ListGroups(_ repository.ACLGroupListParams) ([]models.ACLGroup, int64, error) {
return nil, 0, nil
}
-func (m *MockACLRepository) UpdateGroup(group *models.ACLGroup) error { return nil }
-func (m *MockACLRepository) DeleteGroup(id int) error { return nil }
-func (m *MockACLRepository) CreateIPRule(rule *models.ACLIPRule) error { return nil }
+func (m *MockACLRepository) UpdateGroup(_ *models.ACLGroup) error { return nil }
+func (m *MockACLRepository) DeleteGroup(_ int) error { return nil }
+func (m *MockACLRepository) CreateIPRule(_ *models.ACLIPRule) error { return nil }
func (m *MockACLRepository) GetIPRuleByID(id int) (*models.ACLIPRule, error) {
if m.GetIPRuleByIDFunc != nil {
return m.GetIPRuleByIDFunc(id)
@@ -422,9 +422,9 @@ func (m *MockACLRepository) ListIPRules(groupID int) ([]models.ACLIPRule, error)
}
return []models.ACLIPRule{}, nil
}
-func (m *MockACLRepository) UpdateIPRule(rule *models.ACLIPRule) error { return nil }
-func (m *MockACLRepository) DeleteIPRule(id int) error { return nil }
-func (m *MockACLRepository) CreateBasicAuthUser(user *models.ACLBasicAuthUser) error { return nil }
+func (m *MockACLRepository) UpdateIPRule(_ *models.ACLIPRule) error { return nil }
+func (m *MockACLRepository) DeleteIPRule(_ int) error { return nil }
+func (m *MockACLRepository) CreateBasicAuthUser(_ *models.ACLBasicAuthUser) error { return nil }
func (m *MockACLRepository) GetBasicAuthUserByID(id int) (*models.ACLBasicAuthUser, error) {
if m.GetBasicAuthUserByIDFunc != nil {
return m.GetBasicAuthUserByIDFunc(id)
@@ -432,7 +432,7 @@ func (m *MockACLRepository) GetBasicAuthUserByID(id int) (*models.ACLBasicAuthUs
// Return a default user with group ID 1 for tests that don't need specific behavior
return &models.ACLBasicAuthUser{ID: id, ACLGroupID: 1}, nil
}
-func (m *MockACLRepository) GetBasicAuthUser(groupID int, username string) (*models.ACLBasicAuthUser, error) {
+func (m *MockACLRepository) GetBasicAuthUser(_ int, _ string) (*models.ACLBasicAuthUser, error) {
return nil, nil
}
func (m *MockACLRepository) ListBasicAuthUsers(groupID int) ([]models.ACLBasicAuthUser, error) {
@@ -441,9 +441,9 @@ func (m *MockACLRepository) ListBasicAuthUsers(groupID int) ([]models.ACLBasicAu
}
return []models.ACLBasicAuthUser{}, nil
}
-func (m *MockACLRepository) UpdateBasicAuthUser(user *models.ACLBasicAuthUser) error { return nil }
-func (m *MockACLRepository) DeleteBasicAuthUser(id int) error { return nil }
-func (m *MockACLRepository) CreateExternalProvider(provider *models.ACLExternalProvider) error {
+func (m *MockACLRepository) UpdateBasicAuthUser(_ *models.ACLBasicAuthUser) error { return nil }
+func (m *MockACLRepository) DeleteBasicAuthUser(_ int) error { return nil }
+func (m *MockACLRepository) CreateExternalProvider(_ *models.ACLExternalProvider) error {
return nil
}
func (m *MockACLRepository) GetExternalProviderByID(id int) (*models.ACLExternalProvider, error) {
@@ -459,69 +459,69 @@ func (m *MockACLRepository) ListExternalProviders(groupID int) ([]models.ACLExte
}
return []models.ACLExternalProvider{}, nil
}
-func (m *MockACLRepository) UpdateExternalProvider(provider *models.ACLExternalProvider) error {
+func (m *MockACLRepository) UpdateExternalProvider(_ *models.ACLExternalProvider) error {
return nil
}
-func (m *MockACLRepository) DeleteExternalProvider(id int) error { return nil }
-func (m *MockACLRepository) GetWaygatesAuth(groupID int) (*models.ACLWaygatesAuth, error) {
+func (m *MockACLRepository) DeleteExternalProvider(_ int) error { return nil }
+func (m *MockACLRepository) GetWaygatesAuth(_ int) (*models.ACLWaygatesAuth, error) {
return nil, nil
}
-func (m *MockACLRepository) CreateWaygatesAuth(auth *models.ACLWaygatesAuth) error { return nil }
-func (m *MockACLRepository) UpdateWaygatesAuth(auth *models.ACLWaygatesAuth) error { return nil }
-func (m *MockACLRepository) DeleteWaygatesAuth(groupID int) error { return nil }
-func (m *MockACLRepository) GetOAuthProviderRestrictions(groupID int) ([]models.ACLOAuthProviderRestriction, error) {
+func (m *MockACLRepository) CreateWaygatesAuth(_ *models.ACLWaygatesAuth) error { return nil }
+func (m *MockACLRepository) UpdateWaygatesAuth(_ *models.ACLWaygatesAuth) error { return nil }
+func (m *MockACLRepository) DeleteWaygatesAuth(_ int) error { return nil }
+func (m *MockACLRepository) GetOAuthProviderRestrictions(_ int) ([]models.ACLOAuthProviderRestriction, error) {
return []models.ACLOAuthProviderRestriction{}, nil
}
-func (m *MockACLRepository) GetOAuthProviderRestriction(groupID int, provider string) (*models.ACLOAuthProviderRestriction, error) {
+func (m *MockACLRepository) GetOAuthProviderRestriction(_ int, _ string) (*models.ACLOAuthProviderRestriction, error) {
return nil, nil
}
-func (m *MockACLRepository) CreateOAuthProviderRestriction(restriction *models.ACLOAuthProviderRestriction) error {
+func (m *MockACLRepository) CreateOAuthProviderRestriction(_ *models.ACLOAuthProviderRestriction) error {
return nil
}
-func (m *MockACLRepository) UpdateOAuthProviderRestriction(restriction *models.ACLOAuthProviderRestriction) error {
+func (m *MockACLRepository) UpdateOAuthProviderRestriction(_ *models.ACLOAuthProviderRestriction) error {
return nil
}
-func (m *MockACLRepository) DeleteOAuthProviderRestriction(groupID int, provider string) error {
+func (m *MockACLRepository) DeleteOAuthProviderRestriction(_ int, _ string) error {
return nil
}
-func (m *MockACLRepository) CreateProxyACLAssignment(assignment *models.ProxyACLAssignment) error {
+func (m *MockACLRepository) CreateProxyACLAssignment(_ *models.ProxyACLAssignment) error {
return nil
}
-func (m *MockACLRepository) GetProxyACLAssignments(proxyID int) ([]models.ProxyACLAssignment, error) {
+func (m *MockACLRepository) GetProxyACLAssignments(_ int) ([]models.ProxyACLAssignment, error) {
return nil, nil
}
-func (m *MockACLRepository) GetProxyACLAssignmentsByGroup(groupID int) ([]models.ProxyACLAssignment, error) {
+func (m *MockACLRepository) GetProxyACLAssignmentsByGroup(_ int) ([]models.ProxyACLAssignment, error) {
return nil, nil
}
-func (m *MockACLRepository) UpdateProxyACLAssignment(assignment *models.ProxyACLAssignment) error {
+func (m *MockACLRepository) UpdateProxyACLAssignment(_ *models.ProxyACLAssignment) error {
return nil
}
-func (m *MockACLRepository) DeleteProxyACLAssignment(id int) error { return nil }
-func (m *MockACLRepository) GetProxyACLAssignmentByID(id int) (*models.ProxyACLAssignment, error) {
+func (m *MockACLRepository) DeleteProxyACLAssignment(_ int) error { return nil }
+func (m *MockACLRepository) GetProxyACLAssignmentByID(_ int) (*models.ProxyACLAssignment, error) {
return nil, nil
}
-func (m *MockACLRepository) DeleteProxyACLAssignmentByProxyAndGroup(proxyID, groupID int) error {
+func (m *MockACLRepository) DeleteProxyACLAssignmentByProxyAndGroup(_, _ int) error {
return nil
}
-func (m *MockACLRepository) GetBranding() (*models.ACLBranding, error) { return nil, nil }
-func (m *MockACLRepository) UpdateBranding(branding *models.ACLBranding) error { return nil }
-func (m *MockACLRepository) CreateSession(session *models.ACLSession) error { return nil }
-func (m *MockACLRepository) GetSessionByToken(token string) (*models.ACLSession, error) {
+func (m *MockACLRepository) GetBranding() (*models.ACLBranding, error) { return nil, nil }
+func (m *MockACLRepository) UpdateBranding(_ *models.ACLBranding) error { return nil }
+func (m *MockACLRepository) CreateSession(_ *models.ACLSession) error { return nil }
+func (m *MockACLRepository) GetSessionByToken(_ string) (*models.ACLSession, error) {
return nil, nil
}
-func (m *MockACLRepository) DeleteSession(token string) error { return nil }
+func (m *MockACLRepository) DeleteSession(_ string) error { return nil }
func (m *MockACLRepository) DeleteExpiredSessions() (int64, error) { return 0, nil }
-func (m *MockACLRepository) DeleteUserSessions(userID int) error { return nil }
-func (m *MockACLRepository) DeleteProxySessions(proxyID int) error { return nil }
+func (m *MockACLRepository) DeleteUserSessions(_ int) error { return nil }
+func (m *MockACLRepository) DeleteProxySessions(_ int) error { return nil }
// GetDB implements ACLRepositoryInterface.
func (m *MockACLRepository) GetDB() *gorm.DB { return nil }
// DeleteGroupWithTx implements ACLRepositoryInterface.
-func (m *MockACLRepository) DeleteGroupWithTx(tx *gorm.DB, id int) error { return nil }
+func (m *MockACLRepository) DeleteGroupWithTx(_ *gorm.DB, _ int) error { return nil }
// GetProxyACLAssignmentsByGroupWithTx implements ACLRepositoryInterface.
-func (m *MockACLRepository) GetProxyACLAssignmentsByGroupWithTx(tx *gorm.DB, groupID int) ([]models.ProxyACLAssignment, error) {
+func (m *MockACLRepository) GetProxyACLAssignmentsByGroupWithTx(_ *gorm.DB, _ int) ([]models.ProxyACLAssignment, error) {
return nil, nil
}
@@ -598,7 +598,7 @@ func createAuthenticatedRequest(method, url string, body interface{}) *http.Requ
func TestACLHandler_ListGroups_Success(t *testing.T) {
mockService := &MockACLService{
- ListGroupsFunc: func(params service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error) {
+ ListGroupsFunc: func(_ service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error) {
return &models.ACLGroupListResponse{
Items: []models.ACLGroup{
{ID: 1, Name: "Test Group", CombinationMode: models.ACLCombinationModeAny},
@@ -689,7 +689,7 @@ func TestACLHandler_GetGroup_Success(t *testing.T) {
func TestACLHandler_GetGroup_NotFound(t *testing.T) {
mockService := &MockACLService{
- GetGroupFunc: func(id int) (*models.ACLGroup, error) {
+ GetGroupFunc: func(_ int) (*models.ACLGroup, error) {
return nil, service.ErrACLGroupNotFound
},
}
@@ -739,7 +739,7 @@ func TestACLHandler_CreateGroup_InvalidCombinationMode(t *testing.T) {
func TestACLHandler_CreateGroup_DuplicateName(t *testing.T) {
mockService := &MockACLService{
- CreateGroupFunc: func(group *models.ACLGroup, createdBy int) error {
+ CreateGroupFunc: func(_ *models.ACLGroup, _ int) error {
return service.ErrACLGroupNameExists
},
}
@@ -757,7 +757,7 @@ func TestACLHandler_CreateGroup_DuplicateName(t *testing.T) {
func TestACLHandler_UpdateGroup_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateGroupFunc: func(id int, updates *models.ACLGroup) error {
+ UpdateGroupFunc: func(_ int, _ *models.ACLGroup) error {
return nil
},
GetGroupFunc: func(id int) (*models.ACLGroup, error) {
@@ -782,7 +782,7 @@ func TestACLHandler_UpdateGroup_Success(t *testing.T) {
func TestACLHandler_UpdateGroup_NotFound(t *testing.T) {
mockService := &MockACLService{
- UpdateGroupFunc: func(id int, updates *models.ACLGroup) error {
+ UpdateGroupFunc: func(_ int, _ *models.ACLGroup) error {
return service.ErrACLGroupNotFound
},
}
@@ -812,7 +812,7 @@ func TestACLHandler_UpdateGroup_ValidationError(t *testing.T) {
func TestACLHandler_DeleteGroup_Success(t *testing.T) {
mockService := &MockACLService{
- DeleteGroupFunc: func(id int) error {
+ DeleteGroupFunc: func(_ int) error {
return nil
},
}
@@ -828,7 +828,7 @@ func TestACLHandler_DeleteGroup_Success(t *testing.T) {
func TestACLHandler_DeleteGroup_NotFound(t *testing.T) {
mockService := &MockACLService{
- DeleteGroupWithSyncFunc: func(id int, syncFn service.SyncCallback) error {
+ DeleteGroupWithSyncFunc: func(_ int, _ service.SyncCallback) error {
return service.ErrACLGroupNotFound
},
}
@@ -872,7 +872,7 @@ func TestACLHandler_ListIPRules_Success(t *testing.T) {
func TestACLHandler_ListIPRules_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- GetGroupFunc: func(id int) (*models.ACLGroup, error) {
+ GetGroupFunc: func(_ int) (*models.ACLGroup, error) {
return nil, service.ErrACLGroupNotFound
},
}
@@ -888,7 +888,7 @@ func TestACLHandler_ListIPRules_GroupNotFound(t *testing.T) {
func TestACLHandler_AddIPRule_Success(t *testing.T) {
mockService := &MockACLService{
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
@@ -907,7 +907,7 @@ func TestACLHandler_AddIPRule_Success(t *testing.T) {
func TestACLHandler_AddIPRule_InvalidCIDR(t *testing.T) {
mockService := &MockACLService{
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, _ *models.ACLIPRule) error {
return service.ErrInvalidCIDR
},
}
@@ -925,7 +925,7 @@ func TestACLHandler_AddIPRule_InvalidCIDR(t *testing.T) {
func TestACLHandler_AddIPRule_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, _ *models.ACLIPRule) error {
return service.ErrACLGroupNotFound
},
}
@@ -979,7 +979,7 @@ func TestACLHandler_AddIPRule_InvalidRuleType(t *testing.T) {
func TestACLHandler_UpdateIPRule_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateIPRuleFunc: func(id int, rule *models.ACLIPRule) error {
+ UpdateIPRuleFunc: func(_ int, _ *models.ACLIPRule) error {
return nil
},
}
@@ -997,7 +997,7 @@ func TestACLHandler_UpdateIPRule_Success(t *testing.T) {
func TestACLHandler_UpdateIPRule_NotFound(t *testing.T) {
mockService := &MockACLService{
- UpdateIPRuleFunc: func(id int, rule *models.ACLIPRule) error {
+ UpdateIPRuleFunc: func(_ int, _ *models.ACLIPRule) error {
return service.ErrIPRuleNotFound
},
}
@@ -1015,7 +1015,7 @@ func TestACLHandler_UpdateIPRule_NotFound(t *testing.T) {
func TestACLHandler_DeleteIPRule_Success(t *testing.T) {
mockService := &MockACLService{
- DeleteIPRuleFunc: func(id int) error {
+ DeleteIPRuleFunc: func(_ int) error {
return nil
},
}
@@ -1031,7 +1031,7 @@ func TestACLHandler_DeleteIPRule_Success(t *testing.T) {
func TestACLHandler_DeleteIPRule_NotFound(t *testing.T) {
mockService := &MockACLService{
- DeleteIPRuleFunc: func(id int) error {
+ DeleteIPRuleFunc: func(_ int) error {
return service.ErrIPRuleNotFound
},
}
@@ -1075,7 +1075,7 @@ func TestACLHandler_ListBasicAuthUsers_Success(t *testing.T) {
func TestACLHandler_AddBasicAuthUser_Success(t *testing.T) {
mockService := &MockACLService{
- AddBasicAuthUserFunc: func(groupID int, username, password string) error {
+ AddBasicAuthUserFunc: func(_ int, _, _ string) error {
return nil
},
}
@@ -1093,7 +1093,7 @@ func TestACLHandler_AddBasicAuthUser_Success(t *testing.T) {
func TestACLHandler_AddBasicAuthUser_DuplicateUsername(t *testing.T) {
mockService := &MockACLService{
- AddBasicAuthUserFunc: func(groupID int, username, password string) error {
+ AddBasicAuthUserFunc: func(_ int, _, _ string) error {
return service.ErrBasicAuthUserExists
},
}
@@ -1147,7 +1147,7 @@ func TestACLHandler_AddBasicAuthUser_PasswordTooShort(t *testing.T) {
func TestACLHandler_UpdateBasicAuthUser_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateBasicAuthPasswordFunc: func(id int, password string) error {
+ UpdateBasicAuthPasswordFunc: func(_ int, _ string) error {
return nil
},
}
@@ -1165,7 +1165,7 @@ func TestACLHandler_UpdateBasicAuthUser_Success(t *testing.T) {
func TestACLHandler_UpdateBasicAuthUser_NotFound(t *testing.T) {
mockService := &MockACLService{
- UpdateBasicAuthPasswordFunc: func(id int, password string) error {
+ UpdateBasicAuthPasswordFunc: func(_ int, _ string) error {
return service.ErrBasicAuthUserNotFound
},
}
@@ -1183,7 +1183,7 @@ func TestACLHandler_UpdateBasicAuthUser_NotFound(t *testing.T) {
func TestACLHandler_DeleteBasicAuthUser_Success(t *testing.T) {
mockService := &MockACLService{
- DeleteBasicAuthUserFunc: func(id int) error {
+ DeleteBasicAuthUserFunc: func(_ int) error {
return nil
},
}
@@ -1199,7 +1199,7 @@ func TestACLHandler_DeleteBasicAuthUser_Success(t *testing.T) {
func TestACLHandler_DeleteBasicAuthUser_NotFound(t *testing.T) {
mockService := &MockACLService{
- DeleteBasicAuthUserFunc: func(id int) error {
+ DeleteBasicAuthUserFunc: func(_ int) error {
return service.ErrBasicAuthUserNotFound
},
}
@@ -1242,7 +1242,7 @@ func TestACLHandler_ListExternalProviders_Success(t *testing.T) {
func TestACLHandler_AddExternalProvider_Success(t *testing.T) {
mockService := &MockACLService{
- AddExternalProviderFunc: func(groupID int, provider *models.ACLExternalProvider) error {
+ AddExternalProviderFunc: func(_ int, provider *models.ACLExternalProvider) error {
provider.ID = 1
return nil
},
@@ -1311,7 +1311,7 @@ func TestACLHandler_AddExternalProvider_MissingVerifyURL(t *testing.T) {
func TestACLHandler_UpdateExternalProvider_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateExternalProviderFunc: func(id int, provider *models.ACLExternalProvider) error {
+ UpdateExternalProviderFunc: func(_ int, _ *models.ACLExternalProvider) error {
return nil
},
}
@@ -1333,7 +1333,7 @@ func TestACLHandler_UpdateExternalProvider_Success(t *testing.T) {
func TestACLHandler_UpdateExternalProvider_NotFound(t *testing.T) {
mockService := &MockACLService{
- UpdateExternalProviderFunc: func(id int, provider *models.ACLExternalProvider) error {
+ UpdateExternalProviderFunc: func(_ int, _ *models.ACLExternalProvider) error {
return service.ErrExternalProviderNotFound
},
}
@@ -1355,7 +1355,7 @@ func TestACLHandler_UpdateExternalProvider_NotFound(t *testing.T) {
func TestACLHandler_DeleteExternalProvider_Success(t *testing.T) {
mockService := &MockACLService{
- DeleteExternalProviderFunc: func(id int) error {
+ DeleteExternalProviderFunc: func(_ int) error {
return nil
},
}
@@ -1371,7 +1371,7 @@ func TestACLHandler_DeleteExternalProvider_Success(t *testing.T) {
func TestACLHandler_DeleteExternalProvider_NotFound(t *testing.T) {
mockService := &MockACLService{
- DeleteExternalProviderFunc: func(id int) error {
+ DeleteExternalProviderFunc: func(_ int) error {
return service.ErrExternalProviderNotFound
},
}
@@ -1412,7 +1412,7 @@ func TestACLHandler_GetWaygatesAuth_Success(t *testing.T) {
func TestACLHandler_GetWaygatesAuth_NotConfigured(t *testing.T) {
mockService := &MockACLService{
- GetWaygatesAuthFunc: func(groupID int) (*models.ACLWaygatesAuth, error) {
+ GetWaygatesAuthFunc: func(_ int) (*models.ACLWaygatesAuth, error) {
return nil, service.ErrWaygatesAuthNotFound
},
}
@@ -1429,7 +1429,7 @@ func TestACLHandler_GetWaygatesAuth_NotConfigured(t *testing.T) {
func TestACLHandler_GetWaygatesAuth_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- GetWaygatesAuthFunc: func(groupID int) (*models.ACLWaygatesAuth, error) {
+ GetWaygatesAuthFunc: func(_ int) (*models.ACLWaygatesAuth, error) {
return nil, service.ErrACLGroupNotFound
},
}
@@ -1445,7 +1445,7 @@ func TestACLHandler_GetWaygatesAuth_GroupNotFound(t *testing.T) {
func TestACLHandler_ConfigureWaygatesAuth_Success(t *testing.T) {
mockService := &MockACLService{
- ConfigureWaygatesAuthFunc: func(groupID int, config *models.ACLWaygatesAuth) error {
+ ConfigureWaygatesAuthFunc: func(_ int, _ *models.ACLWaygatesAuth) error {
return nil
},
}
@@ -1468,7 +1468,7 @@ func TestACLHandler_ConfigureWaygatesAuth_Success(t *testing.T) {
func TestACLHandler_ConfigureWaygatesAuth_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- ConfigureWaygatesAuthFunc: func(groupID int, config *models.ACLWaygatesAuth) error {
+ ConfigureWaygatesAuthFunc: func(_ int, _ *models.ACLWaygatesAuth) error {
return service.ErrACLGroupNotFound
},
}
@@ -1516,7 +1516,7 @@ func TestACLHandler_GetBranding_Success(t *testing.T) {
func TestACLHandler_UpdateBranding_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateBrandingFunc: func(branding *models.ACLBranding) error {
+ UpdateBrandingFunc: func(_ *models.ACLBranding) error {
return nil
},
}
@@ -1588,7 +1588,7 @@ func TestACLHandler_GetGroupUsage_Success(t *testing.T) {
func TestACLHandler_GetGroupUsage_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- GetGroupFunc: func(id int) (*models.ACLGroup, error) {
+ GetGroupFunc: func(_ int) (*models.ACLGroup, error) {
return nil, service.ErrACLGroupNotFound
},
}
@@ -1814,7 +1814,7 @@ func TestACLHandler_GetAuthOptions_EmptyHostname(t *testing.T) {
func TestACLHandler_GetAuthOptions_ProxyNotFound(t *testing.T) {
mockService := &MockACLService{
- GetAuthOptionsForProxyFunc: func(hostname string) (*service.AuthOptionsResponse, error) {
+ GetAuthOptionsForProxyFunc: func(_ string) (*service.AuthOptionsResponse, error) {
return nil, fmt.Errorf("proxy not found")
},
}
@@ -1909,7 +1909,7 @@ func TestACLHandler_SyncProxiesUsingGroup_MultipleProxies(t *testing.T) {
{ID: 3, ProxyID: 30, ACLGroupID: groupID},
}, nil
},
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
@@ -1952,7 +1952,7 @@ func TestACLHandler_SyncProxiesUsingGroup_SyncErrors_ContinuesWithOthers(t *test
{ID: 3, ProxyID: 30, ACLGroupID: groupID}, // Should still sync
}, nil
},
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
@@ -1984,7 +1984,7 @@ func TestACLHandler_SyncProxiesUsingGroup_NilSyncService(t *testing.T) {
{ID: 1, ProxyID: 10, ACLGroupID: groupID},
}, nil
},
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
@@ -2006,11 +2006,11 @@ func TestACLHandler_SyncProxiesUsingGroup_NilSyncService(t *testing.T) {
func TestACLHandler_SyncProxiesUsingGroup_GetGroupUsageError(t *testing.T) {
mockSync := &MockSyncService{}
mockService := &MockACLService{
- GetGroupUsageFunc: func(groupID int) ([]models.ProxyACLAssignment, error) {
+ GetGroupUsageFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
// Error getting group usage - should be handled gracefully
return nil, fmt.Errorf("database error")
},
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
@@ -2034,11 +2034,11 @@ func TestACLHandler_SyncProxiesUsingGroup_GetGroupUsageError(t *testing.T) {
func TestACLHandler_SyncProxiesUsingGroup_NoProxiesUsing(t *testing.T) {
mockSync := &MockSyncService{}
mockService := &MockACLService{
- GetGroupUsageFunc: func(groupID int) ([]models.ProxyACLAssignment, error) {
+ GetGroupUsageFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
// No proxies using this group
return []models.ProxyACLAssignment{}, nil
},
- AddIPRuleFunc: func(groupID int, rule *models.ACLIPRule) error {
+ AddIPRuleFunc: func(_ int, rule *models.ACLIPRule) error {
rule.ID = 1
return nil
},
diff --git a/backend/internal/api/handlers/acl_handler_test.go b/backend/internal/api/handlers/acl_handler_test.go
new file mode 100644
index 0000000..ed72601
--- /dev/null
+++ b/backend/internal/api/handlers/acl_handler_test.go
@@ -0,0 +1,1416 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/aloks98/waygates/backend/internal/models"
+ "github.com/aloks98/waygates/backend/internal/repository"
+ "github.com/aloks98/waygates/backend/internal/service"
+)
+
+// =============================================================================
+// Mock Implementations for ACL Handler Tests
+// =============================================================================
+
+// aclMockACLService is a mock implementation of ACLServiceInterface for ACL handler tests
+type aclMockACLService struct {
+ // Group Management
+ CreateGroupFunc func(group *models.ACLGroup, userID int) error
+ GetGroupFunc func(id int) (*models.ACLGroup, error)
+ GetGroupByNameFunc func(name string) (*models.ACLGroup, error)
+ ListGroupsFunc func(req service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error)
+ UpdateGroupFunc func(id int, group *models.ACLGroup) error
+ DeleteGroupFunc func(id int) error
+ DeleteGroupWithSyncFunc func(id int, syncCallback service.SyncCallback) error
+
+ // IP Rules
+ AddIPRuleFunc func(groupID int, rule *models.ACLIPRule) error
+ UpdateIPRuleFunc func(ruleID int, rule *models.ACLIPRule) error
+ DeleteIPRuleFunc func(ruleID int) error
+
+ // Basic Auth
+ AddBasicAuthUserFunc func(groupID int, username, password string) error
+ UpdateBasicAuthPasswordFunc func(userID int, password string) error
+ DeleteBasicAuthUserFunc func(userID int) error
+
+ // External Providers
+ AddExternalProviderFunc func(groupID int, provider *models.ACLExternalProvider) error
+ UpdateExternalProviderFunc func(providerID int, provider *models.ACLExternalProvider) error
+ DeleteExternalProviderFunc func(providerID int) error
+
+ // Waygates Auth Config
+ GetWaygatesAuthFunc func(groupID int) (*models.ACLWaygatesAuth, error)
+ ConfigureWaygatesAuthFunc func(groupID int, config *models.ACLWaygatesAuth) error
+
+ // Proxy Assignment
+ AssignToProxyFunc func(groupID, proxyID int, path string, priority int) error
+ UpdateProxyAssignmentFunc func(assignmentID int, path string, priority int, enabled bool) error
+ RemoveFromProxyFunc func(groupID, proxyID int) error
+ GetProxyACLFunc func(proxyID int) ([]models.ProxyACLAssignment, error)
+ GetGroupUsageFunc func(groupID int) ([]models.ProxyACLAssignment, error)
+
+ // Branding
+ GetBrandingFunc func() (*models.ACLBranding, error)
+ UpdateBrandingFunc func(branding *models.ACLBranding) error
+
+ // OAuth Provider Restrictions
+ GetOAuthProviderRestrictionsFunc func(groupID int) ([]models.ACLOAuthProviderRestriction, error)
+ SetOAuthProviderRestrictionFunc func(groupID int, provider string, allowedEmails, allowedDomains []string, enabled bool) error
+ DeleteOAuthProviderRestrictionFunc func(groupID int, provider string) error
+
+ // Access Verification
+ VerifyAccessFunc func(req *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error)
+
+ // Auth Options
+ GetAuthOptionsForProxyFunc func(hostname string) (*service.AuthOptionsResponse, error)
+
+ // Session Management
+ CreateSessionFunc func(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error)
+ CreateOAuthSessionFunc func(email, provider string, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error)
+ CreateSessionWithParamsFunc func(params service.CreateSessionParams) (*models.ACLSession, error)
+ ValidateSessionFunc func(token string) (*models.ACLSession, error)
+ RevokeSessionFunc func(token string) error
+ RevokeUserSessionsFunc func(userID int) error
+ CleanupExpiredSessionsFunc func() (int64, error)
+}
+
+// Implement ACLServiceInterface
+func (m *aclMockACLService) CreateGroup(group *models.ACLGroup, userID int) error {
+ if m.CreateGroupFunc != nil {
+ return m.CreateGroupFunc(group, userID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) GetGroup(id int) (*models.ACLGroup, error) {
+ if m.GetGroupFunc != nil {
+ return m.GetGroupFunc(id)
+ }
+ return &models.ACLGroup{ID: id, Name: "test-group"}, nil
+}
+
+func (m *aclMockACLService) GetGroupByName(name string) (*models.ACLGroup, error) {
+ if m.GetGroupByNameFunc != nil {
+ return m.GetGroupByNameFunc(name)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) ListGroups(req service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error) {
+ if m.ListGroupsFunc != nil {
+ return m.ListGroupsFunc(req)
+ }
+ return &models.ACLGroupListResponse{}, nil
+}
+
+func (m *aclMockACLService) UpdateGroup(id int, group *models.ACLGroup) error {
+ if m.UpdateGroupFunc != nil {
+ return m.UpdateGroupFunc(id, group)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteGroup(id int) error {
+ if m.DeleteGroupFunc != nil {
+ return m.DeleteGroupFunc(id)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteGroupWithSync(id int, syncCallback service.SyncCallback) error {
+ if m.DeleteGroupWithSyncFunc != nil {
+ return m.DeleteGroupWithSyncFunc(id, syncCallback)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) AddIPRule(groupID int, rule *models.ACLIPRule) error {
+ if m.AddIPRuleFunc != nil {
+ return m.AddIPRuleFunc(groupID, rule)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) UpdateIPRule(ruleID int, rule *models.ACLIPRule) error {
+ if m.UpdateIPRuleFunc != nil {
+ return m.UpdateIPRuleFunc(ruleID, rule)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteIPRule(ruleID int) error {
+ if m.DeleteIPRuleFunc != nil {
+ return m.DeleteIPRuleFunc(ruleID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) AddBasicAuthUser(groupID int, username, password string) error {
+ if m.AddBasicAuthUserFunc != nil {
+ return m.AddBasicAuthUserFunc(groupID, username, password)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) UpdateBasicAuthPassword(userID int, password string) error {
+ if m.UpdateBasicAuthPasswordFunc != nil {
+ return m.UpdateBasicAuthPasswordFunc(userID, password)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteBasicAuthUser(userID int) error {
+ if m.DeleteBasicAuthUserFunc != nil {
+ return m.DeleteBasicAuthUserFunc(userID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) AddExternalProvider(groupID int, provider *models.ACLExternalProvider) error {
+ if m.AddExternalProviderFunc != nil {
+ return m.AddExternalProviderFunc(groupID, provider)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) UpdateExternalProvider(providerID int, provider *models.ACLExternalProvider) error {
+ if m.UpdateExternalProviderFunc != nil {
+ return m.UpdateExternalProviderFunc(providerID, provider)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteExternalProvider(providerID int) error {
+ if m.DeleteExternalProviderFunc != nil {
+ return m.DeleteExternalProviderFunc(providerID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) GetWaygatesAuth(groupID int) (*models.ACLWaygatesAuth, error) {
+ if m.GetWaygatesAuthFunc != nil {
+ return m.GetWaygatesAuthFunc(groupID)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) ConfigureWaygatesAuth(groupID int, config *models.ACLWaygatesAuth) error {
+ if m.ConfigureWaygatesAuthFunc != nil {
+ return m.ConfigureWaygatesAuthFunc(groupID, config)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) AssignToProxy(groupID, proxyID int, path string, priority int) error {
+ if m.AssignToProxyFunc != nil {
+ return m.AssignToProxyFunc(groupID, proxyID, path, priority)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) UpdateProxyAssignment(assignmentID int, path string, priority int, enabled bool) error {
+ if m.UpdateProxyAssignmentFunc != nil {
+ return m.UpdateProxyAssignmentFunc(assignmentID, path, priority, enabled)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) RemoveFromProxy(groupID, proxyID int) error {
+ if m.RemoveFromProxyFunc != nil {
+ return m.RemoveFromProxyFunc(groupID, proxyID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) GetProxyACL(proxyID int) ([]models.ProxyACLAssignment, error) {
+ if m.GetProxyACLFunc != nil {
+ return m.GetProxyACLFunc(proxyID)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) GetGroupUsage(groupID int) ([]models.ProxyACLAssignment, error) {
+ if m.GetGroupUsageFunc != nil {
+ return m.GetGroupUsageFunc(groupID)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) GetBranding() (*models.ACLBranding, error) {
+ if m.GetBrandingFunc != nil {
+ return m.GetBrandingFunc()
+ }
+ return &models.ACLBranding{}, nil
+}
+
+func (m *aclMockACLService) UpdateBranding(branding *models.ACLBranding) error {
+ if m.UpdateBrandingFunc != nil {
+ return m.UpdateBrandingFunc(branding)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) GetOAuthProviderRestrictions(groupID int) ([]models.ACLOAuthProviderRestriction, error) {
+ if m.GetOAuthProviderRestrictionsFunc != nil {
+ return m.GetOAuthProviderRestrictionsFunc(groupID)
+ }
+ return []models.ACLOAuthProviderRestriction{}, nil
+}
+
+func (m *aclMockACLService) SetOAuthProviderRestriction(groupID int, provider string, allowedEmails, allowedDomains []string, enabled bool) error {
+ if m.SetOAuthProviderRestrictionFunc != nil {
+ return m.SetOAuthProviderRestrictionFunc(groupID, provider, allowedEmails, allowedDomains, enabled)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) DeleteOAuthProviderRestriction(groupID int, provider string) error {
+ if m.DeleteOAuthProviderRestrictionFunc != nil {
+ return m.DeleteOAuthProviderRestrictionFunc(groupID, provider)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) VerifyAccess(req *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ if m.VerifyAccessFunc != nil {
+ return m.VerifyAccessFunc(req)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) GetAuthOptionsForProxy(hostname string) (*service.AuthOptionsResponse, error) {
+ if m.GetAuthOptionsForProxyFunc != nil {
+ return m.GetAuthOptionsForProxyFunc(hostname)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) CreateSession(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+ if m.CreateSessionFunc != nil {
+ return m.CreateSessionFunc(userID, proxyID, ip, userAgent, ttl)
+ }
+ return &models.ACLSession{SessionToken: "test-token", ExpiresAt: time.Now().Add(24 * time.Hour)}, nil
+}
+
+func (m *aclMockACLService) CreateOAuthSession(email, provider string, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+ if m.CreateOAuthSessionFunc != nil {
+ return m.CreateOAuthSessionFunc(email, provider, proxyID, ip, userAgent, ttl)
+ }
+ return &models.ACLSession{SessionToken: "test-oauth-token", ExpiresAt: time.Now().Add(24 * time.Hour)}, nil
+}
+
+func (m *aclMockACLService) CreateSessionWithParams(params service.CreateSessionParams) (*models.ACLSession, error) {
+ if m.CreateSessionWithParamsFunc != nil {
+ return m.CreateSessionWithParamsFunc(params)
+ }
+ return &models.ACLSession{SessionToken: "test-token", ExpiresAt: time.Now().Add(24 * time.Hour)}, nil
+}
+
+func (m *aclMockACLService) ValidateSession(token string) (*models.ACLSession, error) {
+ if m.ValidateSessionFunc != nil {
+ return m.ValidateSessionFunc(token)
+ }
+ return nil, nil
+}
+
+func (m *aclMockACLService) RevokeSession(token string) error {
+ if m.RevokeSessionFunc != nil {
+ return m.RevokeSessionFunc(token)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) RevokeUserSessions(userID int) error {
+ if m.RevokeUserSessionsFunc != nil {
+ return m.RevokeUserSessionsFunc(userID)
+ }
+ return nil
+}
+
+func (m *aclMockACLService) CleanupExpiredSessions() (int64, error) {
+ if m.CleanupExpiredSessionsFunc != nil {
+ return m.CleanupExpiredSessionsFunc()
+ }
+ return 0, nil
+}
+
+var _ service.ACLServiceInterface = (*aclMockACLService)(nil)
+
+// aclMockAuditService is a mock implementation of AuditServiceInterface
+type aclMockAuditService struct{}
+
+func (m *aclMockAuditService) LogEvent(_ context.Context, _ models.AuditEvent) error { return nil }
+func (m *aclMockAuditService) GetConfig() (*models.AuditConfig, error) { return nil, nil }
+func (m *aclMockAuditService) SetConfig(_ *models.AuditConfig) error { return nil }
+func (m *aclMockAuditService) InvalidateConfigCache() {}
+func (m *aclMockAuditService) ListAuditLogs(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ return &models.AuditLogListResponse{}, nil
+}
+func (m *aclMockAuditService) GetAuditLogByID(_ int) (*models.AuditLog, error) { return nil, nil }
+func (m *aclMockAuditService) GetStats() (*models.AuditLogStats, error) { return nil, nil }
+func (m *aclMockAuditService) LogProxyCreate(_ context.Context, _ int, _ *models.Proxy, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogProxyUpdate(_ context.Context, _ int, _ *models.Proxy, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogProxyDelete(_ context.Context, _, _ int, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogProxyEnable(_ context.Context, _ int, _ *models.Proxy, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogProxyDisable(_ context.Context, _ int, _ *models.Proxy, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogLogin(_ context.Context, _ int, _, _, _ string) error { return nil }
+func (m *aclMockAuditService) LogLoginFailed(_ context.Context, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogLogout(_ context.Context, _ int, _, _, _ string) error { return nil }
+func (m *aclMockAuditService) LogRegister(_ context.Context, _ int, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogPasswordChange(_ context.Context, _ int, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogSettingsUpdate(_ context.Context, _ int, _, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogSyncStarted(_ context.Context) error { return nil }
+func (m *aclMockAuditService) LogSyncCompleted(_ context.Context, _ int) error { return nil }
+func (m *aclMockAuditService) LogSyncFailed(_ context.Context, _ string) error { return nil }
+func (m *aclMockAuditService) LogSystemStartup(_ context.Context) error { return nil }
+func (m *aclMockAuditService) LogCaddyReload(_ context.Context, _ bool, _ string) error { return nil }
+func (m *aclMockAuditService) LogACLGroupCreate(_ context.Context, _ int, _ *models.ACLGroup, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLGroupUpdate(_ context.Context, _ int, _ *models.ACLGroup, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLGroupDelete(_ context.Context, _, _ int, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLIPRuleAdd(_ context.Context, _, _ int, _ string, _ *models.ACLIPRule, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLIPRuleUpdate(_ context.Context, _ int, _ *models.ACLIPRule, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLIPRuleDelete(_ context.Context, _, _ int, _, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLBasicAuthAdd(_ context.Context, _, _ int, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLBasicAuthUpdate(_ context.Context, _, _ int, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLBasicAuthDelete(_ context.Context, _, _ int, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLWaygatesAuthUpdate(_ context.Context, _, _ int, _ string, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLAssignmentCreate(_ context.Context, _, _ int, _ string, _ int, _, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLAssignmentUpdate(_ context.Context, _ int, _ *models.ProxyACLAssignment, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLAssignmentDelete(_ context.Context, _, _ int, _ string, _ int, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLBrandingUpdate(_ context.Context, _ int, _ map[string]interface{}, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLSessionRevoke(_ context.Context, _, _ int, _, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLOAuthRestrictionSet(_ context.Context, _, _ int, _, _ string, _ *models.ACLOAuthProviderRestriction, _ bool, _, _ []string, _, _ string) error {
+ return nil
+}
+func (m *aclMockAuditService) LogACLOAuthRestrictionDelete(_ context.Context, _, _ int, _, _, _, _ string) error {
+ return nil
+}
+
+var _ service.AuditServiceInterface = (*aclMockAuditService)(nil)
+
+// =============================================================================
+// Test Helpers
+// =============================================================================
+
+func createTestACLHandler(t *testing.T) (*ACLHandler, *aclMockACLService) {
+ t.Helper()
+
+ mockService := &aclMockACLService{}
+ mockAudit := &aclMockAuditService{}
+ logger := zap.NewNop()
+
+ handler := NewACLHandler(mockService, nil, nil, mockAudit, logger)
+
+ return handler, mockService
+}
+
+func setACLChiURLParams(r *http.Request, params map[string]string) *http.Request {
+ ctx := chi.NewRouteContext()
+ for key, value := range params {
+ ctx.URLParams.Add(key, value)
+ }
+ return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx))
+}
+
+// =============================================================================
+// TestGetOAuthProviderRestrictions
+// =============================================================================
+
+func TestACLHandler_GetOAuthProviderRestrictions(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ groupID string
+ setupMock func(*aclMockACLService)
+ expectedStatus int
+ checkResponse func(*testing.T, *httptest.ResponseRecorder)
+ }{
+ {
+ name: "success - returns restrictions",
+ groupID: "1",
+ setupMock: func(m *aclMockACLService) {
+ m.GetOAuthProviderRestrictionsFunc = func(groupID int) ([]models.ACLOAuthProviderRestriction, error) {
+ return []models.ACLOAuthProviderRestriction{
+ {
+ ID: 1,
+ ACLGroupID: groupID,
+ Provider: "google",
+ AllowedEmails: []string{"user@example.com"},
+ AllowedDomains: []string{"example.com"},
+ Enabled: true,
+ },
+ {
+ ID: 2,
+ ACLGroupID: groupID,
+ Provider: "github",
+ AllowedEmails: []string{},
+ AllowedDomains: []string{"github.com"},
+ Enabled: false,
+ },
+ }, nil
+ }
+ },
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) {
+ var response struct {
+ Success bool `json:"success"`
+ Data []models.ACLOAuthProviderRestriction `json:"data"`
+ }
+ err := json.Unmarshal(rec.Body.Bytes(), &response)
+ require.NoError(t, err)
+ assert.True(t, response.Success)
+ assert.Len(t, response.Data, 2)
+ assert.Equal(t, "google", response.Data[0].Provider)
+ assert.Equal(t, "github", response.Data[1].Provider)
+ },
+ },
+ {
+ name: "invalid group ID",
+ groupID: "invalid",
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "group not found",
+ groupID: "999",
+ setupMock: func(m *aclMockACLService) {
+ m.GetOAuthProviderRestrictionsFunc = func(_ int) ([]models.ACLOAuthProviderRestriction, error) {
+ return nil, service.ErrACLGroupNotFound
+ }
+ },
+ expectedStatus: http.StatusNotFound,
+ },
+ {
+ name: "empty restrictions",
+ groupID: "1",
+ setupMock: func(m *aclMockACLService) {
+ m.GetOAuthProviderRestrictionsFunc = func(_ int) ([]models.ACLOAuthProviderRestriction, error) {
+ return []models.ACLOAuthProviderRestriction{}, nil
+ }
+ },
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) {
+ var response struct {
+ Success bool `json:"success"`
+ Data []models.ACLOAuthProviderRestriction `json:"data"`
+ }
+ err := json.Unmarshal(rec.Body.Bytes(), &response)
+ require.NoError(t, err)
+ assert.True(t, response.Success)
+ assert.Len(t, response.Data, 0)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, mockService := createTestACLHandler(t)
+
+ if tc.setupMock != nil {
+ tc.setupMock(mockService)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/acl/groups/"+tc.groupID+"/oauth-restrictions", nil)
+ req = setACLChiURLParams(req, map[string]string{"id": tc.groupID})
+
+ rec := httptest.NewRecorder()
+ handler.GetOAuthProviderRestrictions(rec, req)
+
+ assert.Equal(t, tc.expectedStatus, rec.Code)
+
+ if tc.checkResponse != nil {
+ tc.checkResponse(t, rec)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestSetOAuthProviderRestriction
+// =============================================================================
+
+func TestACLHandler_SetOAuthProviderRestriction(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ groupID string
+ provider string
+ requestBody interface{}
+ setupMock func(*aclMockACLService)
+ expectedStatus int
+ checkResponse func(*testing.T, *httptest.ResponseRecorder)
+ }{
+ {
+ name: "success - set restriction",
+ groupID: "1",
+ provider: "google",
+ requestBody: SetOAuthProviderRestrictionRequest{
+ AllowedEmails: []string{"user@example.com", "admin@example.com"},
+ AllowedDomains: []string{"example.com"},
+ Enabled: true,
+ },
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 1, Name: "test-group"}, nil
+ }
+ m.SetOAuthProviderRestrictionFunc = func(_ int, _ string, _, _ []string, _ bool) error {
+ return nil
+ }
+ },
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) {
+ var response struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(rec.Body.Bytes(), &response)
+ require.NoError(t, err)
+ assert.True(t, response.Success)
+ assert.Contains(t, response.Message, "successfully")
+ },
+ },
+ {
+ name: "invalid group ID",
+ groupID: "invalid",
+ provider: "google",
+ requestBody: SetOAuthProviderRestrictionRequest{},
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "invalid provider",
+ groupID: "1",
+ provider: "invalid-provider",
+ requestBody: SetOAuthProviderRestrictionRequest{
+ AllowedEmails: []string{"user@example.com"},
+ Enabled: true,
+ },
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "group not found",
+ groupID: "999",
+ provider: "google",
+ requestBody: SetOAuthProviderRestrictionRequest{
+ AllowedEmails: []string{"user@example.com"},
+ Enabled: true,
+ },
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 999, Name: "test-group"}, nil
+ }
+ m.SetOAuthProviderRestrictionFunc = func(_ int, _ string, _, _ []string, _ bool) error {
+ return service.ErrACLGroupNotFound
+ }
+ },
+ expectedStatus: http.StatusNotFound,
+ },
+ {
+ name: "invalid request body",
+ groupID: "1",
+ provider: "google",
+ requestBody: "invalid json",
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "empty emails and domains",
+ groupID: "1",
+ provider: "github",
+ requestBody: SetOAuthProviderRestrictionRequest{
+ AllowedEmails: []string{},
+ AllowedDomains: []string{},
+ Enabled: false,
+ },
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 1, Name: "test-group"}, nil
+ }
+ m.SetOAuthProviderRestrictionFunc = func(_ int, _ string, _, _ []string, _ bool) error {
+ return nil
+ }
+ },
+ expectedStatus: http.StatusOK,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, mockService := createTestACLHandler(t)
+
+ if tc.setupMock != nil {
+ tc.setupMock(mockService)
+ }
+
+ var body []byte
+ switch v := tc.requestBody.(type) {
+ case string:
+ body = []byte(v)
+ default:
+ var err error
+ body, err = json.Marshal(tc.requestBody)
+ require.NoError(t, err)
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/api/acl/groups/"+tc.groupID+"/oauth-restrictions/"+tc.provider, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req = setACLChiURLParams(req, map[string]string{"id": tc.groupID, "provider": tc.provider})
+
+ rec := httptest.NewRecorder()
+ handler.SetOAuthProviderRestriction(rec, req)
+
+ assert.Equal(t, tc.expectedStatus, rec.Code)
+
+ if tc.checkResponse != nil {
+ tc.checkResponse(t, rec)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestDeleteOAuthProviderRestriction
+// =============================================================================
+
+func TestACLHandler_DeleteOAuthProviderRestriction(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ groupID string
+ provider string
+ setupMock func(*aclMockACLService)
+ expectedStatus int
+ checkResponse func(*testing.T, *httptest.ResponseRecorder)
+ }{
+ {
+ name: "success - delete restriction",
+ groupID: "1",
+ provider: "google",
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 1, Name: "test-group"}, nil
+ }
+ m.DeleteOAuthProviderRestrictionFunc = func(_ int, _ string) error {
+ return nil
+ }
+ },
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) {
+ var response struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(rec.Body.Bytes(), &response)
+ require.NoError(t, err)
+ assert.True(t, response.Success)
+ assert.Contains(t, response.Message, "deleted")
+ },
+ },
+ {
+ name: "invalid group ID",
+ groupID: "invalid",
+ provider: "google",
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "invalid provider",
+ groupID: "1",
+ provider: "invalid-provider",
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "group not found",
+ groupID: "999",
+ provider: "google",
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 999, Name: "test-group"}, nil
+ }
+ m.DeleteOAuthProviderRestrictionFunc = func(_ int, _ string) error {
+ return service.ErrACLGroupNotFound
+ }
+ },
+ expectedStatus: http.StatusNotFound,
+ },
+ {
+ name: "restriction not found",
+ groupID: "1",
+ provider: "github",
+ setupMock: func(m *aclMockACLService) {
+ m.GetGroupFunc = func(_ int) (*models.ACLGroup, error) {
+ return &models.ACLGroup{ID: 1, Name: "test-group"}, nil
+ }
+ m.DeleteOAuthProviderRestrictionFunc = func(_ int, _ string) error {
+ return service.ErrOAuthProviderRestrictionNotFound
+ }
+ },
+ expectedStatus: http.StatusNotFound,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, mockService := createTestACLHandler(t)
+
+ if tc.setupMock != nil {
+ tc.setupMock(mockService)
+ }
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/acl/groups/"+tc.groupID+"/oauth-restrictions/"+tc.provider, nil)
+ req = setACLChiURLParams(req, map[string]string{"id": tc.groupID, "provider": tc.provider})
+
+ rec := httptest.NewRecorder()
+ handler.DeleteOAuthProviderRestriction(rec, req)
+
+ assert.Equal(t, tc.expectedStatus, rec.Code)
+
+ if tc.checkResponse != nil {
+ tc.checkResponse(t, rec)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestBuildACLGroupChanges
+// =============================================================================
+
+func TestBuildACLGroupChanges(t *testing.T) {
+ t.Parallel()
+
+ // Helper to create string pointer
+ strPtr := func(s string) *string { return &s }
+
+ tests := []struct {
+ name string
+ oldGroup *models.ACLGroup
+ newGroup *models.ACLGroup
+ expectedKeys []string
+ unexpectedKeys []string
+ }{
+ {
+ name: "no changes",
+ oldGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("Test description"),
+ CombinationMode: "any",
+ },
+ newGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("Test description"),
+ CombinationMode: "any",
+ },
+ expectedKeys: []string{},
+ unexpectedKeys: []string{"name", "description", "combination_mode"},
+ },
+ {
+ name: "name changed",
+ oldGroup: &models.ACLGroup{
+ Name: "old-name",
+ Description: strPtr("Test description"),
+ CombinationMode: "any",
+ },
+ newGroup: &models.ACLGroup{
+ Name: "new-name",
+ Description: strPtr("Test description"),
+ CombinationMode: "any",
+ },
+ expectedKeys: []string{"name"},
+ unexpectedKeys: []string{"description", "combination_mode"},
+ },
+ {
+ name: "description changed",
+ oldGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("Old description"),
+ CombinationMode: "any",
+ },
+ newGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("New description"),
+ CombinationMode: "any",
+ },
+ expectedKeys: []string{"description"},
+ unexpectedKeys: []string{"name", "combination_mode"},
+ },
+ {
+ name: "combination mode changed",
+ oldGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("Test description"),
+ CombinationMode: "any",
+ },
+ newGroup: &models.ACLGroup{
+ Name: "test-group",
+ Description: strPtr("Test description"),
+ CombinationMode: "all",
+ },
+ expectedKeys: []string{"combination_mode"},
+ unexpectedKeys: []string{"name", "description"},
+ },
+ {
+ name: "multiple changes",
+ oldGroup: &models.ACLGroup{
+ Name: "old-name",
+ Description: strPtr("Old description"),
+ CombinationMode: "any",
+ },
+ newGroup: &models.ACLGroup{
+ Name: "new-name",
+ Description: strPtr("New description"),
+ CombinationMode: "all",
+ },
+ expectedKeys: []string{"name", "description", "combination_mode"},
+ unexpectedKeys: []string{},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ changes := buildACLGroupChanges(tc.oldGroup, tc.newGroup)
+
+ for _, key := range tc.expectedKeys {
+ assert.Contains(t, changes, key, "Expected key %s to be present", key)
+ }
+
+ for _, key := range tc.unexpectedKeys {
+ assert.NotContains(t, changes, key, "Unexpected key %s should not be present", key)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestBuildIPRuleChanges
+// =============================================================================
+
+func TestBuildIPRuleChanges(t *testing.T) {
+ t.Parallel()
+
+ // Helper to create string pointer
+ strPtr := func(s string) *string { return &s }
+
+ tests := []struct {
+ name string
+ oldRule *models.ACLIPRule
+ newRule *models.ACLIPRule
+ expectedKeys []string
+ unexpectedKeys []string
+ }{
+ {
+ name: "no changes",
+ oldRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("Test rule"),
+ Priority: 10,
+ },
+ newRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("Test rule"),
+ Priority: 10,
+ },
+ expectedKeys: []string{},
+ unexpectedKeys: []string{"rule_type", "cidr", "description", "priority"},
+ },
+ {
+ name: "rule type changed",
+ oldRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "192.168.1.0/24",
+ },
+ newRule: &models.ACLIPRule{
+ RuleType: "deny",
+ CIDR: "192.168.1.0/24",
+ },
+ expectedKeys: []string{"rule_type"},
+ unexpectedKeys: []string{"cidr"},
+ },
+ {
+ name: "CIDR changed",
+ oldRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "192.168.1.0/24",
+ },
+ newRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "10.0.0.0/16",
+ },
+ expectedKeys: []string{"cidr"},
+ unexpectedKeys: []string{"rule_type"},
+ },
+ {
+ name: "description changed",
+ oldRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("Old description"),
+ },
+ newRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("New description"),
+ },
+ expectedKeys: []string{"description"},
+ },
+ {
+ name: "priority changed",
+ oldRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Priority: 10,
+ },
+ newRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Priority: 20,
+ },
+ expectedKeys: []string{"priority"},
+ },
+ {
+ name: "description nil to value",
+ oldRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Description: nil,
+ },
+ newRule: &models.ACLIPRule{
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("New description"),
+ },
+ expectedKeys: []string{"description"},
+ },
+ {
+ name: "multiple changes",
+ oldRule: &models.ACLIPRule{
+ RuleType: "allow",
+ CIDR: "192.168.1.0/24",
+ Description: strPtr("Old rule"),
+ Priority: 10,
+ },
+ newRule: &models.ACLIPRule{
+ RuleType: "deny",
+ CIDR: "10.0.0.0/16",
+ Description: strPtr("New rule"),
+ Priority: 20,
+ },
+ expectedKeys: []string{"rule_type", "cidr", "description", "priority"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ changes := buildIPRuleChanges(tc.oldRule, tc.newRule)
+
+ for _, key := range tc.expectedKeys {
+ assert.Contains(t, changes, key, "Expected key %s to be present", key)
+ }
+
+ for _, key := range tc.unexpectedKeys {
+ assert.NotContains(t, changes, key, "Unexpected key %s should not be present", key)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestBuildWaygatesAuthChanges
+// =============================================================================
+
+func TestBuildWaygatesAuthChanges(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ oldConfig *models.ACLWaygatesAuth
+ newConfig *models.ACLWaygatesAuth
+ expectedKeys []string
+ unexpectedKeys []string
+ }{
+ {
+ name: "nil old config - first time configuration",
+ oldConfig: nil,
+ newConfig: &models.ACLWaygatesAuth{
+ Enabled: true,
+ AllowedRoles: []string{"admin", "user"},
+ AllowedDomains: []string{"example.com"},
+ AllowedProviders: []string{"google"},
+ AllowedEmails: []string{"user@example.com"},
+ AllowedUsers: []string{"admin"},
+ },
+ expectedKeys: []string{"enabled", "allowed_roles", "allowed_domains", "allowed_providers", "allowed_emails", "allowed_users"},
+ },
+ {
+ name: "no changes",
+ oldConfig: &models.ACLWaygatesAuth{
+ Enabled: true,
+ AllowedRoles: []string{"admin"},
+ AllowedDomains: []string{"example.com"},
+ AllowedProviders: []string{"google"},
+ AllowedEmails: []string{"user@example.com"},
+ AllowedUsers: []string{"admin"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ Enabled: true,
+ AllowedRoles: []string{"admin"},
+ AllowedDomains: []string{"example.com"},
+ AllowedProviders: []string{"google"},
+ AllowedEmails: []string{"user@example.com"},
+ AllowedUsers: []string{"admin"},
+ },
+ expectedKeys: []string{},
+ unexpectedKeys: []string{"enabled", "allowed_roles", "allowed_domains"},
+ },
+ {
+ name: "enabled changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ Enabled: true,
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ Enabled: false,
+ },
+ expectedKeys: []string{"enabled"},
+ unexpectedKeys: []string{"allowed_roles"},
+ },
+ {
+ name: "allowed roles changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ AllowedRoles: []string{"admin"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ AllowedRoles: []string{"admin", "user"},
+ },
+ expectedKeys: []string{"allowed_roles"},
+ },
+ {
+ name: "allowed domains changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ AllowedDomains: []string{"old.com"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ AllowedDomains: []string{"new.com"},
+ },
+ expectedKeys: []string{"allowed_domains"},
+ },
+ {
+ name: "allowed providers changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ AllowedProviders: []string{"google"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ AllowedProviders: []string{"google", "github"},
+ },
+ expectedKeys: []string{"allowed_providers"},
+ },
+ {
+ name: "allowed emails changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ AllowedEmails: []string{"old@example.com"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ AllowedEmails: []string{"new@example.com"},
+ },
+ expectedKeys: []string{"allowed_emails"},
+ },
+ {
+ name: "allowed users changed",
+ oldConfig: &models.ACLWaygatesAuth{
+ AllowedUsers: []string{"user1"},
+ },
+ newConfig: &models.ACLWaygatesAuth{
+ AllowedUsers: []string{"user1", "user2"},
+ },
+ expectedKeys: []string{"allowed_users"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ changes := buildWaygatesAuthChanges(tc.oldConfig, tc.newConfig)
+
+ for _, key := range tc.expectedKeys {
+ assert.Contains(t, changes, key, "Expected key %s to be present", key)
+ }
+
+ for _, key := range tc.unexpectedKeys {
+ assert.NotContains(t, changes, key, "Unexpected key %s should not be present", key)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestBuildBrandingChanges
+// =============================================================================
+
+func TestBuildBrandingChanges(t *testing.T) {
+ t.Parallel()
+
+ // Helper to create string pointer
+ strPtr := func(s string) *string { return &s }
+
+ tests := []struct {
+ name string
+ oldBranding *models.ACLBranding
+ newBranding *models.ACLBranding
+ expectedKeys []string
+ unexpectedKeys []string
+ }{
+ {
+ name: "nil old branding - first time configuration",
+ oldBranding: nil,
+ newBranding: &models.ACLBranding{
+ Title: "My App",
+ Subtitle: strPtr("Welcome to my app"),
+ LogoURL: strPtr("https://example.com/logo.png"),
+ PrimaryColor: "#FF5733",
+ BackgroundColor: "#FFFFFF",
+ },
+ expectedKeys: []string{"title", "subtitle", "logo_url", "primary_color", "background_color"},
+ },
+ {
+ name: "no changes",
+ oldBranding: &models.ACLBranding{
+ Title: "My App",
+ Subtitle: strPtr("Welcome"),
+ },
+ newBranding: &models.ACLBranding{
+ Title: "My App",
+ Subtitle: strPtr("Welcome"),
+ },
+ expectedKeys: []string{},
+ unexpectedKeys: []string{"title", "subtitle"},
+ },
+ {
+ name: "title changed",
+ oldBranding: &models.ACLBranding{
+ Title: "Old Title",
+ },
+ newBranding: &models.ACLBranding{
+ Title: "New Title",
+ },
+ expectedKeys: []string{"title"},
+ },
+ {
+ name: "subtitle changed",
+ oldBranding: &models.ACLBranding{
+ Subtitle: strPtr("Old subtitle"),
+ },
+ newBranding: &models.ACLBranding{
+ Subtitle: strPtr("New subtitle"),
+ },
+ expectedKeys: []string{"subtitle"},
+ },
+ {
+ name: "logo URL changed",
+ oldBranding: &models.ACLBranding{
+ LogoURL: strPtr("https://old.com/logo.png"),
+ },
+ newBranding: &models.ACLBranding{
+ LogoURL: strPtr("https://new.com/logo.png"),
+ },
+ expectedKeys: []string{"logo_url"},
+ },
+ {
+ name: "primary color changed",
+ oldBranding: &models.ACLBranding{
+ PrimaryColor: "#000000",
+ },
+ newBranding: &models.ACLBranding{
+ PrimaryColor: "#FFFFFF",
+ },
+ expectedKeys: []string{"primary_color"},
+ },
+ {
+ name: "background color changed",
+ oldBranding: &models.ACLBranding{
+ BackgroundColor: "#000000",
+ },
+ newBranding: &models.ACLBranding{
+ BackgroundColor: "#FFFFFF",
+ },
+ expectedKeys: []string{"background_color"},
+ },
+ {
+ name: "logo URL nil to value",
+ oldBranding: &models.ACLBranding{
+ LogoURL: nil,
+ },
+ newBranding: &models.ACLBranding{
+ LogoURL: strPtr("https://new.com/logo.png"),
+ },
+ expectedKeys: []string{"logo_url"},
+ },
+ {
+ name: "multiple changes",
+ oldBranding: &models.ACLBranding{
+ Title: "Old Title",
+ PrimaryColor: "#000000",
+ Subtitle: strPtr("Old subtitle"),
+ },
+ newBranding: &models.ACLBranding{
+ Title: "New Title",
+ PrimaryColor: "#FFFFFF",
+ Subtitle: strPtr("New subtitle"),
+ },
+ expectedKeys: []string{"title", "primary_color", "subtitle"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ changes := buildBrandingChanges(tc.oldBranding, tc.newBranding)
+
+ for _, key := range tc.expectedKeys {
+ assert.Contains(t, changes, key, "Expected key %s to be present", key)
+ }
+
+ for _, key := range tc.unexpectedKeys {
+ assert.NotContains(t, changes, key, "Unexpected key %s should not be present", key)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestJoinStrings
+// =============================================================================
+
+func TestJoinStrings(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input []string
+ expected string
+ }{
+ {
+ name: "nil slice",
+ input: nil,
+ expected: "",
+ },
+ {
+ name: "empty slice",
+ input: []string{},
+ expected: "",
+ },
+ {
+ name: "single element",
+ input: []string{"admin"},
+ expected: "admin",
+ },
+ {
+ name: "multiple elements",
+ input: []string{"admin", "user", "guest"},
+ expected: "admin,user,guest",
+ },
+ {
+ name: "elements with spaces",
+ input: []string{"admin role", "user role"},
+ expected: "admin role,user role",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := joinStrings(tc.input)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// TestValidOAuthProviders
+// =============================================================================
+
+func TestValidOAuthProviders(t *testing.T) {
+ t.Parallel()
+
+ validProviders := []string{"google", "github", "microsoft", "gitlab"}
+ invalidProviders := []string{"facebook", "twitter", "linkedin", "invalid", "", "Google", "GITHUB"}
+
+ for _, provider := range validProviders {
+ t.Run("valid_"+provider, func(t *testing.T) {
+ t.Parallel()
+ assert.True(t, validOAuthProviders[provider], "Provider %s should be valid", provider)
+ })
+ }
+
+ for _, provider := range invalidProviders {
+ t.Run("invalid_"+provider, func(t *testing.T) {
+ t.Parallel()
+ assert.False(t, validOAuthProviders[provider], "Provider %s should be invalid", provider)
+ })
+ }
+}
diff --git a/backend/internal/api/handlers/acl_verify_handler_integration_test.go b/backend/internal/api/handlers/acl_verify_handler_integration_test.go
index fe17fe1..e4048ae 100644
--- a/backend/internal/api/handlers/acl_verify_handler_integration_test.go
+++ b/backend/internal/api/handlers/acl_verify_handler_integration_test.go
@@ -64,7 +64,7 @@ func createTestACLUser(password string) *models.User {
func TestACLVerifyHandler_Verify_NoACLConfigured(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return &service.ACLVerifyResponse{
Allowed: true,
Headers: map[string]string{},
@@ -85,7 +85,7 @@ func TestACLVerifyHandler_Verify_NoACLConfigured(t *testing.T) {
func TestACLVerifyHandler_Verify_IPBypassMatch(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
// Simulate IP bypass - no further auth required
return &service.ACLVerifyResponse{
Allowed: true,
@@ -109,7 +109,7 @@ func TestACLVerifyHandler_Verify_IPBypassMatch(t *testing.T) {
func TestACLVerifyHandler_Verify_IPDeny(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return &service.ACLVerifyResponse{
Allowed: false,
RequiresAuth: false,
@@ -131,7 +131,7 @@ func TestACLVerifyHandler_Verify_IPDeny(t *testing.T) {
func TestACLVerifyHandler_Verify_NoSessionAuthRequired(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return &service.ACLVerifyResponse{
Allowed: false,
RequiresAuth: true,
@@ -154,7 +154,7 @@ func TestACLVerifyHandler_Verify_NoSessionAuthRequired(t *testing.T) {
func TestACLVerifyHandler_Verify_ValidSession(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return &service.ACLVerifyResponse{
Allowed: true,
User: &models.User{
@@ -226,7 +226,7 @@ func TestACLVerifyHandler_Verify_ValidBasicAuth(t *testing.T) {
func TestACLVerifyHandler_Verify_InvalidBasicAuth(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
// Basic auth fails
return &service.ACLVerifyResponse{
Allowed: false,
@@ -251,7 +251,7 @@ func TestACLVerifyHandler_Verify_InvalidBasicAuth(t *testing.T) {
func TestACLVerifyHandler_Verify_ExpiredSession(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
// Session expired
return &service.ACLVerifyResponse{
Allowed: false,
@@ -277,7 +277,7 @@ func TestACLVerifyHandler_Verify_ExpiredSession(t *testing.T) {
func TestACLVerifyHandler_Verify_InternalError(t *testing.T) {
mockService := &MockACLService{
- VerifyAccessFunc: func(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+ VerifyAccessFunc: func(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return nil, errors.New("database connection error")
},
}
@@ -370,7 +370,7 @@ func TestACLVerifyHandler_Login_Success(t *testing.T) {
}
mockACLService := &MockACLService{
- CreateSessionFunc: func(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+ CreateSessionFunc: func(userID int, _ *int, _, _ string, _ int) (*models.ACLSession, error) {
uid := userID
return &models.ACLSession{
ID: 1,
@@ -410,7 +410,7 @@ func TestACLVerifyHandler_Login_InvalidCredentials_WrongPassword(t *testing.T) {
testUser := createTestACLUser("correctpassword")
mockUserRepo := &mocks.MockUserRepository{
- GetByUsernameOrEmailFunc: func(identifier string) (*models.User, error) {
+ GetByUsernameOrEmailFunc: func(_ string) (*models.User, error) {
return testUser, nil
},
}
@@ -430,7 +430,7 @@ func TestACLVerifyHandler_Login_InvalidCredentials_WrongPassword(t *testing.T) {
func TestACLVerifyHandler_Login_InvalidCredentials_UserNotFound(t *testing.T) {
mockUserRepo := &mocks.MockUserRepository{
- GetByUsernameOrEmailFunc: func(identifier string) (*models.User, error) {
+ GetByUsernameOrEmailFunc: func(_ string) (*models.User, error) {
return nil, errors.New("user not found")
},
}
@@ -489,13 +489,13 @@ func TestACLVerifyHandler_Login_WithRedirect(t *testing.T) {
testUser := createTestACLUser(testPassword)
mockUserRepo := &mocks.MockUserRepository{
- GetByUsernameOrEmailFunc: func(identifier string) (*models.User, error) {
+ GetByUsernameOrEmailFunc: func(_ string) (*models.User, error) {
return testUser, nil
},
}
mockACLService := &MockACLService{
- CreateSessionFunc: func(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+ CreateSessionFunc: func(userID int, _ *int, _, _ string, _ int) (*models.ACLSession, error) {
uid := userID
return &models.ACLSession{
ID: 1,
@@ -529,13 +529,13 @@ func TestACLVerifyHandler_Login_SessionCreationError(t *testing.T) {
testUser := createTestACLUser(testPassword)
mockUserRepo := &mocks.MockUserRepository{
- GetByUsernameOrEmailFunc: func(identifier string) (*models.User, error) {
+ GetByUsernameOrEmailFunc: func(_ string) (*models.User, error) {
return testUser, nil
},
}
mockACLService := &MockACLService{
- CreateSessionFunc: func(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+ CreateSessionFunc: func(_ int, _ *int, _, _ string, _ int) (*models.ACLSession, error) {
return nil, errors.New("failed to create session")
},
}
@@ -604,7 +604,7 @@ func TestACLVerifyHandler_Logout_NoSession(t *testing.T) {
func TestACLVerifyHandler_Logout_RevokeError(t *testing.T) {
mockACLService := &MockACLService{
- RevokeSessionFunc: func(token string) error {
+ RevokeSessionFunc: func(_ string) error {
return errors.New("database error")
},
}
@@ -699,7 +699,7 @@ func TestACLVerifyHandler_GetSession_NoSession(t *testing.T) {
func TestACLVerifyHandler_GetSession_ExpiredSession(t *testing.T) {
mockACLService := &MockACLService{
- ValidateSessionFunc: func(token string) (*models.ACLSession, error) {
+ ValidateSessionFunc: func(_ string) (*models.ACLSession, error) {
return nil, service.ErrSessionExpired
},
}
@@ -739,7 +739,7 @@ func TestACLVerifyHandler_GetSession_ExpiredSession(t *testing.T) {
func TestACLVerifyHandler_GetSession_InvalidSession(t *testing.T) {
mockACLService := &MockACLService{
- ValidateSessionFunc: func(token string) (*models.ACLSession, error) {
+ ValidateSessionFunc: func(_ string) (*models.ACLSession, error) {
return nil, service.ErrSessionNotFound
},
}
diff --git a/backend/internal/api/handlers/acl_verify_handler_test.go b/backend/internal/api/handlers/acl_verify_handler_test.go
new file mode 100644
index 0000000..9cf4f88
--- /dev/null
+++ b/backend/internal/api/handlers/acl_verify_handler_test.go
@@ -0,0 +1,461 @@
+package handlers
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// =============================================================================
+// Unit Tests for Helper Functions
+// =============================================================================
+
+// TestExtractBaseDomainFallback tests the extractBaseDomainFallback function
+func TestExtractBaseDomainFallback(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ host string
+ expected string
+ }{
+ {
+ name: "simple two-part domain",
+ host: "example.com",
+ expected: ".example.com",
+ },
+ {
+ name: "three-part domain",
+ host: "app.example.com",
+ expected: ".example.com",
+ },
+ {
+ name: "four-part domain",
+ host: "deep.app.example.com",
+ expected: ".example.com",
+ },
+ {
+ name: "internal domain",
+ host: "app.internal.local",
+ expected: ".internal.local",
+ },
+ {
+ name: "single part - returns empty",
+ host: "localhost",
+ expected: "",
+ },
+ {
+ name: "empty host",
+ host: "",
+ expected: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := extractBaseDomainFallback(tc.host)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// TestParseBasicAuthHeader tests the parseBasicAuth function
+// Note: Named differently from integration test to avoid conflict
+func TestParseBasicAuthHeader(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ authHeader string
+ expectedUser string
+ expectedPass string
+ expectNil bool
+ }{
+ {
+ name: "valid basic auth",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:password")),
+ expectedUser: "user",
+ expectedPass: "password",
+ expectNil: false,
+ },
+ {
+ name: "valid basic auth with colon in password",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass:with:colons")),
+ expectedUser: "user",
+ expectedPass: "pass:with:colons",
+ expectNil: false,
+ },
+ {
+ name: "valid basic auth with empty password",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:")),
+ expectedUser: "user",
+ expectedPass: "",
+ expectNil: false,
+ },
+ {
+ name: "missing Basic prefix",
+ authHeader: base64.StdEncoding.EncodeToString([]byte("user:password")),
+ expectNil: true,
+ },
+ {
+ name: "Bearer token instead of Basic",
+ authHeader: "Bearer sometoken",
+ expectNil: true,
+ },
+ {
+ name: "invalid base64",
+ authHeader: "Basic not-valid-base64!!!",
+ expectNil: true,
+ },
+ {
+ name: "no colon in decoded string",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("useronly")),
+ expectNil: true,
+ },
+ {
+ name: "empty header",
+ authHeader: "",
+ expectNil: true,
+ },
+ {
+ name: "only Basic keyword",
+ authHeader: "Basic ",
+ expectNil: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := parseBasicAuth(tc.authHeader)
+
+ if tc.expectNil {
+ assert.Nil(t, result)
+ } else {
+ assert.NotNil(t, result)
+ assert.Equal(t, tc.expectedUser, result.Username)
+ assert.Equal(t, tc.expectedPass, result.Password)
+ }
+ })
+ }
+}
+
+// TestIsIPAddressUnit tests the isIPAddress function
+func TestIsIPAddressUnit(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ host string
+ expected bool
+ }{
+ {
+ name: "IPv4 address",
+ host: "192.168.1.1",
+ expected: true,
+ },
+ {
+ name: "IPv4 localhost",
+ host: "127.0.0.1",
+ expected: true,
+ },
+ {
+ name: "IPv6 address with colons",
+ host: "::1",
+ expected: true,
+ },
+ {
+ name: "IPv6 full address",
+ host: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
+ expected: true,
+ },
+ {
+ name: "regular hostname",
+ host: "example.com",
+ expected: false,
+ },
+ {
+ name: "hostname with subdomain",
+ host: "app.example.com",
+ expected: false,
+ },
+ {
+ name: "localhost",
+ host: "localhost",
+ expected: false,
+ },
+ {
+ name: "hostname with numbers",
+ host: "server1.example.com",
+ expected: false,
+ },
+ {
+ name: "empty string",
+ host: "",
+ expected: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := isIPAddress(tc.host)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// TestExtractCookieDomainFromHostUnit tests the extractCookieDomainFromHost function
+func TestExtractCookieDomainFromHostUnit(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ host string
+ expected string
+ }{
+ {
+ name: "simple domain with port",
+ host: "example.com:8080",
+ expected: ".example.com",
+ },
+ {
+ name: "subdomain with port",
+ host: "app.example.com:443",
+ expected: ".example.com",
+ },
+ {
+ name: "domain without port",
+ host: "example.com",
+ expected: ".example.com",
+ },
+ {
+ name: "localhost with port",
+ host: "localhost:8080",
+ expected: "",
+ },
+ {
+ name: "localhost without port",
+ host: "localhost",
+ expected: "",
+ },
+ {
+ name: "IP address with port",
+ host: "192.168.1.1:8080",
+ expected: "",
+ },
+ {
+ name: "IP address without port",
+ host: "192.168.1.1",
+ expected: "",
+ },
+ {
+ name: "deep subdomain",
+ host: "deep.nested.example.com:9000",
+ expected: ".example.com",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := extractCookieDomainFromHost(tc.host)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// TestExtractCookieDomainUnit tests the extractCookieDomain function
+func TestExtractCookieDomainUnit(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ rawURL string
+ expected string
+ }{
+ {
+ name: "simple HTTPS URL",
+ rawURL: "https://example.com/path",
+ expected: ".example.com",
+ },
+ {
+ name: "URL with subdomain",
+ rawURL: "https://app.example.com/dashboard",
+ expected: ".example.com",
+ },
+ {
+ name: "URL with deep subdomain",
+ rawURL: "https://deep.nested.example.com/path",
+ expected: ".example.com",
+ },
+ {
+ name: "HTTP localhost",
+ rawURL: "http://localhost:8080/path",
+ expected: "",
+ },
+ {
+ name: "IP address URL",
+ rawURL: "http://192.168.1.1:8080/api",
+ expected: "",
+ },
+ {
+ name: "empty URL",
+ rawURL: "",
+ expected: "",
+ },
+ {
+ name: "invalid URL",
+ rawURL: "://invalid",
+ expected: "",
+ },
+ {
+ name: "URL with query params",
+ rawURL: "https://app.example.com/path?query=value",
+ expected: ".example.com",
+ },
+ {
+ name: "URL with fragment",
+ rawURL: "https://app.example.com/path#section",
+ expected: ".example.com",
+ },
+ {
+ name: "URL with port",
+ rawURL: "https://app.example.com:8443/secure",
+ expected: ".example.com",
+ },
+ {
+ name: "relative path only",
+ rawURL: "/dashboard",
+ expected: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := extractCookieDomain(tc.rawURL)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// Edge Case Tests
+// =============================================================================
+
+// TestExtractBaseDomainFallbackEdgeCases tests edge cases for extractBaseDomainFallback
+func TestExtractBaseDomainFallbackEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ host string
+ expected string
+ }{
+ {
+ name: "just a dot",
+ host: ".",
+ expected: "..", // Split on "." gives ["", ""], join last 2 = ".", prepend "." = ".."
+ },
+ {
+ name: "multiple dots only",
+ host: "...",
+ expected: "..", // Split on "." gives ["", "", "", ""], join last 2 = ".", prepend "." = ".."
+ },
+ {
+ name: "trailing dot",
+ host: "example.com.",
+ expected: ".com.", // Split gives ["example", "com", ""], join last 2 = "com.", prepend "." = ".com."
+ },
+ {
+ name: "leading dot",
+ host: ".example.com",
+ expected: ".example.com", // Split gives ["", "example", "com"], join last 2 = "example.com", prepend "." = ".example.com"
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := extractBaseDomainFallback(tc.host)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// TestParseBasicAuthEdgeCases tests edge cases for parseBasicAuth
+func TestParseBasicAuthEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ authHeader string
+ expectedUser string
+ expectedPass string
+ expectNil bool
+ }{
+ {
+ name: "unicode username and password",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:password")),
+ expectedUser: "user",
+ expectedPass: "password",
+ expectNil: false,
+ },
+ {
+ name: "special characters in password",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:p@ss!#$%^&*()")),
+ expectedUser: "user",
+ expectedPass: "p@ss!#$%^&*()",
+ expectNil: false,
+ },
+ {
+ name: "empty username",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":password")),
+ expectedUser: "",
+ expectedPass: "password",
+ expectNil: false,
+ },
+ {
+ name: "both empty",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
+ expectedUser: "",
+ expectedPass: "",
+ expectNil: false,
+ },
+ {
+ name: "lowercase basic",
+ authHeader: "basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")),
+ expectNil: true, // Should be case-sensitive
+ },
+ {
+ name: "extra spaces",
+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")),
+ expectNil: true, // Double space makes invalid base64
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := parseBasicAuth(tc.authHeader)
+
+ if tc.expectNil {
+ assert.Nil(t, result)
+ } else {
+ assert.NotNil(t, result)
+ assert.Equal(t, tc.expectedUser, result.Username)
+ assert.Equal(t, tc.expectedPass, result.Password)
+ }
+ })
+ }
+}
diff --git a/backend/internal/api/handlers/audit_handler.go b/backend/internal/api/handlers/audit_handler.go
index cf0b511..4ccde69 100644
--- a/backend/internal/api/handlers/audit_handler.go
+++ b/backend/internal/api/handlers/audit_handler.go
@@ -79,7 +79,7 @@ func (h *AuditHandler) GetByID(w http.ResponseWriter, r *http.Request) {
}
// GetStats returns aggregate statistics for audit logs
-func (h *AuditHandler) GetStats(w http.ResponseWriter, r *http.Request) {
+func (h *AuditHandler) GetStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.auditService.GetStats()
if err != nil {
if h.logger != nil {
@@ -149,7 +149,7 @@ func (h *AuditHandler) Export(w http.ResponseWriter, r *http.Request) {
}
// GetConfig returns the audit configuration
-func (h *AuditHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
+func (h *AuditHandler) GetConfig(w http.ResponseWriter, _ *http.Request) {
config, err := h.auditService.GetConfig()
if err != nil {
if h.logger != nil {
@@ -182,7 +182,7 @@ func (h *AuditHandler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
}
// GetEventGroups returns the available audit event groups for configuration UI
-func (h *AuditHandler) GetEventGroups(w http.ResponseWriter, r *http.Request) {
+func (h *AuditHandler) GetEventGroups(w http.ResponseWriter, _ *http.Request) {
groups := models.GetAuditEventGroups()
utils.Success(w, groups, "Audit event groups retrieved successfully")
}
diff --git a/backend/internal/api/handlers/audit_handler_test.go b/backend/internal/api/handlers/audit_handler_test.go
index 42abcc8..462e0b5 100644
--- a/backend/internal/api/handlers/audit_handler_test.go
+++ b/backend/internal/api/handlers/audit_handler_test.go
@@ -38,7 +38,7 @@ func TestNewAuditHandler(t *testing.T) {
func TestAuditHandler_List_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- ListAuditLogsFunc: func(params repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ ListAuditLogsFunc: func(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
return &models.AuditLogListResponse{
Items: []models.AuditLog{
{ID: 1, Action: "proxy.create", Status: "success"},
@@ -212,7 +212,6 @@ func TestAuditHandler_List_WithIPAddressFilters(t *testing.T) {
}
for _, tc := range testCases {
- tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var capturedParams repository.AuditLogListParams
@@ -343,7 +342,7 @@ func TestAuditHandler_List_InvalidStatus(t *testing.T) {
func TestAuditHandler_List_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- ListAuditLogsFunc: func(params repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ ListAuditLogsFunc: func(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
return nil, errors.New("database error")
},
}
@@ -364,7 +363,7 @@ func TestAuditHandler_List_ServiceError(t *testing.T) {
func TestAuditHandler_GetByID_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- GetAuditLogByIDFunc: func(id int) (*models.AuditLog, error) {
+ GetAuditLogByIDFunc: func(_ int) (*models.AuditLog, error) {
return &models.AuditLog{
ID: 1,
Action: "proxy.create",
@@ -396,7 +395,7 @@ func TestAuditHandler_GetByID_Success(t *testing.T) {
func TestAuditHandler_GetByID_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- GetAuditLogByIDFunc: func(id int) (*models.AuditLog, error) {
+ GetAuditLogByIDFunc: func(_ int) (*models.AuditLog, error) {
return nil, errors.New("not found")
},
}
@@ -626,7 +625,7 @@ func TestAuditHandler_UpdateConfig_InvalidJSON(t *testing.T) {
func TestAuditHandler_UpdateConfig_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- SetConfigFunc: func(config *models.AuditConfig) error {
+ SetConfigFunc: func(_ *models.AuditConfig) error {
return errors.New("database error")
},
}
@@ -666,7 +665,7 @@ func TestAuditHandler_UpdateConfig_ServiceError(t *testing.T) {
func TestAuditHandler_Export_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- ListAuditLogsFunc: func(params repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ ListAuditLogsFunc: func(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
resourceType := "proxy"
resourceID := 1
resourceName := "Test Proxy"
@@ -715,7 +714,7 @@ func TestAuditHandler_Export_Success(t *testing.T) {
func TestAuditHandler_Export_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- ListAuditLogsFunc: func(params repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ ListAuditLogsFunc: func(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
return nil, errors.New("database error")
},
}
@@ -836,7 +835,7 @@ func TestIntPtrToString(t *testing.T) {
func TestAuditHandler_ResponseFormat(t *testing.T) {
t.Parallel()
mockService := &mocks.MockAuditService{
- ListAuditLogsFunc: func(params repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
+ ListAuditLogsFunc: func(_ repository.AuditLogListParams) (*models.AuditLogListResponse, error) {
return &models.AuditLogListResponse{
Items: []models.AuditLog{},
Total: 0,
@@ -919,7 +918,6 @@ func TestSplitAndTrim(t *testing.T) {
}
for _, tc := range testCases {
- tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := splitAndTrim(tc.input)
@@ -1074,7 +1072,6 @@ func TestParseFilterParam(t *testing.T) {
}
for _, tc := range testCases {
- tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := parseFilterParam(tc.input)
@@ -1119,7 +1116,6 @@ func TestParseFilterParam_InvalidOperatorForField(t *testing.T) {
}
for _, tc := range testCases {
- tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/audit-logs?"+tc.query, nil)
diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go
index d39ff18..a4d6764 100644
--- a/backend/internal/api/handlers/auth_handler.go
+++ b/backend/internal/api/handlers/auth_handler.go
@@ -336,6 +336,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Log audit event
if h.auditService != nil && userID > 0 {
+ // nolint:gosec // G115: userID from JWT claims will never exceed int max in practice
if user, err := h.userRepo.GetByID(int(userID)); err == nil {
_ = h.auditService.LogLogout(ctx, int(userID), user.Username, getClientIP(r), r.UserAgent())
}
@@ -376,6 +377,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
}
// Get the current user
+ // nolint:gosec // G115: userID from JWT claims will never exceed int max in practice
user, err := h.userRepo.GetByID(int(userID))
if err != nil {
if h.logger != nil {
@@ -405,6 +407,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
}
// Update user in database - we need to update the password hash
+ // nolint:gosec // G115: userID from JWT claims will never exceed int max in practice
if err := h.userRepo.UpdatePassword(int(userID), user.PasswordHash); err != nil {
if h.logger != nil {
h.logger.Error("Failed to update password in database",
@@ -417,6 +420,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
// Log audit event
if h.auditService != nil {
+ // nolint:gosec // G115: userID from JWT claims will never exceed int max in practice
_ = h.auditService.LogPasswordChange(ctx, int(userID), user.Username, getClientIP(r), r.UserAgent())
}
@@ -432,6 +436,7 @@ func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) {
return
}
+ // nolint:gosec // G115: userID from JWT claims will never exceed int max in practice
user, err := h.userRepo.GetByID(int(userID))
if err != nil {
if h.logger != nil {
diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go
index 136bac7..7c3b9f0 100644
--- a/backend/internal/api/handlers/auth_handler_test.go
+++ b/backend/internal/api/handlers/auth_handler_test.go
@@ -9,13 +9,12 @@ import (
"net/http/httptest"
"testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "gorm.io/gorm"
-
"github.com/aloks98/goauth/middleware"
"github.com/aloks98/goauth/store"
"github.com/aloks98/goauth/token"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
"github.com/aloks98/waygates/backend/internal/models"
"github.com/aloks98/waygates/backend/internal/service/mocks"
@@ -214,7 +213,7 @@ func TestRegister(t *testing.T) {
Email: "test@example.com",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
if identifier == "test@example.com" {
return &models.User{ID: 1, Email: "test@example.com"}, nil
@@ -232,9 +231,9 @@ func TestRegister(t *testing.T) {
Email: "test@example.com",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
callCount := 0
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
callCount++
if callCount == 1 {
return nil, gorm.ErrRecordNotFound // Email not found
@@ -252,8 +251,8 @@ func TestRegister(t *testing.T) {
Email: "test@example.com",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, errors.New("database error")
}
},
@@ -267,11 +266,11 @@ func TestRegister(t *testing.T) {
Email: "test@example.com",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
- userRepo.CreateFunc = func(user *models.User) error {
+ userRepo.CreateFunc = func(_ *models.User) error {
return errors.New("database error")
}
},
@@ -286,7 +285,7 @@ func TestRegister(t *testing.T) {
Password: "password123",
},
setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
userRepo.CreateFunc = func(user *models.User) error {
@@ -296,7 +295,7 @@ func TestRegister(t *testing.T) {
userRepo.CountFunc = func() (int64, error) {
return 1, nil
}
- authProvider.AssignRoleFunc = func(ctx context.Context, userID, role string) error {
+ authProvider.AssignRoleFunc = func(_ context.Context, _, _ string) error {
return errors.New("rbac error")
}
},
@@ -311,7 +310,7 @@ func TestRegister(t *testing.T) {
Password: "password123",
},
setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
userRepo.CreateFunc = func(user *models.User) error {
@@ -321,7 +320,7 @@ func TestRegister(t *testing.T) {
userRepo.CountFunc = func() (int64, error) {
return 1, nil // First user
}
- authProvider.AssignRoleFunc = func(ctx context.Context, userID, role string) error {
+ authProvider.AssignRoleFunc = func(_ context.Context, _, role string) error {
if role != "admin" {
return errors.New("expected admin role for first user")
}
@@ -338,8 +337,8 @@ func TestRegister(t *testing.T) {
Email: "test@example.com",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
userRepo.CreateFunc = func(user *models.User) error {
@@ -427,8 +426,8 @@ func TestLogin(t *testing.T) {
Identifier: "nonexistent",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
},
@@ -440,8 +439,8 @@ func TestLogin(t *testing.T) {
Identifier: "testuser",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, errors.New("database error")
}
},
@@ -453,8 +452,8 @@ func TestLogin(t *testing.T) {
Identifier: "testuser",
Password: "wrongpassword",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return testUser, nil
}
},
@@ -467,10 +466,10 @@ func TestLogin(t *testing.T) {
Password: "password123",
},
setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return testUser, nil
}
- authProvider.GenerateTokenPairFunc = func(ctx context.Context, userID string, metadata map[string]any) (*token.Pair, error) {
+ authProvider.GenerateTokenPairFunc = func(_ context.Context, _ string, _ map[string]any) (*token.Pair, error) {
return nil, errors.New("token error")
}
},
@@ -482,8 +481,8 @@ func TestLogin(t *testing.T) {
Identifier: "testuser",
Password: "password123",
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return testUser, nil
}
},
@@ -549,7 +548,7 @@ func TestRefreshToken(t *testing.T) {
RefreshToken: "invalid-token",
},
setupMocks: func(authProvider *MockAuthProvider) {
- authProvider.RefreshTokensFunc = func(ctx context.Context, refreshToken string) (*token.Pair, error) {
+ authProvider.RefreshTokensFunc = func(_ context.Context, _ string) (*token.Pair, error) {
return nil, errors.New("invalid token")
}
},
@@ -671,8 +670,8 @@ func TestGetMe(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
},
@@ -685,7 +684,7 @@ func TestGetMe(t *testing.T) {
return r.WithContext(ctx)
},
setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return &models.User{
ID: 1,
Name: "Test User",
@@ -693,7 +692,7 @@ func TestGetMe(t *testing.T) {
Email: "test@example.com",
}, nil
}
- authProvider.GetUserPermissionsFunc = func(ctx context.Context, userID string) (*store.UserPermissions, error) {
+ authProvider.GetUserPermissionsFunc = func(_ context.Context, _ string) (*store.UserPermissions, error) {
return &store.UserPermissions{
RoleLabel: "admin",
Permissions: []string{"read", "write"},
@@ -709,7 +708,7 @@ func TestGetMe(t *testing.T) {
return r.WithContext(ctx)
},
setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return &models.User{
ID: 1,
Name: "Test User",
@@ -717,7 +716,7 @@ func TestGetMe(t *testing.T) {
Email: "test@example.com",
}, nil
}
- authProvider.GetUserPermissionsFunc = func(ctx context.Context, userID string) (*store.UserPermissions, error) {
+ authProvider.GetUserPermissionsFunc = func(_ context.Context, _ string) (*store.UserPermissions, error) {
return nil, errors.New("permissions error")
}
},
@@ -1079,12 +1078,12 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
testUser := makeTestUser(t, "oldpassword123")
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return testUser, nil
}
- userRepo.UpdatePasswordFunc = func(id int, passwordHash string) error {
+ userRepo.UpdatePasswordFunc = func(_ int, _ string) error {
return nil
}
},
@@ -1233,12 +1232,12 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
testUser := makeTestUser(t, "oldpassword123")
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return testUser, nil
}
- userRepo.UpdatePasswordFunc = func(id int, passwordHash string) error {
+ userRepo.UpdatePasswordFunc = func(_ int, _ string) error {
return nil
}
},
@@ -1254,8 +1253,8 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "999")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
},
@@ -1278,9 +1277,9 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
testUser := makeTestUser(t, "correctpassword123")
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return testUser, nil
}
},
@@ -1303,12 +1302,12 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
testUser := makeTestUser(t, "oldpassword123")
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return testUser, nil
}
- userRepo.UpdatePasswordFunc = func(id int, passwordHash string) error {
+ userRepo.UpdatePasswordFunc = func(_ int, _ string) error {
return errors.New("database connection error")
}
},
@@ -1331,8 +1330,8 @@ func TestChangePassword(t *testing.T) {
ctx := context.WithValue(r.Context(), middleware.UserIDKey, "1")
return r.WithContext(ctx)
},
- setupMocks: func(userRepo *MockUserRepository, authProvider *MockAuthProvider) {
- userRepo.GetByIDFunc = func(id int) (*models.User, error) {
+ setupMocks: func(userRepo *MockUserRepository, _ *MockAuthProvider) {
+ userRepo.GetByIDFunc = func(_ int) (*models.User, error) {
return nil, errors.New("database connection error")
}
},
@@ -1404,7 +1403,7 @@ func TestChangePassword_WithAuditLogging(t *testing.T) {
auditLogCalled := false
mockAuditService := &mocks.MockAuditService{
- LogPasswordChangeFunc: func(ctx context.Context, userID int, username string, ip, userAgent string) error {
+ LogPasswordChangeFunc: func(_ context.Context, userID int, username string, _, _ string) error {
auditLogCalled = true
assert.Equal(t, 1, userID)
assert.Equal(t, "testuser", username)
@@ -1413,10 +1412,10 @@ func TestChangePassword_WithAuditLogging(t *testing.T) {
}
userRepo := &MockUserRepository{
- GetByIDFunc: func(id int) (*models.User, error) {
+ GetByIDFunc: func(_ int) (*models.User, error) {
return testUser, nil
},
- UpdatePasswordFunc: func(id int, passwordHash string) error {
+ UpdatePasswordFunc: func(_ int, _ string) error {
return nil
},
}
@@ -1457,14 +1456,14 @@ func TestChangePassword_AuditLoggingNotCalledOnFailure(t *testing.T) {
auditLogCalled := false
mockAuditService := &mocks.MockAuditService{
- LogPasswordChangeFunc: func(ctx context.Context, userID int, username string, ip, userAgent string) error {
+ LogPasswordChangeFunc: func(_ context.Context, _ int, _ string, _, _ string) error {
auditLogCalled = true
return nil
},
}
userRepo := &MockUserRepository{
- GetByIDFunc: func(id int) (*models.User, error) {
+ GetByIDFunc: func(_ int) (*models.User, error) {
return testUser, nil
},
}
@@ -1506,10 +1505,10 @@ func TestChangePassword_PasswordHashActuallyUpdated(t *testing.T) {
var updatedHash string
userRepo := &MockUserRepository{
- GetByIDFunc: func(id int) (*models.User, error) {
+ GetByIDFunc: func(_ int) (*models.User, error) {
return testUser, nil
},
- UpdatePasswordFunc: func(id int, passwordHash string) error {
+ UpdatePasswordFunc: func(_ int, passwordHash string) error {
updatedHash = passwordHash
return nil
},
@@ -1557,7 +1556,7 @@ func TestChangePassword_CorrectUserIDUsed(t *testing.T) {
getByIDCalledWith = id
return testUser, nil
},
- UpdatePasswordFunc: func(id int, passwordHash string) error {
+ UpdatePasswordFunc: func(id int, _ string) error {
updatePasswordCalledWith = id
return nil
},
diff --git a/backend/internal/api/handlers/health.go b/backend/internal/api/handlers/health.go
index 40180e7..fc8910e 100644
--- a/backend/internal/api/handlers/health.go
+++ b/backend/internal/api/handlers/health.go
@@ -32,7 +32,7 @@ func NewHealthHandlerWithDB(db *gorm.DB) *HealthHandler {
}
// HealthCheck returns the health status of the service
-func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
+func (h *HealthHandler) HealthCheck(w http.ResponseWriter, _ *http.Request) {
uptime := time.Since(h.startTime)
// Check database health
diff --git a/backend/internal/api/handlers/oauth_handler.go b/backend/internal/api/handlers/oauth_handler.go
index ab51481..f5d0181 100644
--- a/backend/internal/api/handlers/oauth_handler.go
+++ b/backend/internal/api/handlers/oauth_handler.go
@@ -92,7 +92,7 @@ type OAuthProvidersResponse struct {
// ListProviders handles GET /api/auth/oauth/providers
// Returns a list of OAuth providers that are available (env vars configured)
// This is a public endpoint used by the login page to show available OAuth options
-func (h *OAuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
+func (h *OAuthHandler) ListProviders(w http.ResponseWriter, _ *http.Request) {
// Get all available providers (env vars configured)
providers := h.providerManager.GetAvailableProvidersPublic()
@@ -694,7 +694,7 @@ func getString(data map[string]interface{}, key string) string {
}
// findOrCreateUser finds an existing user by email or creates a new one
-func (h *OAuthHandler) findOrCreateUser(ctx context.Context, providerID auth.OAuthProviderID, userInfo *OAuthUserInfo) (*models.User, error) {
+func (h *OAuthHandler) findOrCreateUser(_ context.Context, providerID auth.OAuthProviderID, userInfo *OAuthUserInfo) (*models.User, error) {
// Try to find user by email
user, err := h.userRepo.GetByUsernameOrEmail(userInfo.Email)
if err == nil {
diff --git a/backend/internal/api/handlers/oauth_handler_test.go b/backend/internal/api/handlers/oauth_handler_test.go
index 9fb1d1d..8f23ce0 100644
--- a/backend/internal/api/handlers/oauth_handler_test.go
+++ b/backend/internal/api/handlers/oauth_handler_test.go
@@ -20,6 +20,7 @@ import (
"github.com/aloks98/waygates/backend/internal/auth"
"github.com/aloks98/waygates/backend/internal/config"
"github.com/aloks98/waygates/backend/internal/models"
+ "github.com/aloks98/waygates/backend/internal/repository"
"github.com/aloks98/waygates/backend/internal/service"
)
@@ -33,106 +34,106 @@ type oauthMockACLService struct {
}
// Group Management
-func (m *oauthMockACLService) CreateGroup(group *models.ACLGroup, createdBy int) error {
+func (m *oauthMockACLService) CreateGroup(_ *models.ACLGroup, _ int) error {
return nil
}
-func (m *oauthMockACLService) GetGroup(id int) (*models.ACLGroup, error) { return nil, nil }
-func (m *oauthMockACLService) GetGroupByName(name string) (*models.ACLGroup, error) {
+func (m *oauthMockACLService) GetGroup(_ int) (*models.ACLGroup, error) { return nil, nil }
+func (m *oauthMockACLService) GetGroupByName(_ string) (*models.ACLGroup, error) {
return nil, nil
}
-func (m *oauthMockACLService) ListGroups(params service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error) {
+func (m *oauthMockACLService) ListGroups(_ service.ListACLGroupsRequest) (*models.ACLGroupListResponse, error) {
return nil, nil
}
-func (m *oauthMockACLService) UpdateGroup(id int, updates *models.ACLGroup) error {
+func (m *oauthMockACLService) UpdateGroup(_ int, _ *models.ACLGroup) error {
return nil
}
-func (m *oauthMockACLService) DeleteGroup(id int) error { return nil }
-func (m *oauthMockACLService) DeleteGroupWithSync(id int, syncFn service.SyncCallback) error {
+func (m *oauthMockACLService) DeleteGroup(_ int) error { return nil }
+func (m *oauthMockACLService) DeleteGroupWithSync(_ int, _ service.SyncCallback) error {
return nil
}
// IP Rules
-func (m *oauthMockACLService) AddIPRule(groupID int, rule *models.ACLIPRule) error {
+func (m *oauthMockACLService) AddIPRule(_ int, _ *models.ACLIPRule) error {
return nil
}
-func (m *oauthMockACLService) UpdateIPRule(id int, rule *models.ACLIPRule) error {
+func (m *oauthMockACLService) UpdateIPRule(_ int, _ *models.ACLIPRule) error {
return nil
}
-func (m *oauthMockACLService) DeleteIPRule(id int) error { return nil }
+func (m *oauthMockACLService) DeleteIPRule(_ int) error { return nil }
// Basic Auth
-func (m *oauthMockACLService) AddBasicAuthUser(groupID int, username, password string) error {
+func (m *oauthMockACLService) AddBasicAuthUser(_ int, _, _ string) error {
return nil
}
-func (m *oauthMockACLService) UpdateBasicAuthPassword(id int, password string) error {
+func (m *oauthMockACLService) UpdateBasicAuthPassword(_ int, _ string) error {
return nil
}
-func (m *oauthMockACLService) DeleteBasicAuthUser(id int) error { return nil }
+func (m *oauthMockACLService) DeleteBasicAuthUser(_ int) error { return nil }
// External Providers
-func (m *oauthMockACLService) AddExternalProvider(groupID int, provider *models.ACLExternalProvider) error {
+func (m *oauthMockACLService) AddExternalProvider(_ int, _ *models.ACLExternalProvider) error {
return nil
}
-func (m *oauthMockACLService) UpdateExternalProvider(id int, provider *models.ACLExternalProvider) error {
+func (m *oauthMockACLService) UpdateExternalProvider(_ int, _ *models.ACLExternalProvider) error {
return nil
}
-func (m *oauthMockACLService) DeleteExternalProvider(id int) error { return nil }
+func (m *oauthMockACLService) DeleteExternalProvider(_ int) error { return nil }
// Waygates Auth Config
-func (m *oauthMockACLService) GetWaygatesAuth(groupID int) (*models.ACLWaygatesAuth, error) {
+func (m *oauthMockACLService) GetWaygatesAuth(_ int) (*models.ACLWaygatesAuth, error) {
return nil, nil
}
-func (m *oauthMockACLService) ConfigureWaygatesAuth(groupID int, config *models.ACLWaygatesAuth) error {
+func (m *oauthMockACLService) ConfigureWaygatesAuth(_ int, _ *models.ACLWaygatesAuth) error {
return nil
}
// Proxy Assignment
-func (m *oauthMockACLService) AssignToProxy(proxyID, groupID int, pathPattern string, priority int) error {
+func (m *oauthMockACLService) AssignToProxy(_, _ int, _ string, _ int) error {
return nil
}
-func (m *oauthMockACLService) UpdateProxyAssignment(id int, pathPattern string, priority int, enabled bool) error {
+func (m *oauthMockACLService) UpdateProxyAssignment(_ int, _ string, _ int, _ bool) error {
return nil
}
-func (m *oauthMockACLService) RemoveFromProxy(proxyID, groupID int) error { return nil }
-func (m *oauthMockACLService) GetProxyACL(proxyID int) ([]models.ProxyACLAssignment, error) {
+func (m *oauthMockACLService) RemoveFromProxy(_, _ int) error { return nil }
+func (m *oauthMockACLService) GetProxyACL(_ int) ([]models.ProxyACLAssignment, error) {
return nil, nil
}
-func (m *oauthMockACLService) GetGroupUsage(groupID int) ([]models.ProxyACLAssignment, error) {
+func (m *oauthMockACLService) GetGroupUsage(_ int) ([]models.ProxyACLAssignment, error) {
return nil, nil
}
// Branding
func (m *oauthMockACLService) GetBranding() (*models.ACLBranding, error) { return nil, nil }
-func (m *oauthMockACLService) UpdateBranding(branding *models.ACLBranding) error {
+func (m *oauthMockACLService) UpdateBranding(_ *models.ACLBranding) error {
return nil
}
// OAuth Provider Restrictions
-func (m *oauthMockACLService) GetOAuthProviderRestrictions(groupID int) ([]models.ACLOAuthProviderRestriction, error) {
+func (m *oauthMockACLService) GetOAuthProviderRestrictions(_ int) ([]models.ACLOAuthProviderRestriction, error) {
return nil, nil
}
-func (m *oauthMockACLService) SetOAuthProviderRestriction(groupID int, provider string, emails, domains []string, enabled bool) error {
+func (m *oauthMockACLService) SetOAuthProviderRestriction(_ int, _ string, _, _ []string, _ bool) error {
return nil
}
-func (m *oauthMockACLService) DeleteOAuthProviderRestriction(groupID int, provider string) error {
+func (m *oauthMockACLService) DeleteOAuthProviderRestriction(_ int, _ string) error {
return nil
}
// Access Verification
-func (m *oauthMockACLService) VerifyAccess(request *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
+func (m *oauthMockACLService) VerifyAccess(_ *service.ACLVerifyRequest) (*service.ACLVerifyResponse, error) {
return nil, nil
}
// Auth Options
-func (m *oauthMockACLService) GetAuthOptionsForProxy(hostname string) (*service.AuthOptionsResponse, error) {
+func (m *oauthMockACLService) GetAuthOptionsForProxy(_ string) (*service.AuthOptionsResponse, error) {
return nil, nil
}
// Session Management
-func (m *oauthMockACLService) CreateSession(userID int, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+func (m *oauthMockACLService) CreateSession(_ int, _ *int, _, _ string, _ int) (*models.ACLSession, error) {
return nil, nil
}
-func (m *oauthMockACLService) CreateOAuthSession(email, provider string, proxyID *int, ip, userAgent string, ttl int) (*models.ACLSession, error) {
+func (m *oauthMockACLService) CreateOAuthSession(_, _ string, _ *int, _, _ string, _ int) (*models.ACLSession, error) {
return nil, nil
}
func (m *oauthMockACLService) CreateSessionWithParams(params service.CreateSessionParams) (*models.ACLSession, error) {
@@ -144,11 +145,11 @@ func (m *oauthMockACLService) CreateSessionWithParams(params service.CreateSessi
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
-func (m *oauthMockACLService) ValidateSession(token string) (*models.ACLSession, error) {
+func (m *oauthMockACLService) ValidateSession(_ string) (*models.ACLSession, error) {
return nil, nil
}
-func (m *oauthMockACLService) RevokeSession(token string) error { return nil }
-func (m *oauthMockACLService) RevokeUserSessions(userID int) error {
+func (m *oauthMockACLService) RevokeSession(_ string) error { return nil }
+func (m *oauthMockACLService) RevokeUserSessions(_ int) error {
return nil
}
func (m *oauthMockACLService) CleanupExpiredSessions() (int64, error) { return 0, nil }
@@ -810,7 +811,7 @@ func TestOAuthHandler_findOrCreateUser(t *testing.T) {
Username: "existing",
},
setupMocks: func(repo *oauthMockUserRepository) {
- repo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ repo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return &models.User{
ID: 1,
Email: "existing@example.com",
@@ -833,7 +834,7 @@ func TestOAuthHandler_findOrCreateUser(t *testing.T) {
Username: "newuser",
},
setupMocks: func(repo *oauthMockUserRepository) {
- repo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ repo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
repo.CreateFunc = func(user *models.User) error {
@@ -891,7 +892,7 @@ func TestOAuthHandler_findOrCreateUser(t *testing.T) {
Username: "erroruser",
},
setupMocks: func(repo *oauthMockUserRepository) {
- repo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ repo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrInvalidDB
}
},
@@ -907,10 +908,10 @@ func TestOAuthHandler_findOrCreateUser(t *testing.T) {
Username: "createerror",
},
setupMocks: func(repo *oauthMockUserRepository) {
- repo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ repo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
return nil, gorm.ErrRecordNotFound
}
- repo.CreateFunc = func(user *models.User) error {
+ repo.CreateFunc = func(_ *models.User) error {
return gorm.ErrInvalidDB
}
},
@@ -1080,3 +1081,759 @@ func TestGetString(t *testing.T) {
})
}
}
+
+// =============================================================================
+// TestGenerateCodeVerifier
+// =============================================================================
+
+func TestGenerateCodeVerifier(t *testing.T) {
+ t.Parallel()
+
+ t.Run("generates valid code verifier", func(t *testing.T) {
+ t.Parallel()
+
+ verifier, err := generateCodeVerifier()
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, verifier)
+ // PKCE code verifier should be 43-128 characters (we generate 43)
+ assert.GreaterOrEqual(t, len(verifier), 43)
+ assert.LessOrEqual(t, len(verifier), 128)
+
+ // Should only contain URL-safe characters (a-z, A-Z, 0-9, -, _)
+ for _, c := range verifier {
+ isValid := (c >= 'a' && c <= 'z') ||
+ (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') ||
+ c == '-' || c == '_'
+ assert.True(t, isValid, "Character %c is not URL-safe", c)
+ }
+ })
+
+ t.Run("generates unique verifiers", func(t *testing.T) {
+ t.Parallel()
+
+ verifier1, err1 := generateCodeVerifier()
+ verifier2, err2 := generateCodeVerifier()
+
+ require.NoError(t, err1)
+ require.NoError(t, err2)
+ assert.NotEqual(t, verifier1, verifier2)
+ })
+
+ t.Run("generates consistent length", func(t *testing.T) {
+ t.Parallel()
+
+ // Generate multiple verifiers and check they all have the same length
+ lengths := make(map[int]int)
+ for i := 0; i < 10; i++ {
+ verifier, err := generateCodeVerifier()
+ require.NoError(t, err)
+ lengths[len(verifier)]++
+ }
+ // All verifiers should have the same length
+ assert.Len(t, lengths, 1, "All verifiers should have the same length")
+ })
+}
+
+// =============================================================================
+// TestGenerateCodeChallenge
+// =============================================================================
+
+func TestGenerateCodeChallenge(t *testing.T) {
+ t.Parallel()
+
+ t.Run("generates valid code challenge from verifier", func(t *testing.T) {
+ t.Parallel()
+
+ verifier := "test-verifier-12345"
+ challenge := generateCodeChallenge(verifier)
+
+ assert.NotEmpty(t, challenge)
+ // Challenge should be base64url encoded SHA256 (43 chars without padding)
+ assert.Equal(t, 43, len(challenge))
+
+ // Should only contain URL-safe base64 characters
+ for _, c := range challenge {
+ isValid := (c >= 'a' && c <= 'z') ||
+ (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') ||
+ c == '-' || c == '_'
+ assert.True(t, isValid, "Character %c is not URL-safe base64", c)
+ }
+ })
+
+ t.Run("same verifier produces same challenge", func(t *testing.T) {
+ t.Parallel()
+
+ verifier := "consistent-verifier"
+ challenge1 := generateCodeChallenge(verifier)
+ challenge2 := generateCodeChallenge(verifier)
+
+ assert.Equal(t, challenge1, challenge2)
+ })
+
+ t.Run("different verifiers produce different challenges", func(t *testing.T) {
+ t.Parallel()
+
+ challenge1 := generateCodeChallenge("verifier1")
+ challenge2 := generateCodeChallenge("verifier2")
+
+ assert.NotEqual(t, challenge1, challenge2)
+ })
+
+ t.Run("empty verifier produces valid challenge", func(t *testing.T) {
+ t.Parallel()
+
+ challenge := generateCodeChallenge("")
+
+ assert.NotEmpty(t, challenge)
+ assert.Equal(t, 43, len(challenge))
+ })
+
+ // Known test vector for PKCE S256
+ // This verifies our implementation matches the PKCE spec
+ t.Run("matches PKCE spec example", func(t *testing.T) {
+ t.Parallel()
+
+ // RFC 7636 example: code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ // The expected challenge for this verifier using S256 is "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
+ verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ challenge := generateCodeChallenge(verifier)
+
+ assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge)
+ })
+}
+
+// =============================================================================
+// TestOAuthHandler_buildOAuth2Config
+// =============================================================================
+
+func TestOAuthHandler_buildOAuth2Config(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := createTestOAuthHandler(t)
+
+ tests := []struct {
+ name string
+ provider *auth.OAuthProvider
+ expectedClientID string
+ expectedScopes []string
+ }{
+ {
+ name: "Google provider",
+ provider: &auth.OAuthProvider{
+ ID: auth.OAuthProviderGoogle,
+ ClientID: "google-client-id",
+ ClientSecret: "google-client-secret",
+ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
+ TokenURL: "https://oauth2.googleapis.com/token",
+ Scopes: []string{"openid", "profile", "email"},
+ },
+ expectedClientID: "google-client-id",
+ expectedScopes: []string{"openid", "profile", "email"},
+ },
+ {
+ name: "GitHub provider",
+ provider: &auth.OAuthProvider{
+ ID: auth.OAuthProviderGitHub,
+ ClientID: "github-client-id",
+ ClientSecret: "github-client-secret",
+ AuthURL: "https://github.com/login/oauth/authorize",
+ TokenURL: "https://github.com/login/oauth/access_token",
+ Scopes: []string{"user:email"},
+ },
+ expectedClientID: "github-client-id",
+ expectedScopes: []string{"user:email"},
+ },
+ {
+ name: "Provider with empty scopes",
+ provider: &auth.OAuthProvider{
+ ID: auth.OAuthProviderMicrosoft,
+ ClientID: "ms-client-id",
+ ClientSecret: "ms-client-secret",
+ AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+ TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ Scopes: []string{},
+ },
+ expectedClientID: "ms-client-id",
+ expectedScopes: []string{},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ config := handler.buildOAuth2Config(tc.provider)
+
+ require.NotNil(t, config)
+ assert.Equal(t, tc.expectedClientID, config.ClientID)
+ assert.Equal(t, tc.provider.ClientSecret, config.ClientSecret)
+ assert.Equal(t, tc.provider.AuthURL, config.Endpoint.AuthURL)
+ assert.Equal(t, tc.provider.TokenURL, config.Endpoint.TokenURL)
+ assert.Equal(t, tc.expectedScopes, config.Scopes)
+
+ // Verify redirect URL is set correctly
+ expectedRedirectURL := "http://localhost:8080/auth/oauth/" + string(tc.provider.ID) + "/callback"
+ assert.Equal(t, expectedRedirectURL, config.RedirectURL)
+ })
+ }
+}
+
+// =============================================================================
+// TestOAuthHandler_getCallbackURL
+// =============================================================================
+
+func TestOAuthHandler_getCallbackURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ callbackBaseURL string
+ providerID auth.OAuthProviderID
+ expectedURL string
+ }{
+ {
+ name: "standard callback URL",
+ callbackBaseURL: "http://localhost:8080",
+ providerID: auth.OAuthProviderGoogle,
+ expectedURL: "http://localhost:8080/auth/oauth/google/callback",
+ },
+ {
+ name: "HTTPS callback URL",
+ callbackBaseURL: "https://myapp.example.com",
+ providerID: auth.OAuthProviderGitHub,
+ expectedURL: "https://myapp.example.com/auth/oauth/github/callback",
+ },
+ {
+ name: "callback URL with trailing slash",
+ callbackBaseURL: "https://myapp.example.com/",
+ providerID: auth.OAuthProviderMicrosoft,
+ expectedURL: "https://myapp.example.com/auth/oauth/microsoft/callback",
+ },
+ {
+ name: "empty callback base URL uses default",
+ callbackBaseURL: "",
+ providerID: auth.OAuthProviderGitLab,
+ expectedURL: "http://localhost:8080/auth/oauth/gitlab/callback",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{
+ ACL: config.ACLConfig{
+ OAuth: config.OAuthConfig{
+ CallbackBaseURL: tc.callbackBaseURL,
+ },
+ },
+ }
+
+ handler := NewOAuthHandler(OAuthHandlerConfig{
+ Config: cfg,
+ Logger: zap.NewNop(),
+ })
+
+ result := handler.getCallbackURL(tc.providerID)
+ assert.Equal(t, tc.expectedURL, result)
+ })
+ }
+}
+
+// =============================================================================
+// TestGenerateRandomPassword
+// =============================================================================
+
+func TestGenerateRandomPassword(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ length int
+ }{
+ {"short password", 8},
+ {"medium password", 16},
+ {"long password", 32},
+ {"very long password", 64},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ password, err := generateRandomPassword(tc.length)
+
+ require.NoError(t, err)
+ assert.Equal(t, tc.length, len(password))
+ })
+ }
+
+ t.Run("passwords are unique", func(t *testing.T) {
+ t.Parallel()
+
+ password1, err1 := generateRandomPassword(32)
+ password2, err2 := generateRandomPassword(32)
+
+ require.NoError(t, err1)
+ require.NoError(t, err2)
+ assert.NotEqual(t, password1, password2)
+ })
+}
+
+// =============================================================================
+// TestGenerateRandomSuffix
+// =============================================================================
+
+func TestGenerateRandomSuffix(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ length int
+ }{
+ {"short suffix", 4},
+ {"medium suffix", 8},
+ {"long suffix", 16},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ suffix := generateRandomSuffix(tc.length)
+
+ assert.Equal(t, tc.length, len(suffix))
+
+ // Should only contain alphanumeric characters
+ for _, c := range suffix {
+ isValid := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
+ assert.True(t, isValid, "Character %c is not alphanumeric", c)
+ }
+ })
+ }
+
+ t.Run("suffixes are unique", func(t *testing.T) {
+ t.Parallel()
+
+ suffix1 := generateRandomSuffix(8)
+ suffix2 := generateRandomSuffix(8)
+
+ // While there's a tiny chance they could be equal, practically they should differ
+ assert.NotEqual(t, suffix1, suffix2)
+ })
+}
+
+// =============================================================================
+// TestOAuthHandler_generateUniqueUsername
+// =============================================================================
+
+func TestOAuthHandler_generateUniqueUsername(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ baseUsername string
+ setupMocks func(*oauthMockUserRepository)
+ checkResult func(*testing.T, string)
+ }{
+ {
+ name: "username not taken",
+ baseUsername: "newuser",
+ setupMocks: func(repo *oauthMockUserRepository) {
+ repo.GetByUsernameOrEmailFunc = func(_ string) (*models.User, error) {
+ return nil, gorm.ErrRecordNotFound
+ }
+ },
+ checkResult: func(t *testing.T, result string) {
+ assert.Equal(t, "newuser", result)
+ },
+ },
+ {
+ name: "username taken - generates with suffix",
+ baseUsername: "existinguser",
+ setupMocks: func(repo *oauthMockUserRepository) {
+ callCount := 0
+ repo.GetByUsernameOrEmailFunc = func(identifier string) (*models.User, error) {
+ callCount++
+ if identifier == "existinguser" {
+ return &models.User{Username: "existinguser"}, nil
+ }
+ // Other usernames are available
+ return nil, gorm.ErrRecordNotFound
+ }
+ },
+ checkResult: func(t *testing.T, result string) {
+ assert.True(t, strings.HasPrefix(result, "existinguser_"), "Username should start with 'existinguser_'")
+ assert.Greater(t, len(result), len("existinguser_"))
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, _, mockUserRepo := createTestOAuthHandler(t)
+
+ if tc.setupMocks != nil {
+ tc.setupMocks(mockUserRepo)
+ }
+
+ result := handler.generateUniqueUsername(tc.baseUsername)
+
+ if tc.checkResult != nil {
+ tc.checkResult(t, result)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// TestExtractCookieDomain
+// =============================================================================
+
+func TestExtractCookieDomain(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ rawURL string
+ expected string
+ }{
+ {
+ name: "simple domain",
+ rawURL: "https://example.com/path",
+ expected: ".example.com",
+ },
+ {
+ name: "subdomain",
+ rawURL: "https://app.example.com/path",
+ expected: ".example.com",
+ },
+ {
+ name: "deep subdomain",
+ rawURL: "https://deep.nested.example.com/path",
+ expected: ".example.com",
+ },
+ {
+ name: "localhost",
+ rawURL: "http://localhost:8080/path",
+ expected: "",
+ },
+ {
+ name: "IP address",
+ rawURL: "http://192.168.1.1:8080/path",
+ expected: "",
+ },
+ {
+ name: "empty URL",
+ rawURL: "",
+ expected: "",
+ },
+ {
+ name: "invalid URL",
+ rawURL: "not-a-url",
+ expected: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := extractCookieDomain(tc.rawURL)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// TestMockProxyRepository for OAuth Tests
+// =============================================================================
+
+// oauthMockProxyRepository is a mock implementation of ProxyRepositoryInterface for OAuth handler tests
+type oauthMockProxyRepository struct {
+ GetByHostnameFunc func(hostname string) (*models.Proxy, error)
+}
+
+func (m *oauthMockProxyRepository) Create(_ *models.Proxy) error { return nil }
+func (m *oauthMockProxyRepository) GetByID(_ int) (*models.Proxy, error) {
+ return nil, nil
+}
+func (m *oauthMockProxyRepository) GetByHostname(hostname string) (*models.Proxy, error) {
+ if m.GetByHostnameFunc != nil {
+ return m.GetByHostnameFunc(hostname)
+ }
+ return nil, gorm.ErrRecordNotFound
+}
+func (m *oauthMockProxyRepository) List(_ repository.ProxyListParams) ([]models.Proxy, int64, error) {
+ return nil, 0, nil
+}
+func (m *oauthMockProxyRepository) Update(_ *models.Proxy) error { return nil }
+func (m *oauthMockProxyRepository) Delete(_ int) error { return nil }
+func (m *oauthMockProxyRepository) UpdateStatus(_ int, _ bool) error { return nil }
+func (m *oauthMockProxyRepository) HostnameExists(_ string, _ int) (bool, error) {
+ return false, nil
+}
+func (m *oauthMockProxyRepository) GetStats() (*repository.ProxyStats, error) {
+ return nil, nil
+}
+
+var _ repository.ProxyRepositoryInterface = (*oauthMockProxyRepository)(nil)
+
+// =============================================================================
+// TestOAuthHandler_validateRedirectURL_WithProxyRepo
+// =============================================================================
+
+func TestOAuthHandler_validateRedirectURL_WithProxyRepo(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ redirectURL string
+ setupMocks func(*oauthMockProxyRepository)
+ expected string
+ }{
+ {
+ name: "redirect to configured proxy hostname",
+ redirectURL: "https://myapp.example.com/dashboard",
+ setupMocks: func(repo *oauthMockProxyRepository) {
+ repo.GetByHostnameFunc = func(hostname string) (*models.Proxy, error) {
+ if hostname == "myapp.example.com" {
+ return &models.Proxy{ID: 1, Hostname: "myapp.example.com"}, nil
+ }
+ return nil, gorm.ErrRecordNotFound
+ }
+ },
+ expected: "https://myapp.example.com/dashboard",
+ },
+ {
+ name: "redirect to non-configured hostname",
+ redirectURL: "https://unknown.example.com/path",
+ setupMocks: func(repo *oauthMockProxyRepository) {
+ repo.GetByHostnameFunc = func(_ string) (*models.Proxy, error) {
+ return nil, gorm.ErrRecordNotFound
+ }
+ },
+ expected: "/",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ mockProxyRepo := &oauthMockProxyRepository{}
+ if tc.setupMocks != nil {
+ tc.setupMocks(mockProxyRepo)
+ }
+
+ cfg := &config.Config{
+ ACL: config.ACLConfig{
+ OAuth: config.OAuthConfig{
+ CallbackBaseURL: "http://localhost:8080",
+ },
+ },
+ }
+
+ handler := NewOAuthHandler(OAuthHandlerConfig{
+ Config: cfg,
+ ProxyRepo: mockProxyRepo,
+ Logger: zap.NewNop(),
+ })
+
+ result := handler.validateRedirectURL(tc.redirectURL)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// TestOAuthHandler_StartOAuth_WithConfiguredProvider
+// =============================================================================
+
+func TestOAuthHandler_StartOAuth_WithConfiguredProvider(t *testing.T) {
+ // Skip if Google OAuth env vars aren't set (required for provider to be enabled)
+ // Note: This test requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to be set
+ // In CI/testing environments, these may not be available, so we test the unconfigured case
+
+ t.Run("unconfigured provider returns error", func(t *testing.T) {
+ t.Parallel()
+
+ // Provider manager loads from env vars - without them, providers are disabled
+ providerManager := auth.NewOAuthProviderManager()
+
+ cfg := &config.Config{
+ ACL: config.ACLConfig{
+ CookieSecure: false,
+ SessionTTL: 24 * time.Hour,
+ OAuth: config.OAuthConfig{
+ CallbackBaseURL: "http://localhost:8080",
+ },
+ },
+ }
+
+ handler := NewOAuthHandler(OAuthHandlerConfig{
+ ProviderManager: providerManager,
+ Config: cfg,
+ Logger: zap.NewNop(),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/auth/oauth/google?redirect=/dashboard", nil)
+ req = setChiURLParams(req, map[string]string{"provider": "google"})
+
+ rec := httptest.NewRecorder()
+ handler.StartOAuth(rec, req)
+
+ // Without env vars configured, should return error about provider not configured
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ assert.Contains(t, rec.Body.String(), "not configured")
+ })
+}
+
+// =============================================================================
+// TestOAuthHandler_Callback_StateMismatch
+// =============================================================================
+
+func TestOAuthHandler_Callback_StateMismatch(t *testing.T) {
+ t.Parallel()
+
+ // Provider manager loads from env vars - providers may or may not be enabled
+ // The callback should still validate state before checking provider
+ providerManager := auth.NewOAuthProviderManager()
+
+ cfg := &config.Config{
+ ACL: config.ACLConfig{
+ OAuth: config.OAuthConfig{
+ CallbackBaseURL: "http://localhost:8080",
+ },
+ },
+ }
+
+ handler := NewOAuthHandler(OAuthHandlerConfig{
+ ProviderManager: providerManager,
+ Config: cfg,
+ Logger: zap.NewNop(),
+ })
+
+ t.Run("state mismatch returns error", func(t *testing.T) {
+ t.Parallel()
+
+ req := httptest.NewRequest(http.MethodGet, "/auth/oauth/google/callback?code=testcode&state=wrong-state", nil)
+ req = setChiURLParams(req, map[string]string{"provider": "google"})
+ req.AddCookie(&http.Cookie{
+ Name: oauthStateCookieName,
+ Value: "correct-state",
+ })
+
+ rec := httptest.NewRecorder()
+ handler.Callback(rec, req)
+
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+ location := rec.Header().Get("Location")
+ assert.Contains(t, location, "oauth_error")
+ // Error could be state mismatch or provider not configured depending on order of checks
+ })
+
+ t.Run("missing state cookie returns error", func(t *testing.T) {
+ t.Parallel()
+
+ req := httptest.NewRequest(http.MethodGet, "/auth/oauth/google/callback?code=testcode&state=some-state", nil)
+ req = setChiURLParams(req, map[string]string{"provider": "google"})
+ // No cookie set
+
+ rec := httptest.NewRecorder()
+ handler.Callback(rec, req)
+
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+ location := rec.Header().Get("Location")
+ assert.Contains(t, location, "oauth_error")
+ })
+
+ t.Run("callback with error param returns error", func(t *testing.T) {
+ t.Parallel()
+
+ req := httptest.NewRequest(http.MethodGet, "/auth/oauth/google/callback?error=access_denied&error_description=User+denied", nil)
+ req = setChiURLParams(req, map[string]string{"provider": "google"})
+
+ rec := httptest.NewRecorder()
+ handler.Callback(rec, req)
+
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+ location := rec.Header().Get("Location")
+ assert.Contains(t, location, "oauth_error")
+ })
+}
+
+// =============================================================================
+// TestOAuthHandler_parseUserInfo_EdgeCases
+// =============================================================================
+
+func TestOAuthHandler_parseUserInfo_EdgeCases(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := createTestOAuthHandler(t)
+
+ t.Run("Microsoft provider with userPrincipalName fallback", func(t *testing.T) {
+ t.Parallel()
+
+ responseBody := map[string]interface{}{
+ "id": "ms-user-id",
+ "userPrincipalName": "user@domain.onmicrosoft.com", // Used when mail is empty
+ "displayName": "Microsoft User",
+ }
+
+ bodyBytes, err := json.Marshal(responseBody)
+ require.NoError(t, err)
+ bodyReader := bytes.NewReader(bodyBytes)
+
+ result, err := handler.parseUserInfo(auth.OAuthProviderMicrosoft, bodyReader)
+
+ require.NoError(t, err)
+ assert.Equal(t, "user@domain.onmicrosoft.com", result.Email)
+ assert.Equal(t, "user", result.Username)
+ })
+
+ t.Run("GitHub provider with empty email allowed", func(t *testing.T) {
+ t.Parallel()
+
+ responseBody := map[string]interface{}{
+ "id": 12345,
+ "login": "githubuser",
+ "name": "GitHub User",
+ // No email - this is allowed for GitHub
+ }
+
+ bodyBytes, err := json.Marshal(responseBody)
+ require.NoError(t, err)
+ bodyReader := bytes.NewReader(bodyBytes)
+
+ result, err := handler.parseUserInfo(auth.OAuthProviderGitHub, bodyReader)
+
+ require.NoError(t, err)
+ assert.Equal(t, "", result.Email) // Empty email is OK for GitHub
+ assert.Equal(t, "githubuser", result.Username)
+ })
+
+ t.Run("missing name uses username as fallback", func(t *testing.T) {
+ t.Parallel()
+
+ responseBody := map[string]interface{}{
+ "id": "google-id",
+ "email": "user@gmail.com",
+ // No name provided
+ }
+
+ bodyBytes, err := json.Marshal(responseBody)
+ require.NoError(t, err)
+ bodyReader := bytes.NewReader(bodyBytes)
+
+ result, err := handler.parseUserInfo(auth.OAuthProviderGoogle, bodyReader)
+
+ require.NoError(t, err)
+ assert.Equal(t, "user", result.Name) // Falls back to username derived from email
+ })
+}
diff --git a/backend/internal/api/handlers/proxy.go b/backend/internal/api/handlers/proxy.go
index 85173c0..cef8faf 100644
--- a/backend/internal/api/handlers/proxy.go
+++ b/backend/internal/api/handlers/proxy.go
@@ -495,7 +495,7 @@ func (h *ProxyHandler) DisableProxy(w http.ResponseWriter, r *http.Request) {
}
// GetStats handles GET /api/proxies/stats
-func (h *ProxyHandler) GetStats(w http.ResponseWriter, r *http.Request) {
+func (h *ProxyHandler) GetStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.service.GetStats()
if err != nil {
if h.logger != nil {
@@ -511,62 +511,62 @@ func (h *ProxyHandler) GetStats(w http.ResponseWriter, r *http.Request) {
// buildProxyChanges compares old and new proxy values and returns a map of changes.
// Each changed field is represented as {"old": oldValue, "new": newValue}.
// Returns nil if no tracked fields changed.
-func buildProxyChanges(old, new *models.Proxy) map[string]interface{} {
+func buildProxyChanges(old, updated *models.Proxy) map[string]interface{} {
changes := make(map[string]interface{})
// Track hostname changes
- if old.Hostname != new.Hostname {
+ if old.Hostname != updated.Hostname {
changes["hostname"] = map[string]interface{}{
"old": old.Hostname,
- "new": new.Hostname,
+ "new": updated.Hostname,
}
}
// Track type changes
- if old.Type != new.Type {
+ if old.Type != updated.Type {
changes["type"] = map[string]interface{}{
"old": old.Type,
- "new": new.Type,
+ "new": updated.Type,
}
}
// Track ssl_enabled changes
- if old.SSLEnabled != new.SSLEnabled {
+ if old.SSLEnabled != updated.SSLEnabled {
changes["ssl_enabled"] = map[string]interface{}{
"old": old.SSLEnabled,
- "new": new.SSLEnabled,
+ "new": updated.SSLEnabled,
}
}
// Track is_active changes
- if old.IsActive != new.IsActive {
+ if old.IsActive != updated.IsActive {
changes["is_active"] = map[string]interface{}{
"old": old.IsActive,
- "new": new.IsActive,
+ "new": updated.IsActive,
}
}
// Track name changes
- if old.Name != new.Name {
+ if old.Name != updated.Name {
changes["name"] = map[string]interface{}{
"old": old.Name,
- "new": new.Name,
+ "new": updated.Name,
}
}
// Track upstreams changes (compare JSON representation)
- if !jsonEqual(old.Upstreams, new.Upstreams) {
+ if !jsonEqual(old.Upstreams, updated.Upstreams) {
changes["upstreams"] = map[string]interface{}{
"old": old.Upstreams,
- "new": new.Upstreams,
+ "new": updated.Upstreams,
}
}
// Track redirect config changes
- if !jsonEqual(old.RedirectConfig, new.RedirectConfig) {
+ if !jsonEqual(old.RedirectConfig, updated.RedirectConfig) {
changes["redirect"] = map[string]interface{}{
"old": old.RedirectConfig,
- "new": new.RedirectConfig,
+ "new": updated.RedirectConfig,
}
}
diff --git a/backend/internal/api/handlers/proxy_acl_handler.go b/backend/internal/api/handlers/proxy_acl_handler.go
index fe55b6a..b30f71b 100644
--- a/backend/internal/api/handlers/proxy_acl_handler.go
+++ b/backend/internal/api/handlers/proxy_acl_handler.go
@@ -336,32 +336,32 @@ func (h *ProxyACLHandler) RemoveACLFromProxy(w http.ResponseWriter, r *http.Requ
utils.Success(w, nil, "ACL removed from proxy successfully")
}
-// buildProxyACLAssignmentChanges builds a map of changes between old and new proxy ACL assignment
-func buildProxyACLAssignmentChanges(old, new *models.ProxyACLAssignment) map[string]interface{} {
+// buildProxyACLAssignmentChanges builds a map of changes between old and updated proxy ACL assignment
+func buildProxyACLAssignmentChanges(old, updated *models.ProxyACLAssignment) map[string]interface{} {
changes := make(map[string]interface{})
if old == nil {
return changes
}
- if old.PathPattern != new.PathPattern {
+ if old.PathPattern != updated.PathPattern {
changes["path_pattern"] = map[string]interface{}{
"old": old.PathPattern,
- "new": new.PathPattern,
+ "new": updated.PathPattern,
}
}
- if old.Priority != new.Priority {
+ if old.Priority != updated.Priority {
changes["priority"] = map[string]interface{}{
"old": old.Priority,
- "new": new.Priority,
+ "new": updated.Priority,
}
}
- if old.Enabled != new.Enabled {
+ if old.Enabled != updated.Enabled {
changes["enabled"] = map[string]interface{}{
"old": old.Enabled,
- "new": new.Enabled,
+ "new": updated.Enabled,
}
}
diff --git a/backend/internal/api/handlers/proxy_acl_handler_integration_test.go b/backend/internal/api/handlers/proxy_acl_handler_integration_test.go
index c247ef8..5738149 100644
--- a/backend/internal/api/handlers/proxy_acl_handler_integration_test.go
+++ b/backend/internal/api/handlers/proxy_acl_handler_integration_test.go
@@ -89,7 +89,7 @@ func TestProxyACLHandler_GetProxyACL_Success(t *testing.T) {
func TestProxyACLHandler_GetProxyACL_NoAssignments(t *testing.T) {
mockService := &MockACLService{
- GetProxyACLFunc: func(proxyID int) ([]models.ProxyACLAssignment, error) {
+ GetProxyACLFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
return []models.ProxyACLAssignment{}, nil
},
}
@@ -113,7 +113,7 @@ func TestProxyACLHandler_GetProxyACL_NoAssignments(t *testing.T) {
func TestProxyACLHandler_GetProxyACL_ProxyNotFound(t *testing.T) {
mockService := &MockACLService{
- GetProxyACLFunc: func(proxyID int) ([]models.ProxyACLAssignment, error) {
+ GetProxyACLFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -143,7 +143,7 @@ func TestProxyACLHandler_GetProxyACL_InvalidProxyID(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_Success(t *testing.T) {
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, _ string, _ int) error {
return nil
},
GetProxyACLFunc: func(proxyID int) ([]models.ProxyACLAssignment, error) {
@@ -174,11 +174,11 @@ func TestProxyACLHandler_AssignACLToProxy_Success(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_DefaultPathPattern(t *testing.T) {
var capturedPathPattern string
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, pathPattern string, _ int) error {
capturedPathPattern = pathPattern
return nil
},
- GetProxyACLFunc: func(proxyID int) ([]models.ProxyACLAssignment, error) {
+ GetProxyACLFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
return []models.ProxyACLAssignment{}, nil
},
}
@@ -198,7 +198,7 @@ func TestProxyACLHandler_AssignACLToProxy_DefaultPathPattern(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_DuplicatePath(t *testing.T) {
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, _ string, _ int) error {
return service.ErrProxyACLExists
},
}
@@ -216,7 +216,7 @@ func TestProxyACLHandler_AssignACLToProxy_DuplicatePath(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_ProxyNotFound(t *testing.T) {
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, _ string, _ int) error {
return service.ErrProxyNotFound
},
}
@@ -234,7 +234,7 @@ func TestProxyACLHandler_AssignACLToProxy_ProxyNotFound(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_GroupNotFound(t *testing.T) {
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, _ string, _ int) error {
return service.ErrACLGroupNotFound
},
}
@@ -288,7 +288,7 @@ func TestProxyACLHandler_AssignACLToProxy_NegativeACLGroupID(t *testing.T) {
func TestProxyACLHandler_AssignACLToProxy_InvalidPathPattern(t *testing.T) {
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, _ string, _ int) error {
return service.ErrInvalidPathPattern
},
}
@@ -334,7 +334,7 @@ func TestProxyACLHandler_AssignACLToProxy_InvalidProxyID(t *testing.T) {
func TestProxyACLHandler_UpdateProxyACLAssignment_Success(t *testing.T) {
mockService := &MockACLService{
- UpdateProxyAssignmentFunc: func(id int, pathPattern string, priority int, enabled bool) error {
+ UpdateProxyAssignmentFunc: func(_ int, _ string, _ int, _ bool) error {
return nil
},
}
@@ -352,7 +352,7 @@ func TestProxyACLHandler_UpdateProxyACLAssignment_Success(t *testing.T) {
func TestProxyACLHandler_UpdateProxyACLAssignment_NotFound(t *testing.T) {
mockService := &MockACLService{
- UpdateProxyAssignmentFunc: func(id int, pathPattern string, priority int, enabled bool) error {
+ UpdateProxyAssignmentFunc: func(_ int, _ string, _ int, _ bool) error {
return service.ErrProxyACLNotFound
},
}
@@ -370,7 +370,7 @@ func TestProxyACLHandler_UpdateProxyACLAssignment_NotFound(t *testing.T) {
func TestProxyACLHandler_UpdateProxyACLAssignment_InvalidPathPattern(t *testing.T) {
mockService := &MockACLService{
- UpdateProxyAssignmentFunc: func(id int, pathPattern string, priority int, enabled bool) error {
+ UpdateProxyAssignmentFunc: func(_ int, _ string, _ int, _ bool) error {
return service.ErrInvalidPathPattern
},
}
@@ -389,7 +389,7 @@ func TestProxyACLHandler_UpdateProxyACLAssignment_InvalidPathPattern(t *testing.
func TestProxyACLHandler_UpdateProxyACLAssignment_DisableAssignment(t *testing.T) {
var capturedEnabled bool
mockService := &MockACLService{
- UpdateProxyAssignmentFunc: func(id int, pathPattern string, priority int, enabled bool) error {
+ UpdateProxyAssignmentFunc: func(_ int, _ string, _ int, enabled bool) error {
capturedEnabled = enabled
return nil
},
@@ -449,7 +449,7 @@ func TestProxyACLHandler_UpdateProxyACLAssignment_InvalidJSON(t *testing.T) {
func TestProxyACLHandler_RemoveACLFromProxy_Success(t *testing.T) {
mockService := &MockACLService{
- RemoveFromProxyFunc: func(proxyID, groupID int) error {
+ RemoveFromProxyFunc: func(_, _ int) error {
return nil
},
}
@@ -465,7 +465,7 @@ func TestProxyACLHandler_RemoveACLFromProxy_Success(t *testing.T) {
func TestProxyACLHandler_RemoveACLFromProxy_NotFound(t *testing.T) {
mockService := &MockACLService{
- RemoveFromProxyFunc: func(proxyID, groupID int) error {
+ RemoveFromProxyFunc: func(_, _ int) error {
return service.ErrProxyACLNotFound
},
}
@@ -679,11 +679,11 @@ func TestProxyACLHandler_LargeProxyID(t *testing.T) {
func TestProxyACLHandler_SpecialCharactersInPathPattern(t *testing.T) {
var capturedPattern string
mockService := &MockACLService{
- AssignToProxyFunc: func(proxyID, groupID int, pathPattern string, priority int) error {
+ AssignToProxyFunc: func(_, _ int, pathPattern string, _ int) error {
capturedPattern = pathPattern
return nil
},
- GetProxyACLFunc: func(proxyID int) ([]models.ProxyACLAssignment, error) {
+ GetProxyACLFunc: func(_ int) ([]models.ProxyACLAssignment, error) {
return []models.ProxyACLAssignment{}, nil
},
}
diff --git a/backend/internal/api/handlers/proxy_handler_integration_test.go b/backend/internal/api/handlers/proxy_handler_integration_test.go
index 94b6219..68e65f5 100644
--- a/backend/internal/api/handlers/proxy_handler_integration_test.go
+++ b/backend/internal/api/handlers/proxy_handler_integration_test.go
@@ -18,7 +18,7 @@ import (
func TestProxyHandler_ListProxies_Success(t *testing.T) {
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return &models.ProxyListResponse{
Items: []models.Proxy{
{ID: 1, Name: "Test Proxy", Hostname: "test.example.com", Type: models.ProxyTypeReverseProxy},
@@ -127,7 +127,7 @@ func TestProxyHandler_ListProxies_InvalidStatus(t *testing.T) {
func TestProxyHandler_ListProxies_ServiceError(t *testing.T) {
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return nil, errors.New("database error")
},
}
@@ -173,7 +173,7 @@ func TestProxyHandler_GetProxy_Success(t *testing.T) {
func TestProxyHandler_GetProxy_NotFound(t *testing.T) {
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -211,7 +211,7 @@ func TestProxyHandler_GetProxy_InvalidID(t *testing.T) {
func TestProxyHandler_DeleteProxy_Success(t *testing.T) {
mockService := &mocks.MockProxyService{
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return nil
},
}
@@ -233,7 +233,7 @@ func TestProxyHandler_DeleteProxy_Success(t *testing.T) {
func TestProxyHandler_DeleteProxy_NotFound(t *testing.T) {
mockService := &mocks.MockProxyService{
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return service.ErrProxyNotFound
},
}
@@ -255,7 +255,7 @@ func TestProxyHandler_DeleteProxy_NotFound(t *testing.T) {
func TestProxyHandler_EnableProxy_Success(t *testing.T) {
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return nil
},
}
@@ -277,7 +277,7 @@ func TestProxyHandler_EnableProxy_Success(t *testing.T) {
func TestProxyHandler_EnableProxy_AlreadyEnabled(t *testing.T) {
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return service.ErrProxyAlreadyEnabled
},
}
@@ -299,7 +299,7 @@ func TestProxyHandler_EnableProxy_AlreadyEnabled(t *testing.T) {
func TestProxyHandler_DisableProxy_Success(t *testing.T) {
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return nil
},
}
@@ -321,7 +321,7 @@ func TestProxyHandler_DisableProxy_Success(t *testing.T) {
func TestProxyHandler_DisableProxy_AlreadyDisabled(t *testing.T) {
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return service.ErrProxyAlreadyDisabled
},
}
@@ -388,7 +388,7 @@ func TestProxyHandler_UpdateProxy_Success(t *testing.T) {
GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
return &models.Proxy{ID: id, Name: "Old Name", Hostname: "old.example.com", Type: models.ProxyTypeReverseProxy, SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
@@ -412,7 +412,7 @@ func TestProxyHandler_UpdateProxy_Success(t *testing.T) {
func TestProxyHandler_UpdateProxy_NotFound(t *testing.T) {
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -439,7 +439,7 @@ func TestProxyHandler_UpdateProxy_HostnameConflict(t *testing.T) {
GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
return &models.Proxy{ID: id, Name: "Existing", Hostname: "old.example.com", Type: models.ProxyTypeReverseProxy, SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return service.ErrHostnameConflict
},
}
diff --git a/backend/internal/api/handlers/proxy_handler_test.go b/backend/internal/api/handlers/proxy_handler_test.go
index 2dec62f..b6e959c 100644
--- a/backend/internal/api/handlers/proxy_handler_test.go
+++ b/backend/internal/api/handlers/proxy_handler_test.go
@@ -9,12 +9,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/aloks98/goauth/middleware"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/aloks98/goauth/middleware"
-
"github.com/aloks98/waygates/backend/internal/models"
"github.com/aloks98/waygates/backend/internal/repository"
"github.com/aloks98/waygates/backend/internal/service"
@@ -57,7 +56,7 @@ func TestNewProxyHandler(t *testing.T) {
func TestListProxies_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return &models.ProxyListResponse{
Items: []models.Proxy{
{ID: 1, Name: "Proxy 1", Hostname: "proxy1.example.com"},
@@ -142,7 +141,6 @@ func TestListProxies_ValidationErrors(t *testing.T) {
}
for _, tc := range testCases {
- tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{}
@@ -161,7 +159,7 @@ func TestListProxies_ValidationErrors(t *testing.T) {
func TestListProxies_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return nil, errors.New("database error")
},
}
@@ -182,7 +180,7 @@ func TestListProxies_ServiceError(t *testing.T) {
func TestGetProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
}
@@ -232,7 +230,7 @@ func TestGetProxy_InvalidID(t *testing.T) {
func TestGetProxy_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -252,7 +250,7 @@ func TestGetProxy_NotFound(t *testing.T) {
func TestGetProxy_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, errors.New("database error")
},
}
@@ -276,7 +274,7 @@ func TestGetProxy_ServiceError(t *testing.T) {
func TestCreateProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
proxy.ID = 1
return nil
},
@@ -357,7 +355,7 @@ func TestCreateProxy_InvalidJSON(t *testing.T) {
func TestCreateProxy_HostnameConflict(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(_ *models.Proxy, _ int) error {
return service.ErrHostnameConflict
},
}
@@ -378,7 +376,7 @@ func TestCreateProxy_HostnameConflict(t *testing.T) {
func TestCreateProxy_CaddyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(_ *models.Proxy, _ int) error {
return service.NewCaddyError("caddy validation failed")
},
}
@@ -399,7 +397,7 @@ func TestCreateProxy_CaddyError(t *testing.T) {
func TestCreateProxy_ValidationError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(_ *models.Proxy, _ int) error {
return errors.New("validation: hostname is required")
},
}
@@ -423,10 +421,10 @@ func TestCreateProxy_ValidationError(t *testing.T) {
func TestUpdateProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Old Name", Hostname: "old.example.com", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
@@ -495,7 +493,7 @@ func TestUpdateProxy_InvalidJSON(t *testing.T) {
func TestUpdateProxy_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -517,10 +515,10 @@ func TestUpdateProxy_NotFound(t *testing.T) {
func TestUpdateProxy_HostnameConflict(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Existing", Hostname: "old.example.com", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return service.ErrHostnameConflict
},
}
@@ -542,10 +540,10 @@ func TestUpdateProxy_HostnameConflict(t *testing.T) {
func TestUpdateProxy_CaddyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Existing", Hostname: "test.example.com", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return service.NewCaddyError("caddy reload failed")
},
}
@@ -567,10 +565,10 @@ func TestUpdateProxy_CaddyError(t *testing.T) {
func TestUpdateProxy_ValidationError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Existing", Hostname: "test.example.com", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return errors.New("validation: invalid hostname format")
},
}
@@ -596,7 +594,7 @@ func TestUpdateProxy_ValidationError(t *testing.T) {
func TestDeleteProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return nil
},
}
@@ -632,7 +630,7 @@ func TestDeleteProxy_InvalidID(t *testing.T) {
func TestDeleteProxy_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return service.ErrProxyNotFound
},
}
@@ -652,7 +650,7 @@ func TestDeleteProxy_NotFound(t *testing.T) {
func TestDeleteProxy_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return errors.New("database error")
},
}
@@ -676,7 +674,7 @@ func TestDeleteProxy_ServiceError(t *testing.T) {
func TestEnableProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return nil
},
}
@@ -712,7 +710,7 @@ func TestEnableProxy_InvalidID(t *testing.T) {
func TestEnableProxy_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return service.ErrProxyNotFound
},
}
@@ -732,7 +730,7 @@ func TestEnableProxy_NotFound(t *testing.T) {
func TestEnableProxy_AlreadyEnabled(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return service.ErrProxyAlreadyEnabled
},
}
@@ -752,7 +750,7 @@ func TestEnableProxy_AlreadyEnabled(t *testing.T) {
func TestEnableProxy_CaddyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return service.NewCaddyError("caddy error")
},
}
@@ -772,7 +770,7 @@ func TestEnableProxy_CaddyError(t *testing.T) {
func TestEnableProxy_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return errors.New("unknown error")
},
}
@@ -796,7 +794,7 @@ func TestEnableProxy_ServiceError(t *testing.T) {
func TestDisableProxy_Success(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return nil
},
}
@@ -832,7 +830,7 @@ func TestDisableProxy_InvalidID(t *testing.T) {
func TestDisableProxy_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return service.ErrProxyNotFound
},
}
@@ -852,7 +850,7 @@ func TestDisableProxy_NotFound(t *testing.T) {
func TestDisableProxy_AlreadyDisabled(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return service.ErrProxyAlreadyDisabled
},
}
@@ -872,7 +870,7 @@ func TestDisableProxy_AlreadyDisabled(t *testing.T) {
func TestDisableProxy_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return errors.New("unknown error")
},
}
@@ -1243,10 +1241,10 @@ func TestListProxies_TypeNotOperator(t *testing.T) {
func TestUpdateProxy_WithoutSSLEnabled_FetchExisting(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Existing", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, proxy *models.Proxy) error {
assert.True(t, proxy.SSLEnabled, "SSLEnabled should be preserved from existing proxy")
return nil
},
@@ -1272,7 +1270,7 @@ func TestUpdateProxy_WithoutSSLEnabled_FetchExisting(t *testing.T) {
func TestUpdateProxy_WithoutSSLEnabled_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, service.ErrProxyNotFound
},
}
@@ -1297,7 +1295,7 @@ func TestUpdateProxy_WithoutSSLEnabled_NotFound(t *testing.T) {
func TestUpdateProxy_WithoutSSLEnabled_GetError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, errors.New("database error")
},
}
@@ -1327,7 +1325,7 @@ func TestCreateProxy_SSLEnabledExplicitlyFalse(t *testing.T) {
t.Parallel()
var capturedProxy *models.Proxy
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
capturedProxy = proxy
proxy.ID = 1
return nil
@@ -1354,7 +1352,7 @@ func TestCreateProxy_SSLEnabledDefault(t *testing.T) {
t.Parallel()
var capturedProxy *models.Proxy
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
capturedProxy = proxy
proxy.ID = 1
return nil
@@ -1384,13 +1382,13 @@ func TestCreateProxy_WithAuditService(t *testing.T) {
t.Parallel()
auditCalled := false
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
proxy.ID = 1
return nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyCreateFunc: func(ctx context.Context, userID int, proxy *models.Proxy, ip, userAgent string) error {
+ LogProxyCreateFunc: func(_ context.Context, userID int, _ *models.Proxy, _, _ string) error {
auditCalled = true
assert.Equal(t, 123, userID)
return nil
@@ -1417,15 +1415,15 @@ func TestUpdateProxy_WithAuditService(t *testing.T) {
t.Parallel()
auditCalled := false
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Old Name", Hostname: "old.example.com", SSLEnabled: false}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyUpdateFunc: func(ctx context.Context, userID int, proxy *models.Proxy, changes map[string]interface{}, ip, userAgent string) error {
+ LogProxyUpdateFunc: func(_ context.Context, userID int, _ *models.Proxy, changes map[string]interface{}, _, _ string) error {
auditCalled = true
assert.Equal(t, 123, userID)
// Verify changes were captured
@@ -1456,15 +1454,15 @@ func TestDeleteProxy_WithAuditService(t *testing.T) {
t.Parallel()
auditCalled := false
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyDeleteFunc: func(ctx context.Context, userID int, proxyID int, proxyName, hostname string, ip, userAgent string) error {
+ LogProxyDeleteFunc: func(_ context.Context, userID int, _ int, proxyName, hostname string, _, _ string) error {
auditCalled = true
assert.Equal(t, 123, userID)
assert.Equal(t, "Test Proxy", proxyName)
@@ -1489,10 +1487,10 @@ func TestDeleteProxy_WithAuditService(t *testing.T) {
func TestDeleteProxy_WithAuditService_GetProxyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, errors.New("proxy not found for audit")
},
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return nil
},
}
@@ -1514,15 +1512,15 @@ func TestEnableProxy_WithAuditService(t *testing.T) {
t.Parallel()
auditCalled := false
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyEnableFunc: func(ctx context.Context, userID int, proxy *models.Proxy, ip, userAgent string) error {
+ LogProxyEnableFunc: func(_ context.Context, userID int, _ *models.Proxy, _, _ string) error {
auditCalled = true
assert.Equal(t, 123, userID)
return nil
@@ -1545,10 +1543,10 @@ func TestEnableProxy_WithAuditService(t *testing.T) {
func TestEnableProxy_WithAuditService_GetProxyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, errors.New("proxy not found for audit")
},
}
@@ -1570,15 +1568,15 @@ func TestDisableProxy_WithAuditService(t *testing.T) {
t.Parallel()
auditCalled := false
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyDisableFunc: func(ctx context.Context, userID int, proxy *models.Proxy, ip, userAgent string) error {
+ LogProxyDisableFunc: func(_ context.Context, userID int, _ *models.Proxy, _, _ string) error {
auditCalled = true
assert.Equal(t, 123, userID)
return nil
@@ -1601,10 +1599,10 @@ func TestDisableProxy_WithAuditService(t *testing.T) {
func TestDisableProxy_WithAuditService_GetProxyError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return nil, errors.New("proxy not found for audit")
},
}
@@ -1629,16 +1627,16 @@ func TestDisableProxy_WithAuditService_GetProxyError(t *testing.T) {
func TestUpdateProxy_WithoutUserID_NoAudit(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Existing", Hostname: "test.example.com", SSLEnabled: true}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
auditCalled := false
mockAuditService := &mocks.MockAuditService{
- LogProxyUpdateFunc: func(ctx context.Context, userID int, proxy *models.Proxy, changes map[string]interface{}, ip, userAgent string) error {
+ LogProxyUpdateFunc: func(_ context.Context, _ int, _ *models.Proxy, _ map[string]interface{}, _, _ string) error {
auditCalled = true
return nil
},
@@ -1666,16 +1664,16 @@ func TestUpdateProxy_WithoutUserID_NoAudit(t *testing.T) {
func TestDeleteProxy_WithoutUserID_NoAudit(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
- DeleteProxyFunc: func(id int) error {
+ DeleteProxyFunc: func(_ int) error {
return nil
},
}
auditCalled := false
mockAuditService := &mocks.MockAuditService{
- LogProxyDeleteFunc: func(ctx context.Context, userID int, proxyID int, proxyName, hostname string, ip, userAgent string) error {
+ LogProxyDeleteFunc: func(_ context.Context, _ int, _ int, _, _ string, _, _ string) error {
auditCalled = true
return nil
},
@@ -1697,16 +1695,16 @@ func TestDeleteProxy_WithoutUserID_NoAudit(t *testing.T) {
func TestEnableProxy_WithoutUserID_NoAudit(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- EnableProxyFunc: func(id int) error {
+ EnableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy"}, nil
},
}
auditCalled := false
mockAuditService := &mocks.MockAuditService{
- LogProxyEnableFunc: func(ctx context.Context, userID int, proxy *models.Proxy, ip, userAgent string) error {
+ LogProxyEnableFunc: func(_ context.Context, _ int, _ *models.Proxy, _, _ string) error {
auditCalled = true
return nil
},
@@ -1728,16 +1726,16 @@ func TestEnableProxy_WithoutUserID_NoAudit(t *testing.T) {
func TestDisableProxy_WithoutUserID_NoAudit(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- DisableProxyFunc: func(id int) error {
+ DisableProxyFunc: func(_ int) error {
return nil
},
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy"}, nil
},
}
auditCalled := false
mockAuditService := &mocks.MockAuditService{
- LogProxyDisableFunc: func(ctx context.Context, userID int, proxy *models.Proxy, ip, userAgent string) error {
+ LogProxyDisableFunc: func(_ context.Context, _ int, _ *models.Proxy, _, _ string) error {
auditCalled = true
return nil
},
@@ -1770,7 +1768,7 @@ func TestBuildProxyChanges_NoChanges(t *testing.T) {
SSLEnabled: true,
IsActive: true,
}
- new := &models.Proxy{
+ updated := &models.Proxy{
ID: 1,
Name: "Test Proxy",
Hostname: "test.example.com",
@@ -1779,7 +1777,7 @@ func TestBuildProxyChanges_NoChanges(t *testing.T) {
IsActive: true,
}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
assert.Nil(t, changes, "should return nil when no changes")
}
@@ -1787,9 +1785,9 @@ func TestBuildProxyChanges_NoChanges(t *testing.T) {
func TestBuildProxyChanges_HostnameChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{Hostname: "old.example.com"}
- new := &models.Proxy{Hostname: "new.example.com"}
+ updated := &models.Proxy{Hostname: "new.example.com"}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "hostname")
@@ -1801,9 +1799,9 @@ func TestBuildProxyChanges_HostnameChange(t *testing.T) {
func TestBuildProxyChanges_NameChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{Name: "Old Name"}
- new := &models.Proxy{Name: "New Name"}
+ updated := &models.Proxy{Name: "New Name"}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "name")
@@ -1815,9 +1813,9 @@ func TestBuildProxyChanges_NameChange(t *testing.T) {
func TestBuildProxyChanges_TypeChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{Type: "reverse_proxy"}
- new := &models.Proxy{Type: "redirect"}
+ updated := &models.Proxy{Type: "redirect"}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "type")
@@ -1829,9 +1827,9 @@ func TestBuildProxyChanges_TypeChange(t *testing.T) {
func TestBuildProxyChanges_SSLEnabledChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{SSLEnabled: true}
- new := &models.Proxy{SSLEnabled: false}
+ updated := &models.Proxy{SSLEnabled: false}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "ssl_enabled")
@@ -1843,9 +1841,9 @@ func TestBuildProxyChanges_SSLEnabledChange(t *testing.T) {
func TestBuildProxyChanges_IsActiveChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{IsActive: true}
- new := &models.Proxy{IsActive: false}
+ updated := &models.Proxy{IsActive: false}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "is_active")
@@ -1857,9 +1855,9 @@ func TestBuildProxyChanges_IsActiveChange(t *testing.T) {
func TestBuildProxyChanges_UpstreamsChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{Upstreams: []interface{}{"http://localhost:8080"}}
- new := &models.Proxy{Upstreams: []interface{}{"http://localhost:9090"}}
+ updated := &models.Proxy{Upstreams: []interface{}{"http://localhost:9090"}}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "upstreams")
@@ -1868,9 +1866,9 @@ func TestBuildProxyChanges_UpstreamsChange(t *testing.T) {
func TestBuildProxyChanges_RedirectChange(t *testing.T) {
t.Parallel()
old := &models.Proxy{RedirectConfig: models.JSONField{"url": "https://old.example.com"}}
- new := &models.Proxy{RedirectConfig: models.JSONField{"url": "https://new.example.com"}}
+ updated := &models.Proxy{RedirectConfig: models.JSONField{"url": "https://new.example.com"}}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
require.Contains(t, changes, "redirect")
@@ -1883,13 +1881,13 @@ func TestBuildProxyChanges_MultipleChanges(t *testing.T) {
Hostname: "old.example.com",
SSLEnabled: true,
}
- new := &models.Proxy{
+ updated := &models.Proxy{
Name: "New Name",
Hostname: "new.example.com",
SSLEnabled: false,
}
- changes := buildProxyChanges(old, new)
+ changes := buildProxyChanges(old, updated)
require.NotNil(t, changes)
assert.Len(t, changes, 3, "should have 3 changes")
@@ -1902,7 +1900,7 @@ func TestUpdateProxy_WithAuditService_ChangesTracked(t *testing.T) {
t.Parallel()
var capturedChanges map[string]interface{}
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{
ID: 1,
Name: "Old Name",
@@ -1912,12 +1910,12 @@ func TestUpdateProxy_WithAuditService_ChangesTracked(t *testing.T) {
IsActive: true,
}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyUpdateFunc: func(ctx context.Context, userID int, proxy *models.Proxy, changes map[string]interface{}, ip, userAgent string) error {
+ LogProxyUpdateFunc: func(_ context.Context, _ int, _ *models.Proxy, changes map[string]interface{}, _, _ string) error {
capturedChanges = changes
return nil
},
@@ -1966,7 +1964,7 @@ func TestUpdateProxy_WithAuditService_NoChanges(t *testing.T) {
t.Parallel()
var capturedChanges map[string]interface{}
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{
ID: 1,
Name: "Same Name",
@@ -1975,12 +1973,12 @@ func TestUpdateProxy_WithAuditService_NoChanges(t *testing.T) {
SSLEnabled: true,
}, nil
},
- UpdateProxyFunc: func(id int, proxy *models.Proxy) error {
+ UpdateProxyFunc: func(_ int, _ *models.Proxy) error {
return nil
},
}
mockAuditService := &mocks.MockAuditService{
- LogProxyUpdateFunc: func(ctx context.Context, userID int, proxy *models.Proxy, changes map[string]interface{}, ip, userAgent string) error {
+ LogProxyUpdateFunc: func(_ context.Context, _ int, _ *models.Proxy, changes map[string]interface{}, _, _ string) error {
capturedChanges = changes
return nil
},
@@ -2052,7 +2050,7 @@ func TestJsonEqual_Maps(t *testing.T) {
func BenchmarkListProxies(b *testing.B) {
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return &models.ProxyListResponse{
Items: []models.Proxy{
{ID: 1, Name: "Proxy 1", Hostname: "proxy1.example.com"},
@@ -2074,7 +2072,7 @@ func BenchmarkListProxies(b *testing.B) {
func BenchmarkListProxies_WithFilters(b *testing.B) {
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return &models.ProxyListResponse{Items: []models.Proxy{}, Total: 0}, nil
},
}
@@ -2090,7 +2088,7 @@ func BenchmarkListProxies_WithFilters(b *testing.B) {
func BenchmarkGetProxy(b *testing.B) {
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test Proxy", Hostname: "test.example.com"}, nil
},
}
@@ -2109,7 +2107,7 @@ func BenchmarkGetProxy(b *testing.B) {
func BenchmarkCreateProxy(b *testing.B) {
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
proxy.ID = 1
return nil
},
@@ -2157,7 +2155,7 @@ func BenchmarkGetStats(b *testing.B) {
func TestListProxies_ContextCancellation(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- ListProxiesFunc: func(req service.ListProxiesRequest) (*models.ProxyListResponse, error) {
+ ListProxiesFunc: func(_ service.ListProxiesRequest) (*models.ProxyListResponse, error) {
return &models.ProxyListResponse{Items: []models.Proxy{}, Total: 0}, nil
},
}
@@ -2179,7 +2177,7 @@ func TestListProxies_ContextCancellation(t *testing.T) {
func TestGetProxy_ContextCancellation(t *testing.T) {
t.Parallel()
mockService := &mocks.MockProxyService{
- GetProxyByIDFunc: func(id int) (*models.Proxy, error) {
+ GetProxyByIDFunc: func(_ int) (*models.Proxy, error) {
return &models.Proxy{ID: 1, Name: "Test", Hostname: "test.example.com"}, nil
},
}
@@ -2204,7 +2202,7 @@ func TestCreateProxy_ContextCancellation(t *testing.T) {
t.Parallel()
createCalled := false
mockService := &mocks.MockProxyService{
- CreateProxyFunc: func(proxy *models.Proxy, userID int) error {
+ CreateProxyFunc: func(proxy *models.Proxy, _ int) error {
createCalled = true
proxy.ID = 1
return nil
diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go
index fe8fdb0..92df80f 100644
--- a/backend/internal/api/handlers/settings_handler.go
+++ b/backend/internal/api/handlers/settings_handler.go
@@ -31,7 +31,7 @@ func NewSettingsHandler(settingsService service.SettingsServiceInterface, auditS
}
// GetAll returns all settings as a key-value map
-func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
+func (h *SettingsHandler) GetAll(w http.ResponseWriter, _ *http.Request) {
settings, err := h.settingsService.GetAll()
if err != nil {
if h.logger != nil {
@@ -111,7 +111,7 @@ func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) {
}
// GetNotFound returns the 404 page configuration
-func (h *SettingsHandler) GetNotFound(w http.ResponseWriter, r *http.Request) {
+func (h *SettingsHandler) GetNotFound(w http.ResponseWriter, _ *http.Request) {
settings, err := h.settingsService.GetNotFoundSettings()
if err != nil {
if h.logger != nil {
diff --git a/backend/internal/api/handlers/settings_handler_integration_test.go b/backend/internal/api/handlers/settings_handler_integration_test.go
index b90a520..7c3876d 100644
--- a/backend/internal/api/handlers/settings_handler_integration_test.go
+++ b/backend/internal/api/handlers/settings_handler_integration_test.go
@@ -66,7 +66,7 @@ func TestSettingsHandler_GetAll_Error(t *testing.T) {
func TestSettingsHandler_Get_Success(t *testing.T) {
mockService := &mocks.MockSettingsService{
- GetFunc: func(key string) (string, error) {
+ GetFunc: func(_ string) (string, error) {
return "test_value", nil
},
}
@@ -88,7 +88,7 @@ func TestSettingsHandler_Get_Success(t *testing.T) {
func TestSettingsHandler_Get_NotFound(t *testing.T) {
mockService := &mocks.MockSettingsService{
- GetFunc: func(key string) (string, error) {
+ GetFunc: func(_ string) (string, error) {
return "", errors.New("not found")
},
}
@@ -110,7 +110,7 @@ func TestSettingsHandler_Get_NotFound(t *testing.T) {
func TestSettingsHandler_Update_Success(t *testing.T) {
mockService := &mocks.MockSettingsService{
- SetFunc: func(key, value string) error {
+ SetFunc: func(_, _ string) error {
return nil
},
}
@@ -151,7 +151,7 @@ func TestSettingsHandler_Update_InvalidBody(t *testing.T) {
func TestSettingsHandler_Update_Error(t *testing.T) {
mockService := &mocks.MockSettingsService{
- SetFunc: func(key, value string) error {
+ SetFunc: func(_, _ string) error {
return errors.New("database error")
},
}
@@ -216,7 +216,7 @@ func TestSettingsHandler_GetNotFound_Error(t *testing.T) {
func TestSettingsHandler_UpdateNotFound_Success(t *testing.T) {
mockService := &mocks.MockSettingsService{
- SetNotFoundSettingsFunc: func(settings *models.NotFoundSettings) error {
+ SetNotFoundSettingsFunc: func(_ *models.NotFoundSettings) error {
return nil
},
}
@@ -267,7 +267,7 @@ func TestSettingsHandler_UpdateNotFound_RedirectWithoutURL(t *testing.T) {
func TestSettingsHandler_UpdateNotFound_Error(t *testing.T) {
mockService := &mocks.MockSettingsService{
- SetNotFoundSettingsFunc: func(settings *models.NotFoundSettings) error {
+ SetNotFoundSettingsFunc: func(_ *models.NotFoundSettings) error {
return errors.New("database error")
},
}
diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go
index a8e2dde..22c711e 100644
--- a/backend/internal/api/handlers/settings_handler_test.go
+++ b/backend/internal/api/handlers/settings_handler_test.go
@@ -138,7 +138,7 @@ func TestSettingsHandler_Unit_Get_Success(t *testing.T) {
func TestSettingsHandler_Unit_Get_NotFound(t *testing.T) {
t.Parallel()
mockService := &mocks.MockSettingsService{
- GetFunc: func(key string) (string, error) {
+ GetFunc: func(_ string) (string, error) {
return "", errors.New("setting not found")
},
}
@@ -236,7 +236,7 @@ func TestSettingsHandler_Update_InvalidJSON(t *testing.T) {
func TestSettingsHandler_Update_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockSettingsService{
- SetFunc: func(key, value string) error {
+ SetFunc: func(_, _ string) error {
return errors.New("database error")
},
}
@@ -259,7 +259,7 @@ func TestSettingsHandler_Update_EmptyValue(t *testing.T) {
t.Parallel()
var capturedValue string
mockService := &mocks.MockSettingsService{
- SetFunc: func(key, value string) error {
+ SetFunc: func(_, value string) error {
capturedValue = value
return nil
},
@@ -466,7 +466,7 @@ func TestSettingsHandler_Unit_UpdateNotFound_RedirectWithoutURL(t *testing.T) {
func TestSettingsHandler_UpdateNotFound_ServiceError(t *testing.T) {
t.Parallel()
mockService := &mocks.MockSettingsService{
- SetNotFoundSettingsFunc: func(settings *models.NotFoundSettings) error {
+ SetNotFoundSettingsFunc: func(_ *models.NotFoundSettings) error {
return errors.New("database error")
},
}
diff --git a/backend/internal/api/handlers/status_test.go b/backend/internal/api/handlers/status_test.go
index a6e15b0..4bcafb0 100644
--- a/backend/internal/api/handlers/status_test.go
+++ b/backend/internal/api/handlers/status_test.go
@@ -37,7 +37,7 @@ func TestNewStatusHandler(t *testing.T) {
func TestStatusHandler_GetStatus_AllHealthy(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
@@ -72,7 +72,7 @@ func TestStatusHandler_GetStatus_AllHealthy(t *testing.T) {
func TestStatusHandler_GetStatus_CaddyUnhealthy(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return errors.New("connection refused")
},
}
@@ -103,7 +103,7 @@ func TestStatusHandler_GetStatus_CaddyUnhealthy(t *testing.T) {
func TestStatusHandler_GetStatus_NoUsers(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
@@ -134,7 +134,7 @@ func TestStatusHandler_GetStatus_NoUsers(t *testing.T) {
func TestStatusHandler_GetStatus_UserCountError(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
@@ -156,7 +156,7 @@ func TestStatusHandler_GetStatus_UserCountError(t *testing.T) {
func TestStatusHandler_GetStatus_BothUnhealthy(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return errors.New("caddy not running")
},
}
@@ -189,7 +189,7 @@ func TestStatusHandler_GetStatus_BothUnhealthy(t *testing.T) {
func TestStatusHandler_GetStatus_ResponseFormat(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
@@ -227,7 +227,7 @@ func TestStatusHandler_GetStatus_ResponseFormat(t *testing.T) {
func TestStatusHandler_GetStatus_SuccessMessage(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
@@ -292,7 +292,7 @@ func TestStatusHandler_GetStatus_CaddyTimeout(t *testing.T) {
func TestStatusHandler_GetStatus_ManyUsers(t *testing.T) {
t.Parallel()
mockReloader := &mocks.MockReloader{
- TestConnectionFunc: func(ctx context.Context) error {
+ TestConnectionFunc: func(_ context.Context) error {
return nil
},
}
diff --git a/backend/internal/api/handlers/sync_handler.go b/backend/internal/api/handlers/sync_handler.go
index 5a635ae..a8fe23c 100644
--- a/backend/internal/api/handlers/sync_handler.go
+++ b/backend/internal/api/handlers/sync_handler.go
@@ -24,13 +24,13 @@ func NewSyncHandler(syncService service.SyncServiceInterface, logger *zap.Logger
}
// GetStatus returns the current sync status
-func (h *SyncHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
+func (h *SyncHandler) GetStatus(w http.ResponseWriter, _ *http.Request) {
status := h.syncService.GetStatus()
utils.Success(w, status, "Sync status retrieved successfully")
}
// Trigger manually triggers a full sync
-func (h *SyncHandler) Trigger(w http.ResponseWriter, r *http.Request) {
+func (h *SyncHandler) Trigger(w http.ResponseWriter, _ *http.Request) {
if err := h.syncService.FullSync(); err != nil {
if h.logger != nil {
h.logger.Error("Manual sync trigger failed", zap.Error(err))
diff --git a/backend/internal/api/middleware/bodylimit_test.go b/backend/internal/api/middleware/bodylimit_test.go
index 04bcf6f..323cc6d 100644
--- a/backend/internal/api/middleware/bodylimit_test.go
+++ b/backend/internal/api/middleware/bodylimit_test.go
@@ -11,7 +11,7 @@ import (
func TestBodyLimit_ContentLengthExceeded(t *testing.T) {
t.Parallel()
- handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -34,7 +34,7 @@ func TestBodyLimit_ContentLengthExceeded(t *testing.T) {
func TestBodyLimit_ContentLengthWithinLimit(t *testing.T) {
t.Parallel()
nextCalled := false
- handler := BodyLimit(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(1024)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
@@ -57,7 +57,7 @@ func TestBodyLimit_ContentLengthWithinLimit(t *testing.T) {
func TestBodyLimit_NoBody(t *testing.T) {
t.Parallel()
nextCalled := false
- handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
@@ -78,7 +78,7 @@ func TestBodyLimit_NoBody(t *testing.T) {
func TestBodyLimit_ZeroContentLength(t *testing.T) {
t.Parallel()
nextCalled := false
- handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
@@ -141,7 +141,7 @@ func TestBodyLimit_ExactLimit(t *testing.T) {
t.Parallel()
// Test body exactly at the limit
nextCalled := false
- handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
@@ -163,7 +163,7 @@ func TestBodyLimit_ExactLimit(t *testing.T) {
func TestBodyLimit_OneOverLimit(t *testing.T) {
t.Parallel()
- handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := BodyLimit(10)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index 7c5d7d4..a766c53 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -20,7 +20,6 @@ import (
"github.com/aloks98/waygates/backend/internal/api/middleware"
"github.com/aloks98/waygates/backend/internal/auth"
"github.com/aloks98/waygates/backend/internal/caddy"
- "github.com/aloks98/waygates/backend/internal/caddy/caddyfile"
"github.com/aloks98/waygates/backend/internal/config"
"github.com/aloks98/waygates/backend/internal/repository"
"github.com/aloks98/waygates/backend/internal/service"
@@ -60,21 +59,14 @@ func SetupRoutes(cfg *config.Config, db *gorm.DB, logger *zap.Logger, goauthInst
r.Use(chiMiddleware.Timeout(60 * time.Second))
r.Use(middleware.BodyLimit(middleware.DefaultBodyLimit)) // 1MB body limit
- // Initialize Caddy file-based components
+ // Initialize Caddy components
// Use environment variables if set (for testing), otherwise use Docker defaults
caddyBasePath := getEnvOrDefault("CADDY_BASE_PATH", "/etc/caddy")
- caddyfilePath := getEnvOrDefault("CADDY_CADDYFILE_PATH", "/etc/caddy/Caddyfile")
caddyBinary := getEnvOrDefault("CADDY_BINARY", "caddy")
- caddyBuilder := caddyfile.NewBuilderWithOptions(caddyfile.BuilderOptions{
- Logger: logger,
- WaygatesVerifyURL: cfg.ACL.WaygatesVerifyURL,
- WaygatesLoginURL: cfg.ACL.WaygatesLoginURL,
- })
caddyFileManager := caddy.NewFileManager(caddyBasePath, logger)
caddyReloader := caddy.NewReloader(caddy.ReloaderConfig{
- CaddyBinary: caddyBinary,
- CaddyfilePath: caddyfilePath,
+ CaddyBinary: caddyBinary,
}, logger)
// Repositories
@@ -89,15 +81,18 @@ func SetupRoutes(cfg *config.Config, db *gorm.DB, logger *zap.Logger, goauthInst
// Services - SyncService must be created first as ProxyService depends on it
syncService := service.NewSyncService(service.SyncServiceConfig{
- ProxyRepo: proxyRepo,
- SettingsRepo: settingsRepo,
- ACLRepo: aclRepo,
- Builder: caddyBuilder,
- FileManager: caddyFileManager,
- Reloader: caddyReloader,
- Logger: logger,
- Email: cfg.Caddy.Email,
- ACMEProvider: cfg.Caddy.ACMEProvider,
+ ProxyRepo: proxyRepo,
+ SettingsRepo: settingsRepo,
+ ACLRepo: aclRepo,
+ FileManager: caddyFileManager,
+ Reloader: caddyReloader,
+ Logger: logger,
+ Email: cfg.Caddy.Email,
+ ACMEProvider: cfg.Caddy.ACMEProvider,
+ WaygatesVerifyURL: cfg.ACL.WaygatesVerifyURL,
+ WaygatesLoginURL: cfg.ACL.WaygatesLoginURL,
+ StoragePath: cfg.Caddy.StoragePath,
+ ConfigRetentionDays: cfg.Caddy.ConfigRetentionDays,
})
proxyService := service.NewProxyService(service.ProxyServiceConfig{
@@ -110,9 +105,10 @@ func SetupRoutes(cfg *config.Config, db *gorm.DB, logger *zap.Logger, goauthInst
auditService := service.NewAuditService(auditLogRepo, settingsService, logger)
aclService := service.NewACLService(service.ACLServiceConfig{
- ACLRepo: aclRepo,
- ProxyRepo: proxyRepo,
- Logger: logger,
+ ACLRepo: aclRepo,
+ ProxyRepo: proxyRepo,
+ OAuthChecker: auth.NewOAuthCheckerAdapter(oauthProviderManager),
+ Logger: logger,
})
// Ensure Caddy directories exist
diff --git a/backend/internal/auth/goauth.go b/backend/internal/auth/goauth.go
index 5998e11..d6a55ad 100644
--- a/backend/internal/auth/goauth.go
+++ b/backend/internal/auth/goauth.go
@@ -93,7 +93,7 @@ func (a *Adapter) ExtractUserID(claims interface{}) string {
}
// ExtractPermissions implements middleware.ClaimsExtractor
-func (a *Adapter) ExtractPermissions(claims interface{}) []string {
+func (a *Adapter) ExtractPermissions(_ interface{}) []string {
// Permissions are checked via RBAC, not stored in token
return nil
}
@@ -128,7 +128,7 @@ func (a *Adapter) ValidateAPIKey(ctx context.Context, rawKey string) (*middlewar
// ErrorHandler returns a custom error handler that uses our response utilities
func ErrorHandler() middleware.ErrorHandler {
- return func(w http.ResponseWriter, r *http.Request, err error) {
+ return func(w http.ResponseWriter, _ *http.Request, err error) {
code := middleware.ErrorToHTTPStatus(err)
switch code {
case http.StatusUnauthorized:
diff --git a/backend/internal/auth/goauth_test.go b/backend/internal/auth/goauth_test.go
index 1bde08f..c972383 100644
--- a/backend/internal/auth/goauth_test.go
+++ b/backend/internal/auth/goauth_test.go
@@ -160,7 +160,7 @@ func TestSetAuth(t *testing.T) {
}
}
-func TestAdapter_ImplementsInterfaces(t *testing.T) {
+func TestAdapter_ImplementsInterfaces(_ *testing.T) {
// Compile-time check that Adapter implements required interfaces
var _ middleware.TokenValidator = (*Adapter)(nil)
var _ middleware.ClaimsExtractor = (*Adapter)(nil)
diff --git a/backend/internal/auth/oauth_providers.go b/backend/internal/auth/oauth_providers.go
index 3bd2d19..23e27ae 100644
--- a/backend/internal/auth/oauth_providers.go
+++ b/backend/internal/auth/oauth_providers.go
@@ -236,6 +236,22 @@ func (m *OAuthProviderManager) IsAvailable(id OAuthProviderID) bool {
return ok && p.Enabled // Enabled means env vars are present
}
+// OAuthCheckerAdapter wraps OAuthProviderManager to satisfy the OAuthProviderChecker interface
+type OAuthCheckerAdapter struct {
+ manager *OAuthProviderManager
+}
+
+// NewOAuthCheckerAdapter creates an adapter that wraps OAuthProviderManager
+func NewOAuthCheckerAdapter(manager *OAuthProviderManager) *OAuthCheckerAdapter {
+ return &OAuthCheckerAdapter{manager: manager}
+}
+
+// IsAvailable checks if a provider has env vars configured (string version)
+// This method satisfies the service.OAuthProviderChecker interface
+func (a *OAuthCheckerAdapter) IsAvailable(id string) bool {
+ return a.manager.IsAvailable(OAuthProviderID(id))
+}
+
// GetEnabledProviders returns enabled providers.
//
// Deprecated: Use GetAvailableProviders instead.
diff --git a/backend/internal/caddy/caddyfile/acl.go b/backend/internal/caddy/caddyfile/acl.go
deleted file mode 100644
index 7197f74..0000000
--- a/backend/internal/caddy/caddyfile/acl.go
+++ /dev/null
@@ -1,1003 +0,0 @@
-package caddyfile
-
-import (
- "fmt"
- "sort"
- "strings"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-// ACLBuilder generates Caddy ACL directives for proxy configurations.
-// It supports various authentication methods including IP rules, basic auth,
-// forward auth (Waygates), and external providers (Authelia, Authentik).
-type ACLBuilder struct {
- waygatesVerifyURL string // e.g., http://localhost:8080 (internal URL for Caddy)
- waygatesLoginURL string // e.g., https://waygates.company.com/auth/login (external URL for users)
-}
-
-// NewACLBuilder creates a new ACL builder with the specified Waygates URLs.
-func NewACLBuilder(waygatesVerifyURL, waygatesLoginURL string) *ACLBuilder {
- return &ACLBuilder{
- waygatesVerifyURL: waygatesVerifyURL,
- waygatesLoginURL: waygatesLoginURL,
- }
-}
-
-// Default headers to copy from Waygates forward auth responses
-var waygatesDefaultHeaders = []string{
- "X-Auth-User",
- "X-Auth-User-ID",
- "X-Auth-User-Email",
-}
-
-// Static asset extensions that bypass ACL authentication
-// These are common static files that don't need authentication
-var staticAssetExtensions = []string{
- ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif",
- ".css", ".js", ".mjs",
- ".woff", ".woff2", ".ttf", ".eot", ".otf",
- ".webmanifest", ".map",
-}
-
-// Static asset paths that bypass ACL authentication
-var staticAssetPaths = []string{
- "/favicon.ico",
- "/robots.txt",
- "/sitemap.xml",
-}
-
-// Provider-specific default headers
-var providerDefaultHeaders = map[string][]string{
- models.ACLProviderTypeAuthelia: {
- "Remote-User",
- "Remote-Groups",
- "Remote-Name",
- "Remote-Email",
- },
- models.ACLProviderTypeAuthentik: {
- "X-authentik-username",
- "X-authentik-groups",
- "X-authentik-email",
- "X-authentik-name",
- "X-authentik-uid",
- },
-}
-
-// forbiddenHTML is the HTML template shown when access is denied (403)
-const forbiddenHTML = `
-
-
-
-
- Access Denied
-
-
-
-
-
-
403
-
Access Denied
-
You don't have permission to access this resource. Please contact your administrator if you believe this is an error.
-
Go Back
-
-
-`
-
-// unauthorizedHTML is the HTML template shown when authentication is required (401)
-const unauthorizedHTML = `
-
-
-
-
- Authentication Required
-
-
-
-
-
-
401
-
Authentication Required
-
You need to sign in to access this resource. Please authenticate to continue.
-
Go Back
-
-
-`
-
-// ACLConfig holds the complete ACL configuration for a proxy
-type ACLConfig struct {
- Proxy *models.Proxy
- Assignments []models.ProxyACLAssignment
-}
-
-// BuildACLConfig generates ACL configuration for a proxy.
-// Returns an empty string if no ACL assignments exist.
-func (b *ACLBuilder) BuildACLConfig(proxy *models.Proxy, assignments []models.ProxyACLAssignment) string {
- if len(assignments) == 0 {
- return ""
- }
-
- // Filter enabled assignments and sort by priority (lower = higher priority)
- enabledAssignments := filterEnabledAssignments(assignments)
- if len(enabledAssignments) == 0 {
- return ""
- }
-
- sort.Slice(enabledAssignments, func(i, j int) bool {
- return enabledAssignments[i].Priority < enabledAssignments[j].Priority
- })
-
- var sb strings.Builder
-
- // Process each assignment
- for idx, assignment := range enabledAssignments {
- if assignment.ACLGroup == nil {
- continue
- }
-
- config := b.buildAssignmentConfig(proxy, assignment, idx)
- if config != "" {
- sb.WriteString(config)
- sb.WriteString("\n")
- }
- }
-
- return sb.String()
-}
-
-// filterEnabledAssignments returns only enabled assignments with loaded ACL groups
-func filterEnabledAssignments(assignments []models.ProxyACLAssignment) []models.ProxyACLAssignment {
- var enabled []models.ProxyACLAssignment
- for _, a := range assignments {
- if a.Enabled && a.ACLGroup != nil {
- enabled = append(enabled, a)
- }
- }
- return enabled
-}
-
-// buildAssignmentConfig generates config for a single ACL assignment
-func (b *ACLBuilder) buildAssignmentConfig(proxy *models.Proxy, assignment models.ProxyACLAssignment, idx int) string {
- group := assignment.ACLGroup
- pathPattern := assignment.PathPattern
-
- // Analyze what authentication methods are configured
- hasIPRules := len(group.IPRules) > 0
- hasBasicAuth := len(group.BasicAuthUsers) > 0
- hasWaygatesAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
- hasExternalProviders := len(group.ExternalProviders) > 0
-
- // If no auth methods configured, skip
- if !hasIPRules && !hasBasicAuth && !hasWaygatesAuth && !hasExternalProviders {
- return ""
- }
-
- var sb strings.Builder
- matcherPrefix := fmt.Sprintf("acl_%d", idx)
-
- // Generate config based on combination mode
- switch group.CombinationMode {
- case models.ACLCombinationModeAny:
- sb.WriteString(b.buildAnyModeConfig(proxy, group, pathPattern, matcherPrefix))
- case models.ACLCombinationModeAll:
- sb.WriteString(b.buildAllModeConfig(proxy, group, pathPattern, matcherPrefix))
- case models.ACLCombinationModeIPBypass:
- sb.WriteString(b.buildIPBypassModeConfig(proxy, group, pathPattern, matcherPrefix))
- default:
- // Default to "any" mode
- sb.WriteString(b.buildAnyModeConfig(proxy, group, pathPattern, matcherPrefix))
- }
-
- return sb.String()
-}
-
-// buildAnyModeConfig generates config where ANY auth method can grant access (OR logic)
-// IP bypass rules skip auth entirely
-// IP allow rules grant access without further auth
-// Otherwise, forward_auth is checked
-func (b *ACLBuilder) buildAnyModeConfig(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string) string {
- var sb strings.Builder
-
- // Categorize IP rules
- bypassIPs, allowIPs, denyIPs := categorizeIPRules(group.IPRules)
-
- // 1. Handle IP deny rules first (highest priority)
- if len(denyIPs) > 0 {
- sb.WriteString(b.buildIPDenyBlock(pathPattern, matcherPrefix, denyIPs))
- }
-
- // 2. Handle IP bypass rules (skip all auth)
- if len(bypassIPs) > 0 {
- sb.WriteString(b.buildIPBypassBlock(proxy, pathPattern, matcherPrefix, bypassIPs))
- }
-
- // 3. Handle IP allow rules (grant access without auth)
- if len(allowIPs) > 0 {
- sb.WriteString(b.buildIPAllowBlock(proxy, pathPattern, matcherPrefix, allowIPs))
- }
-
- // 4. Handle static assets bypass (skip auth for common static files)
- sb.WriteString(b.buildStaticAssetsBypassBlock(proxy, matcherPrefix))
-
- // 5. Handle remaining requests with authentication
- hasWaygatesUsernameAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
- hasOAuthProviders := group.WaygatesAuth != nil && len(group.WaygatesAuth.AllowedProviders) > 0
- hasOAuthRestrictions := len(group.OAuthProviderRestrictions) > 0
- hasExternalAuth := len(group.ExternalProviders) > 0
- hasBasicAuth := len(group.BasicAuthUsers) > 0
-
- // Use forward auth if Waygates auth (username/password OR OAuth providers), OAuth restrictions, or external providers are configured.
- // Basic auth is only used when it's the only auth method (more secure methods override it).
- hasSecureAuth := hasWaygatesUsernameAuth || hasOAuthProviders || hasOAuthRestrictions || hasExternalAuth
- if hasBasicAuth && !hasSecureAuth {
- // Only basic auth configured (no more secure auth methods)
- sb.WriteString(b.buildBasicAuthBlock(proxy, group, pathPattern, matcherPrefix, bypassIPs, allowIPs))
- } else if hasSecureAuth {
- // Forward auth (Waygates, OAuth, or external provider)
- sb.WriteString(b.buildForwardAuthBlock(proxy, group, pathPattern, matcherPrefix, bypassIPs, allowIPs))
- }
-
- return sb.String()
-}
-
-// buildAllModeConfig generates config where ALL auth methods must pass (AND logic)
-// IP rules must match AND auth must pass
-func (b *ACLBuilder) buildAllModeConfig(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string) string {
- var sb strings.Builder
-
- // Categorize IP rules
- bypassIPs, allowIPs, denyIPs := categorizeIPRules(group.IPRules)
-
- // 1. Handle IP deny rules first
- if len(denyIPs) > 0 {
- sb.WriteString(b.buildIPDenyBlock(pathPattern, matcherPrefix, denyIPs))
- }
-
- // 2. For ALL mode with IP rules, requests must come from allowed IPs AND pass auth
- allAllowedIPs := make([]string, 0, len(bypassIPs)+len(allowIPs))
- allAllowedIPs = append(allAllowedIPs, bypassIPs...)
- allAllowedIPs = append(allAllowedIPs, allowIPs...)
- if len(allAllowedIPs) > 0 {
- // Deny requests not from allowed IPs
- sb.WriteString(b.buildIPDenyNotInListBlock(pathPattern, matcherPrefix, allAllowedIPs))
- }
-
- // 3. Requests from allowed IPs still need auth check
- hasWaygatesUsernameAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
- hasOAuthProviders := group.WaygatesAuth != nil && len(group.WaygatesAuth.AllowedProviders) > 0
- hasOAuthRestrictions := len(group.OAuthProviderRestrictions) > 0
- hasExternalAuth := len(group.ExternalProviders) > 0
- hasBasicAuth := len(group.BasicAuthUsers) > 0
-
- // Use forward auth if Waygates auth (username/password OR OAuth providers), OAuth restrictions, or external providers are configured.
- // Basic auth is only used when it's the only auth method (more secure methods override it).
- hasSecureAuth := hasWaygatesUsernameAuth || hasOAuthProviders || hasOAuthRestrictions || hasExternalAuth
- if hasBasicAuth && !hasSecureAuth {
- sb.WriteString(b.buildBasicAuthBlockAll(proxy, group, pathPattern, matcherPrefix, allAllowedIPs))
- } else if hasSecureAuth {
- sb.WriteString(b.buildForwardAuthBlockAll(proxy, group, pathPattern, matcherPrefix, allAllowedIPs))
- }
-
- return sb.String()
-}
-
-// buildIPBypassModeConfig generates config where IP rules can bypass auth
-// IP bypass: skip auth entirely
-// IP allow: pre-authenticated but may need group check
-// Others: forward_auth required
-func (b *ACLBuilder) buildIPBypassModeConfig(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string) string {
- // IP bypass mode is similar to ANY mode with specific IP bypass handling
- return b.buildAnyModeConfig(proxy, group, pathPattern, matcherPrefix)
-}
-
-// categorizeIPRules separates IP rules by type
-func categorizeIPRules(rules []models.ACLIPRule) (bypass, allow, deny []string) {
- for _, rule := range rules {
- switch rule.RuleType {
- case models.ACLIPRuleTypeBypass:
- bypass = append(bypass, rule.CIDR)
- case models.ACLIPRuleTypeAllow:
- allow = append(allow, rule.CIDR)
- case models.ACLIPRuleTypeDeny:
- deny = append(deny, rule.CIDR)
- }
- }
- return
-}
-
-// buildIPDenyBlock generates configuration to deny requests from specific IPs
-func (b *ACLBuilder) buildIPDenyBlock(pathPattern, matcherPrefix string, denyIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_denied_ips", matcherPrefix)
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if pathPattern != "" && pathPattern != "/*" {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- }
- sb.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", strings.Join(denyIPs, " ")))
- sb.WriteString("\t}\n")
- sb.WriteString(fmt.Sprintf("\trespond %s \"Forbidden\" 403\n\n", matcherName))
-
- return sb.String()
-}
-
-// buildIPDenyNotInListBlock generates configuration to deny requests NOT from allowed IPs
-func (b *ACLBuilder) buildIPDenyNotInListBlock(pathPattern, matcherPrefix string, allowedIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_not_allowed_ip", matcherPrefix)
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if pathPattern != "" && pathPattern != "/*" {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- }
- sb.WriteString(fmt.Sprintf("\t\tnot remote_ip %s\n", strings.Join(allowedIPs, " ")))
- sb.WriteString("\t}\n")
- sb.WriteString(fmt.Sprintf("\trespond %s \"Forbidden\" 403\n\n", matcherName))
-
- return sb.String()
-}
-
-// buildIPBypassBlock generates configuration for IP bypass (skip all auth)
-func (b *ACLBuilder) buildIPBypassBlock(proxy *models.Proxy, pathPattern, matcherPrefix string, bypassIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_bypass_ip", matcherPrefix)
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if pathPattern != "" && pathPattern != "/*" {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- }
- sb.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", strings.Join(bypassIPs, " ")))
- sb.WriteString("\t}\n")
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildIPAllowBlock generates configuration for IP allow (access without auth)
-func (b *ACLBuilder) buildIPAllowBlock(proxy *models.Proxy, pathPattern, matcherPrefix string, allowIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_allowed_ip", matcherPrefix)
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if pathPattern != "" && pathPattern != "/*" {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- }
- sb.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", strings.Join(allowIPs, " ")))
- sb.WriteString("\t}\n")
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildStaticAssetsBypassBlock generates configuration to bypass auth for static assets
-// This allows common static files (images, CSS, JS, fonts, etc.) to be served without authentication
-func (b *ACLBuilder) buildStaticAssetsBypassBlock(proxy *models.Proxy, matcherPrefix string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_static_assets", matcherPrefix)
-
- // Build path patterns for static assets
- pathPatterns := make([]string, 0, len(staticAssetExtensions)+len(staticAssetPaths))
-
- // Add file extension patterns
- for _, ext := range staticAssetExtensions {
- pathPatterns = append(pathPatterns, fmt.Sprintf("*%s", ext))
- }
-
- // Add specific paths
- pathPatterns = append(pathPatterns, staticAssetPaths...)
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", strings.Join(pathPatterns, " ")))
- sb.WriteString("\t}\n")
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildBasicAuthBlock generates basic auth configuration
-func (b *ACLBuilder) buildBasicAuthBlock(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string, bypassIPs, allowIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_basic_auth", matcherPrefix)
-
- // Exclude bypass and allow IPs
- excludeIPs := make([]string, 0, len(bypassIPs)+len(allowIPs))
- excludeIPs = append(excludeIPs, bypassIPs...)
- excludeIPs = append(excludeIPs, allowIPs...)
-
- hasPathCondition := pathPattern != "" && pathPattern != "/*"
- hasIPCondition := len(excludeIPs) > 0
-
- // Build matcher that excludes already handled IPs
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if hasPathCondition {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- } else if !hasIPCondition {
- // If no conditions at all, add wildcard path to match everything
- sb.WriteString("\t\tpath *\n")
- }
-
- if hasIPCondition {
- sb.WriteString(fmt.Sprintf("\t\tnot remote_ip %s\n", strings.Join(excludeIPs, " ")))
- }
- sb.WriteString("\t}\n")
-
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
- sb.WriteString("\t\tbasicauth {\n")
- for _, user := range group.BasicAuthUsers {
- sb.WriteString(fmt.Sprintf("\t\t\t%s %s\n", user.Username, user.PasswordHash))
- }
- sb.WriteString("\t\t}\n")
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildBasicAuthBlockAll generates basic auth configuration for ALL mode (requires IP match)
-func (b *ACLBuilder) buildBasicAuthBlockAll(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string, allowedIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_basic_auth_all", matcherPrefix)
-
- hasPathCondition := pathPattern != "" && pathPattern != "/*"
- hasIPCondition := len(allowedIPs) > 0
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if hasPathCondition {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- } else if !hasIPCondition {
- // If no conditions at all, add wildcard path to match everything
- sb.WriteString("\t\tpath *\n")
- }
- if hasIPCondition {
- sb.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", strings.Join(allowedIPs, " ")))
- }
- sb.WriteString("\t}\n")
-
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
- sb.WriteString("\t\tbasicauth {\n")
- for _, user := range group.BasicAuthUsers {
- sb.WriteString(fmt.Sprintf("\t\t\t%s %s\n", user.Username, user.PasswordHash))
- }
- sb.WriteString("\t\t}\n")
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildForwardAuthBlock generates forward auth configuration
-func (b *ACLBuilder) buildForwardAuthBlock(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string, bypassIPs, allowIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_forward_auth", matcherPrefix)
-
- // Exclude bypass and allow IPs
- excludeIPs := make([]string, 0, len(bypassIPs)+len(allowIPs))
- excludeIPs = append(excludeIPs, bypassIPs...)
- excludeIPs = append(excludeIPs, allowIPs...)
-
- hasPathCondition := pathPattern != "" && pathPattern != "/*"
- hasIPCondition := len(excludeIPs) > 0
-
- // Build matcher that excludes already handled IPs
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if hasPathCondition {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- } else if !hasIPCondition {
- // If no conditions at all, add wildcard path to match everything
- // An empty matcher {} matches nothing in Caddy
- sb.WriteString("\t\tpath *\n")
- }
-
- if hasIPCondition {
- sb.WriteString(fmt.Sprintf("\t\tnot remote_ip %s\n", strings.Join(excludeIPs, " ")))
- }
- sb.WriteString("\t}\n")
-
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
-
- // Determine which forward auth to use
- if len(group.ExternalProviders) > 0 {
- // Use first external provider
- provider := group.ExternalProviders[0]
- sb.WriteString(b.buildExternalForwardAuth(provider, "\t\t"))
- } else if group.WaygatesAuth != nil && (group.WaygatesAuth.Enabled || len(group.WaygatesAuth.AllowedProviders) > 0) {
- // Use Waygates forward auth if username/password is enabled OR OAuth providers are configured
- sb.WriteString(b.buildWaygatesForwardAuth("\t\t"))
- }
-
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildForwardAuthBlockAll generates forward auth configuration for ALL mode
-func (b *ACLBuilder) buildForwardAuthBlockAll(proxy *models.Proxy, group *models.ACLGroup, pathPattern, matcherPrefix string, allowedIPs []string) string {
- var sb strings.Builder
-
- matcherName := fmt.Sprintf("@%s_forward_auth_all", matcherPrefix)
-
- hasPathCondition := pathPattern != "" && pathPattern != "/*"
- hasIPCondition := len(allowedIPs) > 0
-
- sb.WriteString(fmt.Sprintf("\t%s {\n", matcherName))
- if hasPathCondition {
- sb.WriteString(fmt.Sprintf("\t\tpath %s\n", pathPattern))
- } else if !hasIPCondition {
- // If no conditions at all, add wildcard path to match everything
- sb.WriteString("\t\tpath *\n")
- }
- if hasIPCondition {
- sb.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", strings.Join(allowedIPs, " ")))
- }
- sb.WriteString("\t}\n")
-
- sb.WriteString(fmt.Sprintf("\thandle %s {\n", matcherName))
-
- // Determine which forward auth to use
- if len(group.ExternalProviders) > 0 {
- provider := group.ExternalProviders[0]
- sb.WriteString(b.buildExternalForwardAuth(provider, "\t\t"))
- } else if group.WaygatesAuth != nil && (group.WaygatesAuth.Enabled || len(group.WaygatesAuth.AllowedProviders) > 0) {
- // Use Waygates forward auth if username/password is enabled OR OAuth providers are configured
- sb.WriteString(b.buildWaygatesForwardAuth("\t\t"))
- }
-
- sb.WriteString(b.buildReverseProxyDirective(proxy, "\t\t"))
- sb.WriteString("\t}\n\n")
-
- return sb.String()
-}
-
-// buildWaygatesForwardAuth generates Waygates forward auth directive
-func (b *ACLBuilder) buildWaygatesForwardAuth(indent string) string {
- var sb strings.Builder
-
- sb.WriteString(fmt.Sprintf("%sforward_auth %s {\n", indent, b.waygatesVerifyURL))
- sb.WriteString(fmt.Sprintf("%s\turi /api/auth/acl/verify\n", indent))
- sb.WriteString(fmt.Sprintf("%s\tcopy_headers %s\n", indent, strings.Join(waygatesDefaultHeaders, " ")))
-
- // Handle 401 response
- sb.WriteString(fmt.Sprintf("%s\t@unauthorized status 401\n", indent))
- sb.WriteString(fmt.Sprintf("%s\thandle_response @unauthorized {\n", indent))
- if b.waygatesLoginURL != "" {
- // Redirect to login page with original URL
- // {scheme}://{host}{uri} captures the original URL the user was trying to access
- sb.WriteString(fmt.Sprintf("%s\t\tredir %s?redirect={scheme}://{host}{uri} 302\n", indent, b.waygatesLoginURL))
- } else {
- // No login URL configured, show error page
- sb.WriteString(fmt.Sprintf("%s\t\theader Content-Type text/html\n", indent))
- sb.WriteString(fmt.Sprintf("%s\t\trespond < 0 {
- sb.WriteString(fmt.Sprintf("%s\tcopy_headers %s\n", indent, strings.Join(headers, " ")))
- }
-
- sb.WriteString(fmt.Sprintf("%s}\n", indent))
-
- return sb.String()
-}
-
-// buildReverseProxyDirective generates the reverse_proxy directive for the proxy
-func (b *ACLBuilder) buildReverseProxyDirective(proxy *models.Proxy, indent string) string {
- if proxy.Upstreams == nil {
- return ""
- }
-
- upstreams, ok := proxy.Upstreams.([]interface{})
- if !ok || len(upstreams) == 0 {
- return ""
- }
-
- var sb strings.Builder
-
- // Build upstream addresses
- addresses := make([]string, 0, len(upstreams))
- var hasHTTPS bool
-
- for _, up := range upstreams {
- upstreamMap, ok := up.(map[string]interface{})
- if !ok {
- continue
- }
-
- host, _ := upstreamMap["host"].(string)
- port, _ := upstreamMap["port"].(float64)
- scheme, _ := upstreamMap["scheme"].(string)
-
- if scheme == "https" {
- hasHTTPS = true
- }
-
- addr := fmt.Sprintf("%s:%d", host, int(port))
- addresses = append(addresses, addr)
- }
-
- sb.WriteString(fmt.Sprintf("%sreverse_proxy %s {\n", indent, strings.Join(addresses, " ")))
-
- // Transport config for HTTPS upstreams
- if hasHTTPS || proxy.TLSInsecureSkipVerify {
- sb.WriteString(fmt.Sprintf("%s\ttransport http {\n", indent))
- if hasHTTPS {
- sb.WriteString(fmt.Sprintf("%s\t\ttls\n", indent))
- }
- if proxy.TLSInsecureSkipVerify {
- sb.WriteString(fmt.Sprintf("%s\t\ttls_insecure_skip_verify\n", indent))
- }
- sb.WriteString(fmt.Sprintf("%s\t}\n", indent))
- }
-
- // Standard headers
- sb.WriteString(fmt.Sprintf("%s\theader_up X-Real-IP {remote_host}\n", indent))
- sb.WriteString(fmt.Sprintf("%s\theader_up X-Forwarded-For {remote_host}\n", indent))
- sb.WriteString(fmt.Sprintf("%s\theader_up X-Forwarded-Proto {scheme}\n", indent))
- sb.WriteString(fmt.Sprintf("%s\theader_up X-Forwarded-Host {host}\n", indent))
-
- // Custom headers
- if len(proxy.CustomHeaders) > 0 {
- for key, value := range proxy.CustomHeaders {
- if strVal, ok := value.(string); ok {
- sb.WriteString(fmt.Sprintf("%s\theader_up %s %q\n", indent, key, strVal))
- }
- }
- }
-
- sb.WriteString(fmt.Sprintf("%s}\n", indent))
-
- return sb.String()
-}
-
-// HasACLConfig checks if proxy has any enabled ACL assignments
-func HasACLConfig(assignments []models.ProxyACLAssignment) bool {
- for _, a := range assignments {
- if a.Enabled && a.ACLGroup != nil {
- return true
- }
- }
- return false
-}
-
-// =============================================================================
-// Union ACL Config Builder
-// =============================================================================
-
-// deduplicateCIDRs removes duplicate CIDR entries while preserving order.
-func deduplicateCIDRs(cidrs []string) []string {
- seen := make(map[string]bool)
- result := make([]string, 0, len(cidrs))
- for _, cidr := range cidrs {
- if !seen[cidr] {
- seen[cidr] = true
- result = append(result, cidr)
- }
- }
- return result
-}
-
-// collectUnionIPRules collects all IP rules from all enabled assignments grouped by type.
-// Returns deduplicated slices of deny, bypass, and allow CIDRs.
-func collectUnionIPRules(assignments []models.ProxyACLAssignment) (denyRules, bypassRules, allowRules []string) {
- for _, assignment := range assignments {
- if !assignment.Enabled || assignment.ACLGroup == nil {
- continue
- }
- for _, rule := range assignment.ACLGroup.IPRules {
- switch rule.RuleType {
- case models.ACLIPRuleTypeDeny:
- denyRules = append(denyRules, rule.CIDR)
- case models.ACLIPRuleTypeBypass:
- bypassRules = append(bypassRules, rule.CIDR)
- case models.ACLIPRuleTypeAllow:
- allowRules = append(allowRules, rule.CIDR)
- }
- }
- }
- return deduplicateCIDRs(denyRules), deduplicateCIDRs(bypassRules), deduplicateCIDRs(allowRules)
-}
-
-// BuildUnionACLConfig generates Caddyfile config combining IP rules from all ACL groups
-// into unified matchers. This creates a single set of deny, bypass, and forward_auth
-// directives that represent the union of all assigned ACL groups.
-//
-// The generated config follows this order:
-// 1. Deny matcher - blocks requests from any denied IP across all groups
-// 2. Bypass matcher - allows requests from bypass IPs to skip authentication
-// 3. Forward auth - requires authentication for all other requests
-//
-// Example output for two groups with deny 10.0.10.0/24 and deny 10.0.12.0/24, bypass 192.168.1.0/24:
-//
-// @denied_ips {
-// remote_ip 10.0.10.0/24
-// remote_ip 10.0.12.0/24
-// }
-// respond @denied_ips 403
-//
-// @bypass_ips {
-// remote_ip 192.168.1.0/24
-// }
-//
-// @needs_auth {
-// not {
-// remote_ip 192.168.1.0/24
-// }
-// }
-//
-// forward_auth @needs_auth localhost:8080 {
-// uri /api/auth/acl/verify
-// copy_headers Remote-User Remote-Groups Remote-Email X-Forwarded-User
-// }
-func (b *ACLBuilder) BuildUnionACLConfig(assignments []models.ProxyACLAssignment) string {
- if len(assignments) == 0 {
- return ""
- }
-
- // Check if any assignment is enabled
- hasEnabled := false
- for _, a := range assignments {
- if a.Enabled && a.ACLGroup != nil {
- hasEnabled = true
- break
- }
- }
- if !hasEnabled {
- return ""
- }
-
- denyRules, bypassRules, _ := collectUnionIPRules(assignments)
-
- var config strings.Builder
-
- // Generate deny matcher if there are deny rules
- if len(denyRules) > 0 {
- config.WriteString("\t@denied_ips {\n")
- for _, cidr := range denyRules {
- config.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", cidr))
- }
- config.WriteString("\t}\n")
- config.WriteString("\trespond @denied_ips 403\n\n")
- }
-
- // Generate bypass matcher if there are bypass rules
- if len(bypassRules) > 0 {
- config.WriteString("\t@bypass_ips {\n")
- for _, cidr := range bypassRules {
- config.WriteString(fmt.Sprintf("\t\tremote_ip %s\n", cidr))
- }
- config.WriteString("\t}\n\n")
-
- // Generate needs_auth matcher (not in bypass list)
- config.WriteString("\t@needs_auth {\n")
- config.WriteString("\t\tnot {\n")
- for _, cidr := range bypassRules {
- config.WriteString(fmt.Sprintf("\t\t\tremote_ip %s\n", cidr))
- }
- config.WriteString("\t\t}\n")
- config.WriteString("\t}\n\n")
-
- // Forward auth only for IPs that need it
- config.WriteString(fmt.Sprintf("\tforward_auth @needs_auth %s {\n", b.waygatesVerifyURL))
- config.WriteString("\t\turi /api/auth/acl/verify\n")
- config.WriteString("\t\tcopy_headers Remote-User Remote-Groups Remote-Email X-Forwarded-User\n")
- config.WriteString("\t}\n")
- } else {
- // No bypass rules, forward auth for all requests
- config.WriteString(fmt.Sprintf("\tforward_auth %s {\n", b.waygatesVerifyURL))
- config.WriteString("\t\turi /api/auth/acl/verify\n")
- config.WriteString("\t\tcopy_headers Remote-User Remote-Groups Remote-Email X-Forwarded-User\n")
- config.WriteString("\t}\n")
- }
-
- return config.String()
-}
-
-// GetDefaultWaygatesHeaders returns the default headers copied from Waygates auth
-func GetDefaultWaygatesHeaders() []string {
- return waygatesDefaultHeaders
-}
-
-// GetProviderDefaultHeaders returns the default headers for a provider type
-func GetProviderDefaultHeaders(providerType string) []string {
- if headers, ok := providerDefaultHeaders[providerType]; ok {
- return headers
- }
- return nil
-}
diff --git a/backend/internal/caddy/caddyfile/acl_test.go b/backend/internal/caddy/caddyfile/acl_test.go
deleted file mode 100644
index cd7c827..0000000
--- a/backend/internal/caddy/caddyfile/acl_test.go
+++ /dev/null
@@ -1,2094 +0,0 @@
-package caddyfile
-
-import (
- "strings"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-func TestNewACLBuilder(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "https://waygates.example.com/auth/login")
- require.NotNil(t, builder)
- assert.Equal(t, "http://localhost:8080", builder.waygatesVerifyURL)
- assert.Equal(t, "https://waygates.example.com/auth/login", builder.waygatesLoginURL)
-}
-
-func TestBuildACLConfig_EmptyAssignments(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- // Test with nil assignments
- result := builder.BuildACLConfig(proxy, nil)
- assert.Empty(t, result)
-
- // Test with empty slice
- result = builder.BuildACLConfig(proxy, []models.ProxyACLAssignment{})
- assert.Empty(t, result)
-}
-
-func TestBuildACLConfig_DisabledAssignments(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "192.168.1.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/api/*",
- Priority: 0,
- Enabled: false, // Disabled
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
- assert.Empty(t, result, "disabled assignments should produce no output")
-}
-
-func TestBuildACLConfig_IPDenyRules(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "1.2.3.4"},
- {ID: 2, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "@acl_0_denied_ips")
- assert.Contains(t, result, "remote_ip 1.2.3.4 10.0.0.0/8")
- assert.Contains(t, result, "respond @acl_0_denied_ips \"Forbidden\" 403")
-}
-
-func TestBuildACLConfig_IPBypassRules(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/api/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "@acl_0_bypass_ip")
- assert.Contains(t, result, "remote_ip 192.168.1.0/24")
- assert.Contains(t, result, "handle @acl_0_bypass_ip")
- assert.Contains(t, result, "reverse_proxy")
-}
-
-func TestBuildACLConfig_IPAllowRules(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "@acl_0_allowed_ip")
- assert.Contains(t, result, "remote_ip 10.0.0.0/8")
- assert.Contains(t, result, "handle @acl_0_allowed_ip")
-}
-
-func TestBuildACLConfig_BasicAuth(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- {ID: 2, Username: "user", PasswordHash: "$2a$14$hashedpassword2"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/admin/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "@acl_0_basic_auth")
- assert.Contains(t, result, "path /admin/*")
- assert.Contains(t, result, "basicauth")
- assert.Contains(t, result, "admin $2a$14$hashedpassword1")
- assert.Contains(t, result, "user $2a$14$hashedpassword2")
-}
-
-func TestBuildACLConfig_WaygatesAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "@acl_0_forward_auth")
- assert.Contains(t, result, "path /protected/*")
- assert.Contains(t, result, "forward_auth http://waygates:8080")
- assert.Contains(t, result, "uri /api/auth/acl/verify")
- assert.Contains(t, result, "copy_headers X-Auth-User X-Auth-User-ID X-Auth-User-Email")
-}
-
-func TestBuildACLConfig_ExternalProvider_Authelia(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- redirectURL := "https://auth.example.com/"
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- ExternalProviders: []models.ACLExternalProvider{
- {
- ID: 1,
- ACLGroupID: 1,
- ProviderType: models.ACLProviderTypeAuthelia,
- Name: "authelia",
- VerifyURL: "http://authelia:9091/api/verify",
- AuthRedirectURL: &redirectURL,
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "forward_auth http://authelia:9091/api/verify?rd=https://auth.example.com/")
- assert.Contains(t, result, "copy_headers Remote-User Remote-Groups Remote-Name Remote-Email")
-}
-
-func TestBuildACLConfig_ExternalProvider_Authentik(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- ExternalProviders: []models.ACLExternalProvider{
- {
- ID: 1,
- ACLGroupID: 1,
- ProviderType: models.ACLProviderTypeAuthentik,
- Name: "authentik",
- VerifyURL: "http://authentik:9000/outpost.goauthentik.io/auth/nginx",
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "forward_auth http://authentik:9000/outpost.goauthentik.io/auth/nginx")
- assert.Contains(t, result, "X-authentik-username")
- assert.Contains(t, result, "X-authentik-groups")
-}
-
-func TestBuildACLConfig_ExternalProvider_CustomHeaders(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- ExternalProviders: []models.ACLExternalProvider{
- {
- ID: 1,
- ACLGroupID: 1,
- ProviderType: models.ACLProviderTypeCustom,
- Name: "custom-auth",
- VerifyURL: "http://auth.local/verify",
- HeadersToCopy: []string{"X-Custom-User", "X-Custom-Role"},
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- assert.Contains(t, result, "forward_auth http://auth.local/verify")
- assert.Contains(t, result, "copy_headers X-Custom-User X-Custom-Role")
-}
-
-func TestBuildACLConfig_CombinationModeAll(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAll,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/secure/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // In ALL mode, should deny requests not from allowed IPs
- assert.Contains(t, result, "@acl_0_not_allowed_ip")
- assert.Contains(t, result, "not remote_ip 10.0.0.0/8")
- assert.Contains(t, result, "respond @acl_0_not_allowed_ip \"Forbidden\" 403")
-
- // Should also require forward auth for allowed IPs
- assert.Contains(t, result, "forward_auth")
-}
-
-func TestBuildACLConfig_IPBypassWithForwardAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeIPBypass,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/api/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Bypass IPs should skip auth
- assert.Contains(t, result, "@acl_0_bypass_ip")
- assert.Contains(t, result, "remote_ip 192.168.1.0/24")
- assert.Contains(t, result, "handle @acl_0_bypass_ip")
-
- // Forward auth should exclude bypass IPs
- assert.Contains(t, result, "@acl_0_forward_auth")
- assert.Contains(t, result, "not remote_ip 192.168.1.0/24")
- assert.Contains(t, result, "forward_auth")
-}
-
-func TestBuildACLConfig_MultipleAssignments(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "admin-group",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$adminpass"},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "api-group",
- CombinationMode: models.ACLCombinationModeAny,
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 2,
- ACLGroupID: 2,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/admin/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/api/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should have both matchers
- assert.Contains(t, result, "@acl_0_basic_auth")
- assert.Contains(t, result, "path /admin/*")
- assert.Contains(t, result, "@acl_1_forward_auth")
- assert.Contains(t, result, "path /api/*")
-}
-
-func TestBuildACLConfig_PriorityOrdering(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{{ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"}},
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{{ID: 2, RuleType: models.ACLIPRuleTypeAllow, CIDR: "192.168.0.0/16"}},
- }
-
- // Assignments in reverse priority order
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/second/*",
- Priority: 10, // Lower priority (processed second)
- Enabled: true,
- ACLGroup: group2,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/first/*",
- Priority: 1, // Higher priority (processed first)
- Enabled: true,
- ACLGroup: group1,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // First group should be acl_0 (priority 1)
- firstIdx := strings.Index(result, "10.0.0.0/8")
- secondIdx := strings.Index(result, "192.168.0.0/16")
-
- assert.True(t, firstIdx < secondIdx, "higher priority assignment should appear first")
-}
-
-func TestBuildACLConfig_NoAuthMethodsConfigured(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Group with no auth methods
- group := &models.ACLGroup{
- ID: 1,
- Name: "empty-group",
- CombinationMode: models.ACLCombinationModeAny,
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
- assert.Empty(t, result, "groups with no auth methods should produce no output")
-}
-
-func TestBuildACLConfig_NilACLGroup(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: nil, // No group loaded
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
- assert.Empty(t, result, "assignments with nil ACLGroup should be skipped")
-}
-
-func TestBuildACLConfig_PathPatternWildcard(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "1.2.3.4"},
- },
- }
-
- // Test with /* pattern (should not generate path matcher)
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should not contain path directive for /* pattern
- assert.NotContains(t, result, "path /*")
-}
-
-func TestHasACLConfig(t *testing.T) {
- group := &models.ACLGroup{
- ID: 1,
- Name: "test",
- }
-
- tests := []struct {
- name string
- assignments []models.ProxyACLAssignment
- expected bool
- }{
- {
- name: "nil assignments",
- assignments: nil,
- expected: false,
- },
- {
- name: "empty assignments",
- assignments: []models.ProxyACLAssignment{},
- expected: false,
- },
- {
- name: "disabled assignment",
- assignments: []models.ProxyACLAssignment{
- {Enabled: false, ACLGroup: group},
- },
- expected: false,
- },
- {
- name: "enabled assignment with nil group",
- assignments: []models.ProxyACLAssignment{
- {Enabled: true, ACLGroup: nil},
- },
- expected: false,
- },
- {
- name: "enabled assignment with group",
- assignments: []models.ProxyACLAssignment{
- {Enabled: true, ACLGroup: group},
- },
- expected: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := HasACLConfig(tt.assignments)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
-func TestGetDefaultWaygatesHeaders(t *testing.T) {
- headers := GetDefaultWaygatesHeaders()
- assert.Contains(t, headers, "X-Auth-User")
- assert.Contains(t, headers, "X-Auth-User-ID")
- assert.Contains(t, headers, "X-Auth-User-Email")
-}
-
-func TestGetProviderDefaultHeaders(t *testing.T) {
- tests := []struct {
- provider string
- expected []string
- }{
- {
- provider: models.ACLProviderTypeAuthelia,
- expected: []string{"Remote-User", "Remote-Groups", "Remote-Name", "Remote-Email"},
- },
- {
- provider: models.ACLProviderTypeAuthentik,
- expected: []string{"X-authentik-username", "X-authentik-groups"},
- },
- {
- provider: "unknown",
- expected: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.provider, func(t *testing.T) {
- result := GetProviderDefaultHeaders(tt.provider)
- if tt.expected == nil {
- assert.Nil(t, result)
- } else {
- for _, h := range tt.expected {
- assert.Contains(t, result, h)
- }
- }
- })
- }
-}
-
-// Helper function to create a test proxy
-func createTestProxy() *models.Proxy {
- return &models.Proxy{
- ID: 1,
- Type: models.ProxyTypeReverseProxy,
- Name: "test-proxy",
- Hostname: "test.example.com",
- Upstreams: []interface{}{
- map[string]interface{}{
- "host": "backend",
- "port": float64(8080),
- "scheme": "http",
- },
- },
- SSLEnabled: true,
- IsActive: true,
- }
-}
-
-// =============================================================================
-// Union Config Builder Tests
-// =============================================================================
-
-// TestBuildUnionACLConfig_MultipleGroupsIPRules tests that IP rules from multiple
-// ACL groups are correctly combined when building Caddyfile configuration.
-func TestBuildUnionACLConfig_MultipleGroupsIPRules(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Group 1 with deny rules for one subnet
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1-deny",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"},
- {ID: 2, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- }
-
- // Group 2 with deny rules for a different subnet
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2-deny",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 3, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.12.0/24"},
- {ID: 4, RuleType: models.ACLIPRuleTypeAllow, CIDR: "192.168.0.0/16"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Verify both groups have their deny rules
- assert.Contains(t, result, "10.0.10.0/24", "First group's deny rule should be present")
- assert.Contains(t, result, "10.0.12.0/24", "Second group's deny rule should be present")
-
- // Verify both groups have their allow rules
- assert.Contains(t, result, "10.0.0.0/8", "First group's allow rule should be present")
- assert.Contains(t, result, "192.168.0.0/16", "Second group's allow rule should be present")
-
- // Verify we have matchers for both groups
- assert.Contains(t, result, "@acl_0_", "First assignment should have acl_0 prefix")
- assert.Contains(t, result, "@acl_1_", "Second assignment should have acl_1 prefix")
-}
-
-// TestBuildUnionACLConfig_MultipleGroupsBypassRules tests that IP bypass rules
-// from multiple ACL groups are correctly combined.
-func TestBuildUnionACLConfig_MultipleGroupsBypassRules(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Group 1 with bypass rule for internal network
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1-bypass",
- CombinationMode: models.ACLCombinationModeIPBypass,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- Enabled: true,
- },
- }
-
- // Group 2 with bypass rule for different internal network
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2-bypass",
- CombinationMode: models.ACLCombinationModeIPBypass,
- IPRules: []models.ACLIPRule{
- {ID: 2, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.2.0/24"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Verify both bypass rules are present
- assert.Contains(t, result, "192.168.1.0/24", "First group's bypass range should be present")
- assert.Contains(t, result, "192.168.2.0/24", "Second group's bypass range should be present")
-
- // Verify bypass handlers are created for both
- assert.Contains(t, result, "@acl_0_bypass_ip", "First group should have bypass_ip matcher")
- assert.Contains(t, result, "@acl_1_bypass_ip", "Second group should have bypass_ip matcher")
-}
-
-// TestBuildUnionACLConfig_DifferentAuthMethods tests that different authentication
-// methods from multiple groups are all generated in the Caddyfile.
-func TestBuildUnionACLConfig_DifferentAuthMethods(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Group 1 with basic auth
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1-basicauth",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- }
-
- // Group 2 with Waygates auth (forward_auth)
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2-waygates",
- CombinationMode: models.ACLCombinationModeAny,
- WaygatesAuth: &models.ACLWaygatesAuth{
- Enabled: true,
- },
- }
-
- // Group 3 with external provider (Authelia)
- redirectURL := "https://auth.example.com/"
- group3 := &models.ACLGroup{
- ID: 3,
- Name: "group3-authelia",
- CombinationMode: models.ACLCombinationModeAny,
- ExternalProviders: []models.ACLExternalProvider{
- {
- ID: 1,
- ProviderType: models.ACLProviderTypeAuthelia,
- Name: "authelia",
- VerifyURL: "http://authelia:9091/api/verify",
- AuthRedirectURL: &redirectURL,
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/admin/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/api/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- {
- ID: 3,
- ProxyID: 1,
- ACLGroupID: 3,
- PathPattern: "/secure/*",
- Priority: 2,
- Enabled: true,
- ACLGroup: group3,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Verify basic auth is configured
- assert.Contains(t, result, "basicauth", "Basic auth directive should be present")
- assert.Contains(t, result, "admin", "Basic auth username should be present")
- assert.Contains(t, result, "/admin/*", "Basic auth path should be present")
-
- // Verify Waygates forward_auth is configured
- assert.Contains(t, result, "forward_auth http://waygates:8080", "Waygates forward_auth should be present")
- assert.Contains(t, result, "/api/auth/acl/verify", "Waygates verify URI should be present")
- assert.Contains(t, result, "/api/*", "Waygates path should be present")
-
- // Verify Authelia forward_auth is configured
- assert.Contains(t, result, "http://authelia:9091/api/verify", "Authelia verify URL should be present")
- assert.Contains(t, result, "Remote-User", "Authelia headers should be present")
- assert.Contains(t, result, "/secure/*", "Authelia path should be present")
-}
-
-// TestBuildUnionACLConfig_DeduplicateCIDRs tests that duplicate CIDRs
-// are handled appropriately when multiple groups have the same CIDR.
-func TestBuildUnionACLConfig_DeduplicateCIDRs(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Both groups have the same deny CIDR
- sharedCIDR := "10.0.0.0/8"
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: sharedCIDR},
- {ID: 2, RuleType: models.ACLIPRuleTypeAllow, CIDR: "192.168.0.0/16"},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 3, RuleType: models.ACLIPRuleTypeDeny, CIDR: sharedCIDR}, // Same CIDR as group1
- {ID: 4, RuleType: models.ACLIPRuleTypeAllow, CIDR: "172.16.0.0/12"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Verify the shared CIDR appears (at least once for each group's deny matcher)
- assert.Contains(t, result, sharedCIDR, "Shared CIDR should be present in config")
-
- // Each group should have its own denied_ips matcher
- assert.Contains(t, result, "@acl_0_denied_ips", "First group should have denied_ips matcher")
- assert.Contains(t, result, "@acl_1_denied_ips", "Second group should have denied_ips matcher")
-}
-
-// TestBuildUnionACLConfig_EmptyAssignmentsReturnsEmpty tests that an empty
-// list of assignments produces no config output.
-func TestBuildUnionACLConfig_EmptyAssignmentsReturnsEmpty(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // Test with nil
- result := builder.BuildACLConfig(proxy, nil)
- assert.Empty(t, result, "Nil assignments should return empty config")
-
- // Test with empty slice
- result = builder.BuildACLConfig(proxy, []models.ProxyACLAssignment{})
- assert.Empty(t, result, "Empty assignments should return empty config")
-}
-
-// TestBuildUnionACLConfig_OnlyDenyRules tests configuration generation when
-// groups only have deny rules without any allow rules.
-func TestBuildUnionACLConfig_OnlyDenyRules(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "deny-only",
- CombinationMode: models.ACLCombinationModeAny,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "1.2.3.0/24"},
- {ID: 2, RuleType: models.ACLIPRuleTypeDeny, CIDR: "4.5.6.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain deny matcher
- assert.Contains(t, result, "@acl_0_denied_ips", "Deny matcher should be present")
- assert.Contains(t, result, "1.2.3.0/24", "First deny CIDR should be present")
- assert.Contains(t, result, "4.5.6.0/24", "Second deny CIDR should be present")
- assert.Contains(t, result, "respond @acl_0_denied_ips", "Should respond 403 to denied IPs")
- assert.Contains(t, result, "403", "Should return 403 status")
-}
-
-// TestBuildUnionACLConfig_OnlyBypassRules tests configuration generation when
-// groups only have bypass rules.
-func TestBuildUnionACLConfig_OnlyBypassRules(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "bypass-only",
- CombinationMode: models.ACLCombinationModeIPBypass,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- {ID: 2, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.2.0/24"},
- },
- // Need some auth method for bypass to make sense
- WaygatesAuth: &models.ACLWaygatesAuth{
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain bypass matcher
- assert.Contains(t, result, "@acl_0_bypass_ip", "Bypass matcher should be present")
- assert.Contains(t, result, "192.168.1.0/24", "First bypass CIDR should be present")
- assert.Contains(t, result, "192.168.2.0/24", "Second bypass CIDR should be present")
- assert.Contains(t, result, "handle @acl_0_bypass_ip", "Should handle bypass IPs")
- assert.Contains(t, result, "reverse_proxy", "Should proxy to backend for bypass IPs")
-}
-
-// TestBuildUnionACLConfig_MixedRulesWithPriority tests that multiple groups
-// with mixed rules are generated with correct priority ordering.
-func TestBuildUnionACLConfig_MixedRulesWithPriority(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- // High priority group with basic auth
- highPriorityGroup := &models.ACLGroup{
- ID: 1,
- Name: "high-priority",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hash1"},
- },
- }
-
- // Low priority group with Waygates auth
- lowPriorityGroup := &models.ACLGroup{
- ID: 2,
- Name: "low-priority",
- CombinationMode: models.ACLCombinationModeAny,
- WaygatesAuth: &models.ACLWaygatesAuth{
- Enabled: true,
- },
- }
-
- // Assignments in reverse priority order to test sorting
- assignments := []models.ProxyACLAssignment{
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/*",
- Priority: 10, // Lower priority (processed second)
- Enabled: true,
- ACLGroup: lowPriorityGroup,
- },
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 1, // Higher priority (processed first)
- Enabled: true,
- ACLGroup: highPriorityGroup,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Verify both configurations are present
- assert.Contains(t, result, "basicauth", "Basic auth should be present")
- assert.Contains(t, result, "forward_auth", "Forward auth should be present")
-
- // Verify priority ordering: basic auth (priority 1) should come before forward_auth (priority 10)
- basicAuthIdx := strings.Index(result, "basicauth")
- forwardAuthIdx := strings.Index(result, "forward_auth")
-
- assert.True(t, basicAuthIdx > 0, "Basic auth should be in config")
- assert.True(t, forwardAuthIdx > 0, "Forward auth should be in config")
- assert.True(t, basicAuthIdx < forwardAuthIdx, "Higher priority (basic auth) should appear before lower priority (forward auth)")
-}
-
-// TestBuildUnionACLConfig_AllCombinationModes tests that all combination modes
-// are handled correctly when building union config.
-func TestBuildUnionACLConfig_AllCombinationModes(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "")
- proxy := createTestProxy()
-
- tests := []struct {
- name string
- combinationMode string
- hasIPRules bool
- hasAuth bool
- expectDeny bool
- expectBypass bool
- expectAuth bool
- }{
- {
- name: "any mode with IP allow",
- combinationMode: models.ACLCombinationModeAny,
- hasIPRules: true,
- hasAuth: false,
- expectDeny: false,
- expectBypass: false,
- expectAuth: false,
- },
- {
- name: "all mode requires both IP and auth",
- combinationMode: models.ACLCombinationModeAll,
- hasIPRules: true,
- hasAuth: true,
- expectDeny: false,
- expectBypass: false,
- expectAuth: true,
- },
- {
- name: "ip_bypass mode with bypass rules",
- combinationMode: models.ACLCombinationModeIPBypass,
- hasIPRules: true,
- hasAuth: true,
- expectDeny: false,
- expectBypass: true,
- expectAuth: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- group := &models.ACLGroup{
- ID: 1,
- Name: "test-group",
- CombinationMode: tt.combinationMode,
- }
-
- if tt.hasIPRules {
- if tt.combinationMode == models.ACLCombinationModeIPBypass {
- group.IPRules = []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "10.0.0.0/8"},
- }
- } else {
- group.IPRules = []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- }
- }
- }
-
- if tt.hasAuth {
- group.WaygatesAuth = &models.ACLWaygatesAuth{
- Enabled: true,
- }
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- if tt.expectBypass {
- assert.Contains(t, result, "bypass_ip", "Should have bypass_ip matcher for ip_bypass mode")
- }
-
- if tt.expectAuth {
- assert.Contains(t, result, "forward_auth", "Should have forward_auth for auth-enabled configs")
- }
-
- // All mode with IP allow should deny non-matching IPs
- if tt.combinationMode == models.ACLCombinationModeAll && tt.hasIPRules {
- assert.Contains(t, result, "not_allowed_ip", "ALL mode should deny non-matching IPs")
- }
- })
- }
-}
-
-// =============================================================================
-// BuildUnionACLConfig Tests - Union IP Rule Combination
-// =============================================================================
-
-// TestDeduplicateCIDRs tests the CIDR deduplication helper function.
-func TestDeduplicateCIDRs(t *testing.T) {
- tests := []struct {
- name string
- input []string
- expected []string
- }{
- {
- name: "empty input",
- input: []string{},
- expected: []string{},
- },
- {
- name: "no duplicates",
- input: []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"},
- expected: []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"},
- },
- {
- name: "with duplicates",
- input: []string{"10.0.0.0/8", "192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"},
- expected: []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"},
- },
- {
- name: "all duplicates",
- input: []string{"10.0.0.0/8", "10.0.0.0/8", "10.0.0.0/8"},
- expected: []string{"10.0.0.0/8"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := deduplicateCIDRs(tt.input)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
-// TestCollectUnionIPRules tests the IP rule collection function.
-func TestCollectUnionIPRules(t *testing.T) {
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"},
- {ID: 2, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- {ID: 3, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- IPRules: []models.ACLIPRule{
- {ID: 4, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.12.0/24"},
- {ID: 5, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.2.0/24"},
- {ID: 6, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"}, // Duplicate deny
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- denyRules, bypassRules, allowRules := collectUnionIPRules(assignments)
-
- // Verify deny rules are collected and deduplicated
- assert.Len(t, denyRules, 2, "Should have 2 unique deny rules")
- assert.Contains(t, denyRules, "10.0.10.0/24")
- assert.Contains(t, denyRules, "10.0.12.0/24")
-
- // Verify bypass rules are collected
- assert.Len(t, bypassRules, 2, "Should have 2 bypass rules")
- assert.Contains(t, bypassRules, "192.168.1.0/24")
- assert.Contains(t, bypassRules, "192.168.2.0/24")
-
- // Verify allow rules are collected
- assert.Len(t, allowRules, 1, "Should have 1 allow rule")
- assert.Contains(t, allowRules, "10.0.0.0/8")
-}
-
-// TestCollectUnionIPRules_DisabledAssignments tests that disabled assignments are skipped.
-func TestCollectUnionIPRules_DisabledAssignments(t *testing.T) {
- group := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: false, // Disabled
- ACLGroup: group,
- },
- }
-
- denyRules, bypassRules, allowRules := collectUnionIPRules(assignments)
-
- assert.Empty(t, denyRules, "Disabled assignment should not contribute deny rules")
- assert.Empty(t, bypassRules, "Disabled assignment should not contribute bypass rules")
- assert.Empty(t, allowRules, "Disabled assignment should not contribute allow rules")
-}
-
-// TestBuildUnionACLConfig_Empty tests that empty assignments return empty config.
-func TestBuildUnionACLConfig_Empty(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- // Test with nil
- result := builder.BuildUnionACLConfig(nil)
- assert.Empty(t, result, "Nil assignments should return empty config")
-
- // Test with empty slice
- result = builder.BuildUnionACLConfig([]models.ProxyACLAssignment{})
- assert.Empty(t, result, "Empty assignments should return empty config")
-}
-
-// TestBuildUnionACLConfig_AllDisabled tests that all-disabled assignments return empty config.
-func TestBuildUnionACLConfig_AllDisabled(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: false,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
- assert.Empty(t, result, "All disabled assignments should return empty config")
-}
-
-// TestBuildUnionACLConfig_DenyRulesOnly tests config generation with only deny rules.
-func TestBuildUnionACLConfig_DenyRulesOnly(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- IPRules: []models.ACLIPRule{
- {ID: 2, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.12.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Verify deny matcher is present
- assert.Contains(t, result, "@denied_ips", "Should have denied_ips matcher")
- assert.Contains(t, result, "remote_ip 10.0.10.0/24", "Should contain first deny CIDR")
- assert.Contains(t, result, "remote_ip 10.0.12.0/24", "Should contain second deny CIDR")
- assert.Contains(t, result, "respond @denied_ips 403", "Should respond 403 to denied IPs")
-
- // Verify forward_auth is present (since no bypass rules)
- assert.Contains(t, result, "forward_auth http://localhost:8080", "Should have forward_auth")
- assert.Contains(t, result, "uri /api/auth/acl/verify", "Should have verify URI")
-}
-
-// TestBuildUnionACLConfig_BypassRulesOnly tests config generation with only bypass rules.
-func TestBuildUnionACLConfig_BypassRulesOnly(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- {ID: 2, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.2.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Verify bypass matcher is present
- assert.Contains(t, result, "@bypass_ips", "Should have bypass_ips matcher")
- assert.Contains(t, result, "remote_ip 192.168.1.0/24", "Should contain first bypass CIDR")
- assert.Contains(t, result, "remote_ip 192.168.2.0/24", "Should contain second bypass CIDR")
-
- // Verify needs_auth matcher is present
- assert.Contains(t, result, "@needs_auth", "Should have needs_auth matcher")
- assert.Contains(t, result, "not {", "Should have not block in needs_auth")
-
- // Verify forward_auth is applied to needs_auth
- assert.Contains(t, result, "forward_auth @needs_auth", "Should apply forward_auth to needs_auth matcher")
-}
-
-// TestBuildUnionACLConfig_MixedRules tests config generation with deny and bypass rules.
-func TestBuildUnionACLConfig_MixedRules(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.10.0/24"},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- IPRules: []models.ACLIPRule{
- {ID: 2, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.12.0/24"},
- {ID: 3, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Verify deny matcher comes first
- denyIdx := strings.Index(result, "@denied_ips")
- bypassIdx := strings.Index(result, "@bypass_ips")
- assert.True(t, denyIdx < bypassIdx, "Deny matcher should come before bypass matcher")
-
- // Verify both deny rules are in a single matcher
- assert.Contains(t, result, "remote_ip 10.0.10.0/24", "Should contain first deny CIDR")
- assert.Contains(t, result, "remote_ip 10.0.12.0/24", "Should contain second deny CIDR")
-
- // Verify bypass rules
- assert.Contains(t, result, "remote_ip 192.168.1.0/24", "Should contain bypass CIDR")
-
- // Verify forward_auth with needs_auth
- assert.Contains(t, result, "forward_auth @needs_auth", "Should apply forward_auth to needs_auth")
-}
-
-// TestBuildUnionACLConfig_DuplicateCIDRsAcrossGroups tests that duplicate CIDRs are deduplicated.
-func TestBuildUnionACLConfig_DuplicateCIDRsAcrossGroups(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- sharedCIDR := "10.0.0.0/8"
-
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: sharedCIDR},
- },
- }
-
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "group2",
- IPRules: []models.ACLIPRule{
- {ID: 2, RuleType: models.ACLIPRuleTypeDeny, CIDR: sharedCIDR}, // Same as group1
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Count occurrences of the CIDR in the deny block
- // The CIDR should appear exactly once in the denied_ips matcher
- denyBlockStart := strings.Index(result, "@denied_ips")
- denyBlockEnd := strings.Index(result, "respond @denied_ips")
- denyBlock := result[denyBlockStart:denyBlockEnd]
-
- count := strings.Count(denyBlock, "remote_ip "+sharedCIDR)
- assert.Equal(t, 1, count, "Duplicate CIDR should appear only once in deny block")
-}
-
-// TestBuildUnionACLConfig_ForwardAuthHeaders tests that correct headers are included.
-func TestBuildUnionACLConfig_ForwardAuthHeaders(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Verify headers in forward_auth
- assert.Contains(t, result, "copy_headers", "Should have copy_headers directive")
- assert.Contains(t, result, "Remote-User", "Should copy Remote-User header")
- assert.Contains(t, result, "Remote-Groups", "Should copy Remote-Groups header")
- assert.Contains(t, result, "Remote-Email", "Should copy Remote-Email header")
- assert.Contains(t, result, "X-Forwarded-User", "Should copy X-Forwarded-User header")
-}
-
-// TestBuildUnionACLConfig_VerifyURL tests that the verify URL is correctly used.
-func TestBuildUnionACLConfig_VerifyURL(t *testing.T) {
- customVerifyURL := "http://waygates-service:8080"
- builder := NewACLBuilder(customVerifyURL, "")
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeAllow, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- assert.Contains(t, result, "forward_auth http://waygates-service:8080", "Should use custom verify URL")
- assert.Contains(t, result, "uri /api/auth/acl/verify", "Should have verify URI")
-}
-
-// TestBuildUnionACLConfig_NilACLGroup tests that assignments with nil ACLGroup are skipped.
-func TestBuildUnionACLConfig_NilACLGroup(t *testing.T) {
- builder := NewACLBuilder("http://localhost:8080", "")
-
- validGroup := &models.ACLGroup{
- ID: 1,
- Name: "group1",
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeDeny, CIDR: "10.0.0.0/8"},
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- Enabled: true,
- ACLGroup: nil, // Nil group should be skipped
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- Enabled: true,
- ACLGroup: validGroup,
- },
- }
-
- result := builder.BuildUnionACLConfig(assignments)
-
- // Should still produce config from valid group
- assert.Contains(t, result, "@denied_ips", "Should have denied_ips from valid group")
- assert.Contains(t, result, "10.0.0.0/8", "Should contain CIDR from valid group")
-}
-
-// =============================================================================
-// Basic Auth Override Tests - Caddyfile Generation
-// =============================================================================
-
-// TestBuildACLConfig_BasicAuthOnlyGeneratesBasicAuth tests that when a group has
-// ONLY basic auth users configured (no Waygates auth, no OAuth), the Caddyfile
-// should contain a "basicauth" directive.
-func TestBuildACLConfig_BasicAuthOnlyGeneratesBasicAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "basic-auth-only",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- {ID: 2, Username: "user", PasswordHash: "$2a$14$hashedpassword2"},
- },
- // No WaygatesAuth - nil
- // No ExternalProviders - empty
- // No OAuthProviderRestrictions - empty
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain basicauth directive since it's the only auth method
- assert.Contains(t, result, "basicauth", "Should contain basicauth directive when only basic auth is configured")
- assert.Contains(t, result, "admin $2a$14$hashedpassword1", "Should contain first user credentials")
- assert.Contains(t, result, "user $2a$14$hashedpassword2", "Should contain second user credentials")
-
- // Should NOT contain forward_auth since Waygates/OAuth are not configured
- assert.NotContains(t, result, "forward_auth", "Should NOT contain forward_auth when only basic auth is configured")
-}
-
-// TestBuildACLConfig_BasicAuthWithWaygatesGeneratesForwardAuth tests that when
-// a group has both basic auth users AND Waygates auth enabled, the Caddyfile
-// should contain "forward_auth" and NOT "basicauth".
-func TestBuildACLConfig_BasicAuthWithWaygatesGeneratesForwardAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "basic-plus-waygates",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain forward_auth since Waygates auth is enabled (secure auth overrides basic auth)
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth when Waygates auth is enabled")
- assert.Contains(t, result, "http://waygates:8080", "Should use Waygates verify URL")
- assert.Contains(t, result, "/api/auth/acl/verify", "Should have verify URI")
-
- // Should NOT contain basicauth since Waygates auth overrides it
- assert.NotContains(t, result, "basicauth", "Should NOT contain basicauth when Waygates auth is enabled")
-}
-
-// TestBuildACLConfig_BasicAuthWithOAuthGeneratesForwardAuth tests that when
-// a group has both basic auth users AND OAuth restrictions, the Caddyfile
-// should contain "forward_auth" and NOT "basicauth".
-func TestBuildACLConfig_BasicAuthWithOAuthGeneratesForwardAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "basic-plus-oauth",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- OAuthProviderRestrictions: []models.ACLOAuthProviderRestriction{
- {
- ID: 1,
- ACLGroupID: 1,
- Provider: "google",
- AllowedDomains: []string{"example.com"},
- Enabled: true,
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain forward_auth since OAuth restrictions are configured (secure auth overrides basic auth)
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth when OAuth restrictions are configured")
-
- // Should NOT contain basicauth since OAuth overrides it
- assert.NotContains(t, result, "basicauth", "Should NOT contain basicauth when OAuth restrictions are configured")
-}
-
-// TestBuildACLConfig_BasicAuthWithExternalProviderGeneratesForwardAuth tests that when
-// a group has both basic auth users AND external providers (Authelia/Authentik),
-// the Caddyfile should contain "forward_auth" and NOT "basicauth".
-func TestBuildACLConfig_BasicAuthWithExternalProviderGeneratesForwardAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- redirectURL := "https://auth.external.com/"
- group := &models.ACLGroup{
- ID: 1,
- Name: "basic-plus-external",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- ExternalProviders: []models.ACLExternalProvider{
- {
- ID: 1,
- ACLGroupID: 1,
- ProviderType: models.ACLProviderTypeAuthelia,
- Name: "authelia",
- VerifyURL: "http://authelia:9091/api/verify",
- AuthRedirectURL: &redirectURL,
- },
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Should contain forward_auth since external provider is configured (secure auth overrides basic auth)
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth when external provider is configured")
- assert.Contains(t, result, "http://authelia:9091/api/verify", "Should use Authelia verify URL")
-
- // Should NOT contain basicauth since external provider overrides it
- assert.NotContains(t, result, "basicauth", "Should NOT contain basicauth when external provider is configured")
-}
-
-// TestBuildACLConfig_AllModeBasicAuthWithWaygatesGeneratesForwardAuth tests the
-// basic auth override behavior in ACLCombinationModeAll.
-func TestBuildACLConfig_AllModeBasicAuthWithWaygatesGeneratesForwardAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "all-mode-mixed-auth",
- CombinationMode: models.ACLCombinationModeAll, // All auth methods must pass
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Even in ALL mode, forward_auth should be used instead of basicauth when secure auth is configured
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth in ALL mode when Waygates auth is enabled")
- assert.NotContains(t, result, "basicauth", "Should NOT contain basicauth in ALL mode when Waygates auth is enabled")
-}
-
-// TestBuildACLConfig_AllModeBasicAuthOnlyGeneratesBasicAuth tests that in ALL mode,
-// basicauth is still generated when it's the only auth method.
-func TestBuildACLConfig_AllModeBasicAuthOnlyGeneratesBasicAuth(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "all-mode-basic-only",
- CombinationMode: models.ACLCombinationModeAll,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- // No WaygatesAuth
- // No ExternalProviders
- // No OAuthProviderRestrictions
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // In ALL mode with only basic auth, basicauth should still be generated
- assert.Contains(t, result, "basicauth", "Should contain basicauth in ALL mode when only basic auth is configured")
- assert.NotContains(t, result, "forward_auth", "Should NOT contain forward_auth when only basic auth is configured")
-}
-
-// TestBuildACLConfig_IPBypassModeBasicAuthOverride tests the basic auth override
-// behavior in ACLCombinationModeIPBypass.
-func TestBuildACLConfig_IPBypassModeBasicAuthOverride(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- group := &models.ACLGroup{
- ID: 1,
- Name: "ip-bypass-mixed-auth",
- CombinationMode: models.ACLCombinationModeIPBypass,
- IPRules: []models.ACLIPRule{
- {ID: 1, RuleType: models.ACLIPRuleTypeBypass, CIDR: "192.168.1.0/24"},
- },
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 1,
- ACLGroupID: 1,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/protected/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // IP bypass rules should still work
- assert.Contains(t, result, "@acl_0_bypass_ip", "Should have bypass IP matcher")
- assert.Contains(t, result, "192.168.1.0/24", "Should contain bypass CIDR")
-
- // For non-bypass IPs, forward_auth should be used instead of basicauth
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth for non-bypass IPs when Waygates auth is enabled")
- assert.NotContains(t, result, "basicauth", "Should NOT contain basicauth when Waygates auth is enabled")
-}
-
-// TestBuildACLConfig_MultipleGroupsMixedBasicAuthOverride tests Caddyfile generation
-// with multiple ACL groups where some have basic auth only and others have secure auth.
-func TestBuildACLConfig_MultipleGroupsMixedBasicAuthOverride(t *testing.T) {
- builder := NewACLBuilder("http://waygates:8080", "https://auth.example.com/login")
- proxy := createTestProxy()
-
- // Group 1: Only basic auth (should generate basicauth)
- group1 := &models.ACLGroup{
- ID: 1,
- Name: "basic-auth-only",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 1, Username: "admin", PasswordHash: "$2a$14$hashedpassword1"},
- },
- }
-
- // Group 2: Basic auth + Waygates (should generate forward_auth, not basicauth)
- group2 := &models.ACLGroup{
- ID: 2,
- Name: "basic-plus-waygates",
- CombinationMode: models.ACLCombinationModeAny,
- BasicAuthUsers: []models.ACLBasicAuthUser{
- {ID: 2, Username: "user", PasswordHash: "$2a$14$hashedpassword2"},
- },
- WaygatesAuth: &models.ACLWaygatesAuth{
- ID: 2,
- ACLGroupID: 2,
- Enabled: true,
- },
- }
-
- assignments := []models.ProxyACLAssignment{
- {
- ID: 1,
- ProxyID: 1,
- ACLGroupID: 1,
- PathPattern: "/admin/*",
- Priority: 0,
- Enabled: true,
- ACLGroup: group1,
- },
- {
- ID: 2,
- ProxyID: 1,
- ACLGroupID: 2,
- PathPattern: "/api/*",
- Priority: 1,
- Enabled: true,
- ACLGroup: group2,
- },
- }
-
- result := builder.BuildACLConfig(proxy, assignments)
-
- // Group 1 should have basicauth (only basic auth configured)
- assert.Contains(t, result, "basicauth", "Should contain basicauth for group 1 (basic auth only)")
- assert.Contains(t, result, "admin $2a$14$hashedpassword1", "Should contain admin credentials")
- assert.Contains(t, result, "/admin/*", "Should have /admin/* path")
-
- // Group 2 should have forward_auth (Waygates overrides basic auth)
- assert.Contains(t, result, "forward_auth", "Should contain forward_auth for group 2 (Waygates enabled)")
- assert.Contains(t, result, "/api/*", "Should have /api/* path")
-
- // Group 2's basic auth user should NOT appear in basicauth block
- // (the forward_auth handles auth, not basicauth)
- // Note: We check that forward_auth block doesn't contain "user $2a$14"
- // by verifying the structure - forward_auth block should be separate from basicauth
-}
diff --git a/backend/internal/caddy/caddyfile/builder.go b/backend/internal/caddy/caddyfile/builder.go
deleted file mode 100644
index bda824a..0000000
--- a/backend/internal/caddy/caddyfile/builder.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package caddyfile
-
-import (
- "fmt"
- "regexp"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-// Builder generates Caddyfile content for different proxy types
-type Builder struct {
- logger *zap.Logger
- aclBuilder *ACLBuilder
-}
-
-// BuilderOptions holds configuration options for creating a new Builder
-type BuilderOptions struct {
- Logger *zap.Logger
- WaygatesVerifyURL string // URL for Waygates forward auth verification (internal, e.g., http://waygates:8080)
- WaygatesLoginURL string // URL for Waygates login page (external, e.g., https://waygates.company.com/auth/login)
-}
-
-// NewBuilder creates a new Caddyfile builder
-func NewBuilder(logger *zap.Logger) *Builder {
- return &Builder{
- logger: logger,
- aclBuilder: nil, // No ACL support by default for backward compatibility
- }
-}
-
-// NewBuilderWithOptions creates a new Caddyfile builder with full options
-func NewBuilderWithOptions(opts BuilderOptions) *Builder {
- var aclBuilder *ACLBuilder
- if opts.WaygatesVerifyURL != "" {
- aclBuilder = NewACLBuilder(opts.WaygatesVerifyURL, opts.WaygatesLoginURL)
- }
-
- logger := opts.Logger
- if logger == nil {
- logger = zap.NewNop()
- }
-
- return &Builder{
- logger: logger,
- aclBuilder: aclBuilder,
- }
-}
-
-// MainCaddyfileOptions holds options for building the main Caddyfile
-type MainCaddyfileOptions struct {
- Email string // Email for ACME certificate notifications
- ACMEProvider string // DNS provider: off, http, cloudflare, route53, duckdns, digitalocean, hetzner, porkbun, azure, vultr, namecheap, ovh
-}
-
-// BuildMainCaddyfile generates a Caddyfile with global options based on ACME provider.
-// The ACMEProvider option controls TLS certificate issuance:
-// - "off": Disable automatic HTTPS (default for development)
-// - "http": Use HTTP challenge (requires ports 80/443 open to internet)
-// - DNS providers: Use DNS challenge with the specified provider
-func (b *Builder) BuildMainCaddyfile(opts MainCaddyfileOptions) string {
- var sb strings.Builder
-
- sb.WriteString("# Managed by Waygates - DO NOT EDIT MANUALLY\n")
- sb.WriteString(fmt.Sprintf("# ACME Provider: %s\n", opts.ACMEProvider))
- sb.WriteString(fmt.Sprintf("# Generated: %s\n\n", time.Now().Format(time.RFC3339)))
-
- // Global options block
- sb.WriteString("{\n")
-
- // Persist certificates and ACME account in /data (Docker volume)
- sb.WriteString("\tstorage file_system /data\n")
-
- if opts.Email != "" {
- sb.WriteString(fmt.Sprintf("\temail %s\n", opts.Email))
- }
-
- // Configure ACME based on provider
- switch opts.ACMEProvider {
- case "off", "":
- sb.WriteString("\tauto_https off\n")
- case "http":
- // HTTP challenge - no additional config needed, Caddy handles it automatically
- default:
- // DNS challenge - add the provider-specific acme_dns directive
- acmeConfig := buildACMEDNSConfig(opts.ACMEProvider)
- if acmeConfig != "" {
- sb.WriteString(acmeConfig)
- }
- }
-
- sb.WriteString("\tadmin localhost:2019\n")
- sb.WriteString("}\n\n")
-
- // Import proxy configs
- sb.WriteString("import sites/*.conf\n\n")
-
- // Import catch-all (must be last)
- sb.WriteString("import catchall.conf\n")
-
- return sb.String()
-}
-
-// buildACMEDNSConfig returns the acme_dns directive configuration for the given provider.
-// Each DNS provider has a specific configuration format as per caddy-dns plugin documentation.
-// Uses {$VAR} syntax for parse-time environment variable substitution (more reliable than {env.VAR}).
-func buildACMEDNSConfig(provider string) string {
- switch provider {
- case "cloudflare":
- return "\tacme_dns cloudflare {$CLOUDFLARE_API_TOKEN}\n"
- case "route53":
- // Route53 uses AWS SDK which reads credentials from environment automatically
- return "\tacme_dns route53\n"
- case "duckdns":
- return "\tacme_dns duckdns {$DUCKDNS_API_TOKEN}\n"
- case "digitalocean":
- return "\tacme_dns digitalocean {$DO_AUTH_TOKEN}\n"
- case "hetzner":
- return "\tacme_dns hetzner {$HETZNER_API_TOKEN}\n"
- case "porkbun":
- return "\tacme_dns porkbun {\n\t\tapi_key {$PORKBUN_API_KEY}\n\t\tapi_secret_key {$PORKBUN_API_SECRET_KEY}\n\t}\n"
- case "azure":
- return "\tacme_dns azure {\n\t\ttenant_id {$AZURE_TENANT_ID}\n\t\tclient_id {$AZURE_CLIENT_ID}\n\t\tclient_secret {$AZURE_CLIENT_SECRET}\n\t\tsubscription_id {$AZURE_SUBSCRIPTION_ID}\n\t\tresource_group_name {$AZURE_RESOURCE_GROUP}\n\t}\n"
- case "vultr":
- return "\tacme_dns vultr {$VULTR_API_KEY}\n"
- case "namecheap":
- return "\tacme_dns namecheap {\n\t\tapi_key {$NAMECHEAP_API_KEY}\n\t\tuser {$NAMECHEAP_API_USER}\n\t}\n"
- case "ovh":
- return "\tacme_dns ovh {\n\t\tendpoint {$OVH_ENDPOINT}\n\t\tapplication_key {$OVH_APPLICATION_KEY}\n\t\tapplication_secret {$OVH_APPLICATION_SECRET}\n\t\tconsumer_key {$OVH_CONSUMER_KEY}\n\t}\n"
- default:
- return ""
- }
-}
-
-// BuildProxyFile generates config content for a single proxy without ACL support.
-// For ACL-enabled proxies, use BuildProxyFileWithACL instead.
-func (b *Builder) BuildProxyFile(proxy *models.Proxy) (string, error) {
- return b.BuildProxyFileWithACL(proxy, nil)
-}
-
-// BuildProxyFileWithACL generates config content for a single proxy with optional ACL support.
-// If aclAssignments is nil or empty, it generates standard proxy config without ACL.
-func (b *Builder) BuildProxyFileWithACL(proxy *models.Proxy, aclAssignments []models.ProxyACLAssignment) (string, error) {
- if proxy == nil {
- return "", fmt.Errorf("proxy is nil")
- }
-
- var content string
- var err error
-
- // Check if we have ACL assignments and ACL builder is configured
- hasAssignments := len(aclAssignments) > 0
- hasBuilder := b.aclBuilder != nil
- hasConfig := HasACLConfig(aclAssignments)
- hasACL := hasAssignments && hasBuilder && hasConfig
-
- b.logger.Debug("Building proxy config with ACL check",
- zap.Int("proxy_id", proxy.ID),
- zap.String("proxy_name", proxy.Name),
- zap.Int("acl_assignments_count", len(aclAssignments)),
- zap.Bool("has_assignments", hasAssignments),
- zap.Bool("has_acl_builder", hasBuilder),
- zap.Bool("has_acl_config", hasConfig),
- zap.Bool("has_acl", hasACL),
- )
-
- switch proxy.Type {
- case models.ProxyTypeReverseProxy:
- if hasACL {
- content, err = b.buildReverseProxyBlockWithACL(proxy, aclAssignments)
- } else {
- content, err = b.buildReverseProxyBlock(proxy)
- }
- case models.ProxyTypeStatic:
- // Static proxies currently don't support ACL
- content, err = b.buildStaticBlock(proxy)
- case models.ProxyTypeRedirect:
- // Redirect proxies currently don't support ACL
- content, err = b.buildRedirectBlock(proxy)
- default:
- return "", fmt.Errorf("unknown proxy type: %s", proxy.Type)
- }
-
- if err != nil {
- return "", err
- }
-
- // Add header comment
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf("# Proxy ID: %d\n", proxy.ID))
- sb.WriteString(fmt.Sprintf("# Name: %s\n", proxy.Name))
- sb.WriteString(fmt.Sprintf("# Type: %s\n", proxy.Type))
- if hasACL {
- sb.WriteString(fmt.Sprintf("# ACL Enabled: true (%d assignments)\n", len(aclAssignments)))
- }
- sb.WriteString(fmt.Sprintf("# Updated: %s\n\n", time.Now().Format(time.RFC3339)))
- sb.WriteString(content)
-
- return sb.String(), nil
-}
-
-// BuildCatchAllFile generates the catch-all 404 config
-func (b *Builder) BuildCatchAllFile(settings *models.NotFoundSettings) string {
- var sb strings.Builder
-
- sb.WriteString("# Catch-all 404 handler\n")
- sb.WriteString(fmt.Sprintf("# Mode: %s\n", settings.Mode))
- sb.WriteString(fmt.Sprintf("# Updated: %s\n\n", time.Now().Format(time.RFC3339)))
-
- // Catch-all on port 80 only - specific domains handle their own HTTPS
- sb.WriteString(":80 {\n")
-
- if settings.Mode == "redirect" && settings.RedirectURL != "" {
- sb.WriteString(fmt.Sprintf("\tredir %s 302\n", settings.RedirectURL))
- } else {
- // Default mode: respond with 404
- sb.WriteString("\trespond \"Not Found\" 404\n")
- }
-
- sb.WriteString("}\n")
-
- return sb.String()
-}
-
-// GetProxyFilename returns the filename for a proxy
-// Format: {id}_{sanitized_hostname}.conf
-func (b *Builder) GetProxyFilename(proxy *models.Proxy) string {
- sanitized := sanitizeFilename(proxy.Hostname)
- return fmt.Sprintf("%d_%s.conf", proxy.ID, sanitized)
-}
-
-// GetDisabledFilename returns the disabled filename for a proxy
-func (b *Builder) GetDisabledFilename(proxy *models.Proxy) string {
- return b.GetProxyFilename(proxy) + ".disabled"
-}
-
-// formatSiteAddress returns the site address for Caddyfile
-// If SSL is disabled, returns http://hostname to prevent auto-HTTPS
-func formatSiteAddress(hostname string, sslEnabled bool) string {
- if !sslEnabled {
- return "http://" + hostname
- }
- return hostname
-}
-
-// sanitizeFilename removes unsafe characters from filename
-func sanitizeFilename(name string) string {
- // Replace dots with underscores, remove other unsafe chars
- reg := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
- sanitized := reg.ReplaceAllString(name, "_")
- // Remove consecutive underscores
- reg = regexp.MustCompile(`_+`)
- sanitized = reg.ReplaceAllString(sanitized, "_")
- // Trim underscores from ends
- sanitized = strings.Trim(sanitized, "_")
- return sanitized
-}
diff --git a/backend/internal/caddy/caddyfile/builder_test.go b/backend/internal/caddy/caddyfile/builder_test.go
deleted file mode 100644
index efe39dd..0000000
--- a/backend/internal/caddy/caddyfile/builder_test.go
+++ /dev/null
@@ -1,890 +0,0 @@
-package caddyfile
-
-import (
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-func newTestBuilder() *Builder {
- return NewBuilder(zap.NewNop())
-}
-
-func TestBuildMainCaddyfile_Cloudflare(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{
- Email: "admin@example.com",
- ACMEProvider: "cloudflare",
- })
-
- // Check header
- if !strings.Contains(content, "Managed by Waygates") {
- t.Error("Expected header comment")
- }
-
- // Check email
- if !strings.Contains(content, "email admin@example.com") {
- t.Error("Expected email directive")
- }
-
- // Check Cloudflare ACME DNS
- if !strings.Contains(content, "acme_dns cloudflare {$CLOUDFLARE_API_TOKEN}") {
- t.Error("Expected acme_dns cloudflare directive")
- }
-
- if !strings.Contains(content, "admin localhost:2019") {
- t.Error("Expected admin localhost:2019")
- }
-
- // Check imports
- if !strings.Contains(content, "import sites/*.conf") {
- t.Error("Expected sites import")
- }
-
- if !strings.Contains(content, "import catchall.conf") {
- t.Error("Expected catchall import")
- }
-}
-
-func TestBuildMainCaddyfile_NoEmail(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{ACMEProvider: "off"})
-
- // Should not contain email directive
- if strings.Contains(content, "email ") {
- t.Error("Should not have email directive when email is empty")
- }
-}
-
-func TestBuildMainCaddyfile_ProviderOff(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{ACMEProvider: "off"})
-
- // Should have auto_https off
- if !strings.Contains(content, "auto_https off") {
- t.Error("Expected auto_https off directive")
- }
-
- // Should not have acme_dns
- if strings.Contains(content, "acme_dns") {
- t.Error("Should not have acme_dns when provider is off")
- }
-}
-
-func TestBuildMainCaddyfile_ProviderHTTP(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{
- Email: "admin@example.com",
- ACMEProvider: "http",
- })
-
- // Should NOT have auto_https off (HTTP challenge uses automatic HTTPS)
- if strings.Contains(content, "auto_https off") {
- t.Error("Should not have auto_https off for HTTP challenge")
- }
-
- // Should not have acme_dns (HTTP challenge is default)
- if strings.Contains(content, "acme_dns") {
- t.Error("Should not have acme_dns for HTTP challenge")
- }
-}
-
-func TestBuildMainCaddyfile_Route53(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{
- Email: "admin@example.com",
- ACMEProvider: "route53",
- })
-
- // Route53 reads AWS credentials from environment
- if !strings.Contains(content, "acme_dns route53") {
- t.Error("Expected acme_dns route53 directive")
- }
-}
-
-func TestBuildMainCaddyfile_Porkbun(t *testing.T) {
- builder := newTestBuilder()
-
- content := builder.BuildMainCaddyfile(MainCaddyfileOptions{
- Email: "admin@example.com",
- ACMEProvider: "porkbun",
- })
-
- // Porkbun has block format with api_key and api_secret_key
- if !strings.Contains(content, "acme_dns porkbun {") {
- t.Error("Expected acme_dns porkbun block")
- }
- if !strings.Contains(content, "api_key {$PORKBUN_API_KEY}") {
- t.Error("Expected api_key directive")
- }
- if !strings.Contains(content, "api_secret_key {$PORKBUN_API_SECRET_KEY}") {
- t.Error("Expected api_secret_key directive")
- }
-}
-
-func TestBuildReverseProxy_Basic(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 1,
- Name: "Test API",
- Hostname: "api.example.com",
- Type: models.ProxyTypeReverseProxy,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Check header
- if !strings.Contains(content, "# Proxy ID: 1") {
- t.Error("Expected proxy ID comment")
- }
- if !strings.Contains(content, "# Name: Test API") {
- t.Error("Expected name comment")
- }
-
- // Check site block
- if !strings.Contains(content, "api.example.com {") {
- t.Error("Expected hostname site block")
- }
-
- // Check reverse_proxy directive
- if !strings.Contains(content, "reverse_proxy backend:8080") {
- t.Error("Expected reverse_proxy directive with upstream")
- }
-
- // Check standard headers
- if !strings.Contains(content, "header_up X-Real-IP") {
- t.Error("Expected X-Real-IP header")
- }
- if !strings.Contains(content, "header_up X-Forwarded-For") {
- t.Error("Expected X-Forwarded-For header")
- }
-}
-
-func TestBuildReverseProxy_BlockExploits(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 1,
- Name: "Secure API",
- Hostname: "secure.example.com",
- Type: models.ProxyTypeReverseProxy,
- BlockExploits: true,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Check security snippet is imported
- if !strings.Contains(content, "import /etc/caddy/snippets/security.caddy") {
- t.Error("Expected security snippet import when BlockExploits is true")
- }
-}
-
-func TestBuildReverseProxy_NoBlockExploits(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 1,
- Name: "Open API",
- Hostname: "open.example.com",
- Type: models.ProxyTypeReverseProxy,
- BlockExploits: false,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Check security snippet is NOT imported
- if strings.Contains(content, "import /etc/caddy/snippets/security.caddy") {
- t.Error("Should not have security snippet import when BlockExploits is false")
- }
-}
-
-func TestBuildReverseProxy_MultipleUpstreams(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 2,
- Name: "Load Balanced",
- Hostname: "lb.example.com",
- Type: models.ProxyTypeReverseProxy,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend1", "port": float64(8080), "scheme": "http"},
- map[string]interface{}{"host": "backend2", "port": float64(8080), "scheme": "http"},
- map[string]interface{}{"host": "backend3", "port": float64(8080), "scheme": "http"},
- },
- LoadBalancing: models.JSONField{
- "strategy": "round_robin",
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Check all upstreams
- if !strings.Contains(content, "backend1:8080 backend2:8080 backend3:8080") {
- t.Error("Expected all upstreams in reverse_proxy directive")
- }
-
- // Check load balancing
- if !strings.Contains(content, "lb_policy round_robin") {
- t.Error("Expected load balancing policy")
- }
-}
-
-func TestBuildReverseProxy_HealthChecks(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 3,
- Name: "With Health Checks",
- Hostname: "hc.example.com",
- Type: models.ProxyTypeReverseProxy,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- LoadBalancing: models.JSONField{
- "strategy": "round_robin",
- "health_checks": map[string]interface{}{
- "enabled": true,
- "path": "/health",
- "interval": "30s",
- "timeout": "10s",
- },
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "health_uri /health") {
- t.Error("Expected health_uri directive")
- }
- if !strings.Contains(content, "health_interval 30s") {
- t.Error("Expected health_interval directive")
- }
- if !strings.Contains(content, "health_timeout 10s") {
- t.Error("Expected health_timeout directive")
- }
-}
-
-func TestBuildReverseProxy_HTTPSUpstream(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 4,
- Name: "HTTPS Backend",
- Hostname: "secure.example.com",
- Type: models.ProxyTypeReverseProxy,
- TLSInsecureSkipVerify: true,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "secure-backend", "port": float64(443), "scheme": "https"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "transport http {") {
- t.Error("Expected transport block")
- }
- if !strings.Contains(content, "tls") {
- t.Error("Expected tls in transport")
- }
- if !strings.Contains(content, "tls_insecure_skip_verify") {
- t.Error("Expected tls_insecure_skip_verify")
- }
-}
-
-func TestBuildReverseProxy_CustomHeaders(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 5,
- Name: "Custom Headers",
- Hostname: "headers.example.com",
- Type: models.ProxyTypeReverseProxy,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- CustomHeaders: models.JSONField{
- "X-Custom-Header": "custom-value",
- "X-API-Key": "secret123",
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "header_up X-Custom-Header \"custom-value\"") {
- t.Error("Expected custom header")
- }
- if !strings.Contains(content, "header_up X-API-Key \"secret123\"") {
- t.Error("Expected API key header")
- }
-}
-
-func TestBuildReverseProxy_NoUpstreams(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 6,
- Name: "No Upstreams",
- Hostname: "empty.example.com",
- Type: models.ProxyTypeReverseProxy,
- Upstreams: []interface{}{},
- }
-
- _, err := builder.BuildProxyFile(proxy)
- if err == nil {
- t.Error("Expected error for reverse proxy without upstreams")
- }
-}
-
-func TestBuildStatic_Basic(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 10,
- Name: "Static Files",
- Hostname: "static.example.com",
- Type: models.ProxyTypeStatic,
- StaticConfig: models.JSONField{
- "root_path": "/var/www/static",
- "index_file": "index.html",
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "static.example.com {") {
- t.Error("Expected hostname site block")
- }
- if !strings.Contains(content, "root * /var/www/static") {
- t.Error("Expected root directive")
- }
- if !strings.Contains(content, "file_server") {
- t.Error("Expected file_server directive")
- }
- if !strings.Contains(content, "index index.html") {
- t.Error("Expected index file")
- }
-}
-
-func TestBuildStatic_SPA(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 11,
- Name: "SPA App",
- Hostname: "app.example.com",
- Type: models.ProxyTypeStatic,
- StaticConfig: models.JSONField{
- "root_path": "/var/www/spa",
- "index_file": "index.html",
- "try_files": []interface{}{"/index.html"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "try_files {path} /index.html") {
- t.Error("Expected try_files directive for SPA")
- }
-}
-
-func TestBuildStatic_Templates(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 12,
- Name: "Template Site",
- Hostname: "templates.example.com",
- Type: models.ProxyTypeStatic,
- StaticConfig: models.JSONField{
- "root_path": "/var/www/templates",
- "template_rendering": true,
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "templates") {
- t.Error("Expected templates directive")
- }
-}
-
-func TestBuildStatic_NoConfig(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 13,
- Name: "No Config",
- Hostname: "noconfig.example.com",
- Type: models.ProxyTypeStatic,
- }
-
- _, err := builder.BuildProxyFile(proxy)
- if err == nil {
- t.Error("Expected error for static proxy without config")
- }
-}
-
-func TestBuildRedirect_Basic(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 20,
- Name: "Basic Redirect",
- Hostname: "old.example.com",
- Type: models.ProxyTypeRedirect,
- RedirectConfig: models.JSONField{
- "target": "https://new.example.com",
- "status_code": float64(301),
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "old.example.com {") {
- t.Error("Expected hostname site block")
- }
- if !strings.Contains(content, "redir https://new.example.com permanent") {
- t.Error("Expected redirect directive with permanent status")
- }
-}
-
-func TestBuildRedirect_PreservePath(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 21,
- Name: "Preserve Path",
- Hostname: "path.example.com",
- Type: models.ProxyTypeRedirect,
- RedirectConfig: models.JSONField{
- "target": "https://new.example.com",
- "status_code": float64(302),
- "preserve_path": true,
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- if !strings.Contains(content, "redir https://new.example.com{uri} temporary") {
- t.Error("Expected redirect with {uri} placeholder and temporary status")
- }
-}
-
-func TestBuildRedirect_StatusCodes(t *testing.T) {
- builder := newTestBuilder()
-
- testCases := []struct {
- code float64
- expected string
- }{
- {301, "permanent"},
- {302, "temporary"},
- {303, "303"},
- {307, "307"},
- {308, "308"},
- }
-
- for _, tc := range testCases {
- proxy := &models.Proxy{
- ID: 30,
- Name: "Status Test",
- Hostname: "status.example.com",
- Type: models.ProxyTypeRedirect,
- RedirectConfig: models.JSONField{
- "target": "https://target.example.com",
- "status_code": tc.code,
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error for status %v: %v", tc.code, err)
- }
-
- if !strings.Contains(content, tc.expected) {
- t.Errorf("Expected status keyword '%s' for code %v, got: %s", tc.expected, tc.code, content)
- }
- }
-}
-
-func TestBuildRedirect_NoConfig(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 40,
- Name: "No Config",
- Hostname: "noconfig.example.com",
- Type: models.ProxyTypeRedirect,
- }
-
- _, err := builder.BuildProxyFile(proxy)
- if err == nil {
- t.Error("Expected error for redirect proxy without config")
- }
-}
-
-func TestBuildCatchAll_Default(t *testing.T) {
- builder := newTestBuilder()
-
- settings := &models.NotFoundSettings{
- Mode: "default",
- }
-
- content := builder.BuildCatchAllFile(settings)
-
- // Catch-all uses port 80 only - specific domains handle their own HTTPS
- if !strings.Contains(content, ":80 {") {
- t.Error("Expected :80 catch-all block")
- }
- if !strings.Contains(content, "respond \"Not Found\" 404") {
- t.Error("Expected 404 respond directive")
- }
-}
-
-func TestBuildCatchAll_Redirect(t *testing.T) {
- builder := newTestBuilder()
-
- settings := &models.NotFoundSettings{
- Mode: "redirect",
- RedirectURL: "https://example.com/404-page",
- }
-
- content := builder.BuildCatchAllFile(settings)
-
- // Catch-all uses port 80 only
- if !strings.Contains(content, ":80 {") {
- t.Error("Expected :80 catch-all block")
- }
- if !strings.Contains(content, "redir https://example.com/404-page 302") {
- t.Error("Expected redirect directive")
- }
- if strings.Contains(content, "respond") {
- t.Error("Should not have respond directive in redirect mode")
- }
-}
-
-func TestGetProxyFilename(t *testing.T) {
- builder := newTestBuilder()
-
- testCases := []struct {
- proxy *models.Proxy
- expected string
- }{
- {
- proxy: &models.Proxy{ID: 1, Hostname: "api.example.com"},
- expected: "1_api_example_com.conf",
- },
- {
- proxy: &models.Proxy{ID: 42, Hostname: "my-app.example.com"},
- expected: "42_my-app_example_com.conf",
- },
- {
- proxy: &models.Proxy{ID: 100, Hostname: "test"},
- expected: "100_test.conf",
- },
- }
-
- for _, tc := range testCases {
- result := builder.GetProxyFilename(tc.proxy)
- if result != tc.expected {
- t.Errorf("GetProxyFilename(%v) = %s, expected %s", tc.proxy.Hostname, result, tc.expected)
- }
- }
-}
-
-func TestGetDisabledFilename(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{ID: 5, Hostname: "disabled.example.com"}
- expected := "5_disabled_example_com.conf.disabled"
-
- result := builder.GetDisabledFilename(proxy)
- if result != expected {
- t.Errorf("GetDisabledFilename() = %s, expected %s", result, expected)
- }
-}
-
-func TestSanitizeFilename(t *testing.T) {
- testCases := []struct {
- input string
- expected string
- }{
- {"api.example.com", "api_example_com"},
- {"my-app.test.com", "my-app_test_com"},
- {"simple", "simple"},
- {"with spaces", "with_spaces"},
- {"special!@#chars", "special_chars"},
- {"multiple...dots", "multiple_dots"},
- {"_leading_", "leading"},
- }
-
- for _, tc := range testCases {
- result := sanitizeFilename(tc.input)
- if result != tc.expected {
- t.Errorf("sanitizeFilename(%s) = %s, expected %s", tc.input, result, tc.expected)
- }
- }
-}
-
-func TestBuildProxyFile_UnknownType(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 999,
- Name: "Unknown",
- Hostname: "unknown.example.com",
- Type: "invalid_type",
- }
-
- _, err := builder.BuildProxyFile(proxy)
- if err == nil {
- t.Error("Expected error for unknown proxy type")
- }
-}
-
-func TestBuildProxyFile_NilProxy(t *testing.T) {
- builder := newTestBuilder()
-
- _, err := builder.BuildProxyFile(nil)
- if err == nil {
- t.Error("Expected error for nil proxy")
- }
-}
-
-func TestBuildReverseProxy_SSLEnabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 1,
- Name: "SSL Enabled",
- Hostname: "ssl.example.com",
- Type: models.ProxyTypeReverseProxy,
- SSLEnabled: true,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use hostname directly (Caddy auto-HTTPS)
- if !strings.Contains(content, "ssl.example.com {") {
- t.Error("Expected hostname site block without http:// prefix")
- }
- if strings.Contains(content, "http://ssl.example.com") {
- t.Error("Should not have http:// prefix when SSL is enabled")
- }
-}
-
-func TestBuildReverseProxy_SSLDisabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 1,
- Name: "SSL Disabled",
- Hostname: "nossl.example.com",
- Type: models.ProxyTypeReverseProxy,
- SSLEnabled: false,
- Upstreams: []interface{}{
- map[string]interface{}{"host": "backend", "port": float64(8080), "scheme": "http"},
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use http:// prefix to disable auto-HTTPS
- if !strings.Contains(content, "http://nossl.example.com {") {
- t.Error("Expected http:// prefix when SSL is disabled")
- }
-}
-
-func TestBuildRedirect_SSLEnabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 20,
- Name: "SSL Redirect",
- Hostname: "ssl-redirect.example.com",
- Type: models.ProxyTypeRedirect,
- SSLEnabled: true,
- RedirectConfig: models.JSONField{
- "target": "https://new.example.com",
- "status_code": float64(301),
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use hostname directly
- if !strings.Contains(content, "ssl-redirect.example.com {") {
- t.Error("Expected hostname site block without http:// prefix")
- }
- if strings.Contains(content, "http://ssl-redirect.example.com") {
- t.Error("Should not have http:// prefix when SSL is enabled")
- }
-}
-
-func TestBuildRedirect_SSLDisabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 21,
- Name: "No SSL Redirect",
- Hostname: "nossl-redirect.example.com",
- Type: models.ProxyTypeRedirect,
- SSLEnabled: false,
- RedirectConfig: models.JSONField{
- "target": "https://new.example.com",
- "status_code": float64(301),
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use http:// prefix
- if !strings.Contains(content, "http://nossl-redirect.example.com {") {
- t.Error("Expected http:// prefix when SSL is disabled")
- }
-}
-
-func TestBuildStatic_SSLEnabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 10,
- Name: "SSL Static",
- Hostname: "ssl-static.example.com",
- Type: models.ProxyTypeStatic,
- SSLEnabled: true,
- StaticConfig: models.JSONField{
- "root_path": "/var/www/static",
- "index_file": "index.html",
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use hostname directly
- if !strings.Contains(content, "ssl-static.example.com {") {
- t.Error("Expected hostname site block without http:// prefix")
- }
- if strings.Contains(content, "http://ssl-static.example.com") {
- t.Error("Should not have http:// prefix when SSL is enabled")
- }
-}
-
-func TestBuildStatic_SSLDisabled(t *testing.T) {
- builder := newTestBuilder()
-
- proxy := &models.Proxy{
- ID: 11,
- Name: "No SSL Static",
- Hostname: "nossl-static.example.com",
- Type: models.ProxyTypeStatic,
- SSLEnabled: false,
- StaticConfig: models.JSONField{
- "root_path": "/var/www/static",
- "index_file": "index.html",
- },
- }
-
- content, err := builder.BuildProxyFile(proxy)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
- }
-
- // Should use http:// prefix
- if !strings.Contains(content, "http://nossl-static.example.com {") {
- t.Error("Expected http:// prefix when SSL is disabled")
- }
-}
-
-func TestFormatSiteAddress(t *testing.T) {
- testCases := []struct {
- hostname string
- sslEnabled bool
- expected string
- }{
- {"example.com", true, "example.com"},
- {"example.com", false, "http://example.com"},
- {"api.example.com", true, "api.example.com"},
- {"api.example.com", false, "http://api.example.com"},
- }
-
- for _, tc := range testCases {
- result := formatSiteAddress(tc.hostname, tc.sslEnabled)
- if result != tc.expected {
- t.Errorf("formatSiteAddress(%s, %v) = %s, expected %s",
- tc.hostname, tc.sslEnabled, result, tc.expected)
- }
- }
-}
diff --git a/backend/internal/caddy/caddyfile/interfaces.go b/backend/internal/caddy/caddyfile/interfaces.go
deleted file mode 100644
index e880d21..0000000
--- a/backend/internal/caddy/caddyfile/interfaces.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package caddyfile
-
-import "github.com/aloks98/waygates/backend/internal/models"
-
-// BuilderInterface defines the interface for Caddyfile generation
-type BuilderInterface interface {
- BuildMainCaddyfile(opts MainCaddyfileOptions) string
- BuildProxyFile(proxy *models.Proxy) (string, error)
- BuildProxyFileWithACL(proxy *models.Proxy, aclAssignments []models.ProxyACLAssignment) (string, error)
- BuildCatchAllFile(settings *models.NotFoundSettings) string
- GetProxyFilename(proxy *models.Proxy) string
-}
-
-// ACLBuilderInterface defines the interface for ACL configuration generation
-type ACLBuilderInterface interface {
- BuildACLConfig(proxy *models.Proxy, assignments []models.ProxyACLAssignment) string
-}
-
-// Ensure Builder implements BuilderInterface
-var _ BuilderInterface = (*Builder)(nil)
-
-// Ensure ACLBuilder implements ACLBuilderInterface
-var _ ACLBuilderInterface = (*ACLBuilder)(nil)
diff --git a/backend/internal/caddy/caddyfile/redirect.go b/backend/internal/caddy/caddyfile/redirect.go
deleted file mode 100644
index f438c45..0000000
--- a/backend/internal/caddy/caddyfile/redirect.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package caddyfile
-
-import (
- "fmt"
- "strings"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-// buildRedirectBlock generates a redirect site block
-func (b *Builder) buildRedirectBlock(proxy *models.Proxy) (string, error) {
- if len(proxy.RedirectConfig) == 0 {
- return "", fmt.Errorf("redirect proxy requires redirect configuration")
- }
-
- target, _ := proxy.RedirectConfig["target"].(string)
- if target == "" {
- return "", fmt.Errorf("redirect proxy requires target URL")
- }
-
- var sb strings.Builder
-
- // Site block with hostname (use http:// prefix if SSL disabled)
- siteAddr := formatSiteAddress(proxy.Hostname, proxy.SSLEnabled)
- sb.WriteString(fmt.Sprintf("%s {\n", siteAddr))
-
- // Build redirect target with optional path/query preservation
- redirectTarget := b.buildRedirectTarget(proxy.RedirectConfig)
-
- // Status code (default to 301 permanent)
- statusCode := 301
- if sc, ok := proxy.RedirectConfig["status_code"].(float64); ok && sc > 0 {
- statusCode = int(sc)
- }
-
- // Map status code to Caddy keyword or use number
- statusKeyword := mapRedirectStatus(statusCode)
-
- sb.WriteString(fmt.Sprintf("\tredir %s %s\n", redirectTarget, statusKeyword))
- sb.WriteString("}\n")
-
- return sb.String(), nil
-}
-
-// buildRedirectTarget constructs the redirect target URL with optional placeholders
-func (b *Builder) buildRedirectTarget(redirect models.JSONField) string {
- target, _ := redirect["target"].(string)
-
- preservePath, _ := redirect["preserve_path"].(bool)
- preserveQuery, _ := redirect["preserve_query"].(bool)
-
- // Add path preservation
- if preservePath {
- // Remove trailing slash from target to avoid double slashes
- target = strings.TrimSuffix(target, "/")
- target += "{uri}"
- }
-
- // If only preserving query (not path), append query placeholder
- if !preservePath && preserveQuery {
- if !strings.Contains(target, "?") {
- target += "?{query}"
- } else {
- target += "&{query}"
- }
- }
-
- return target
-}
-
-// mapRedirectStatus maps HTTP status codes to Caddy keywords
-func mapRedirectStatus(code int) string {
- switch code {
- case 301:
- return "permanent"
- case 302:
- return "temporary"
- case 303:
- return "303"
- case 307:
- return "307"
- case 308:
- return "308"
- default:
- return fmt.Sprintf("%d", code)
- }
-}
diff --git a/backend/internal/caddy/caddyfile/reverse_proxy.go b/backend/internal/caddy/caddyfile/reverse_proxy.go
deleted file mode 100644
index 0fce757..0000000
--- a/backend/internal/caddy/caddyfile/reverse_proxy.go
+++ /dev/null
@@ -1,244 +0,0 @@
-package caddyfile
-
-import (
- "fmt"
- "strings"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-// buildReverseProxyBlock generates a reverse proxy site block
-func (b *Builder) buildReverseProxyBlock(proxy *models.Proxy) (string, error) {
- if proxy.Upstreams == nil {
- return "", fmt.Errorf("reverse proxy requires at least one upstream")
- }
-
- // Parse upstreams from interface{}
- upstreams, ok := proxy.Upstreams.([]interface{})
- if !ok || len(upstreams) == 0 {
- return "", fmt.Errorf("reverse proxy requires at least one upstream")
- }
-
- var sb strings.Builder
-
- // Site block with hostname (use http:// prefix if SSL disabled)
- siteAddr := formatSiteAddress(proxy.Hostname, proxy.SSLEnabled)
- sb.WriteString(fmt.Sprintf("%s {\n", siteAddr))
-
- // Import security snippet if block exploits is enabled
- if proxy.BlockExploits {
- sb.WriteString("\timport /etc/caddy/snippets/security.caddy\n\n")
- }
-
- // Build upstream list
- upstreamAddrs, hasHTTPS := b.buildUpstreamList(upstreams)
-
- // reverse_proxy directive
- sb.WriteString(fmt.Sprintf("\treverse_proxy %s {\n", upstreamAddrs))
-
- // Load balancing config
- if len(proxy.LoadBalancing) > 0 {
- b.writeLoadBalancingConfig(&sb, proxy.LoadBalancing)
- }
-
- // Health checks
- if len(proxy.LoadBalancing) > 0 {
- if healthChecks, ok := proxy.LoadBalancing["health_checks"].(map[string]interface{}); ok {
- if enabled, _ := healthChecks["enabled"].(bool); enabled {
- b.writeHealthCheckConfig(&sb, healthChecks)
- }
- }
- }
-
- // Transport config for HTTPS upstreams
- if hasHTTPS || proxy.TLSInsecureSkipVerify {
- b.writeTransportConfig(&sb, hasHTTPS, proxy.TLSInsecureSkipVerify)
- }
-
- // Standard headers
- sb.WriteString("\t\theader_up X-Real-IP {remote_host}\n")
- sb.WriteString("\t\theader_up X-Forwarded-For {remote_host}\n")
- sb.WriteString("\t\theader_up X-Forwarded-Proto {scheme}\n")
- sb.WriteString("\t\theader_up X-Forwarded-Host {host}\n")
-
- // Custom headers
- if len(proxy.CustomHeaders) > 0 {
- for key, value := range proxy.CustomHeaders {
- if strVal, ok := value.(string); ok {
- sb.WriteString(fmt.Sprintf("\t\theader_up %s %q\n", key, strVal))
- }
- }
- }
-
- sb.WriteString("\t}\n") // Close reverse_proxy
- sb.WriteString("}\n") // Close site block
-
- return sb.String(), nil
-}
-
-// buildUpstreamList creates the upstream address list from interface{}
-func (b *Builder) buildUpstreamList(upstreams []interface{}) (string, bool) {
- addresses := make([]string, 0, len(upstreams))
- var hasHTTPS bool
-
- for _, up := range upstreams {
- upstreamMap, ok := up.(map[string]interface{})
- if !ok {
- continue
- }
-
- host, _ := upstreamMap["host"].(string)
- port, _ := upstreamMap["port"].(float64)
- scheme, _ := upstreamMap["scheme"].(string)
-
- if scheme == "https" {
- hasHTTPS = true
- }
-
- addr := fmt.Sprintf("%s:%d", host, int(port))
- addresses = append(addresses, addr)
- }
-
- return strings.Join(addresses, " "), hasHTTPS
-}
-
-// writeLoadBalancingConfig writes load balancing configuration
-func (b *Builder) writeLoadBalancingConfig(sb *strings.Builder, lb models.JSONField) {
- if strategy, ok := lb["strategy"].(string); ok && strategy != "" {
- policy := mapLBStrategy(strategy)
- fmt.Fprintf(sb, "\t\tlb_policy %s\n", policy)
- }
-}
-
-// writeHealthCheckConfig writes health check configuration
-func (b *Builder) writeHealthCheckConfig(sb *strings.Builder, hc map[string]interface{}) {
- if path, ok := hc["path"].(string); ok && path != "" {
- fmt.Fprintf(sb, "\t\thealth_uri %s\n", path)
- }
- if interval, ok := hc["interval"].(string); ok && interval != "" {
- fmt.Fprintf(sb, "\t\thealth_interval %s\n", interval)
- }
- if timeout, ok := hc["timeout"].(string); ok && timeout != "" {
- fmt.Fprintf(sb, "\t\thealth_timeout %s\n", timeout)
- }
-}
-
-// writeTransportConfig writes HTTPS transport configuration
-func (b *Builder) writeTransportConfig(sb *strings.Builder, hasHTTPS, insecureSkipVerify bool) {
- sb.WriteString("\t\ttransport http {\n")
-
- if hasHTTPS {
- sb.WriteString("\t\t\ttls\n")
- }
-
- if insecureSkipVerify {
- sb.WriteString("\t\t\ttls_insecure_skip_verify\n")
- }
-
- sb.WriteString("\t\t}\n")
-}
-
-// mapLBStrategy maps our strategy names to Caddy's lb_policy names
-func mapLBStrategy(strategy string) string {
- switch strategy {
- case "round_robin":
- return "round_robin"
- case "least_conn":
- return "least_conn"
- case "random":
- return "random"
- case "first":
- return "first"
- case "ip_hash":
- return "ip_hash"
- case "uri_hash":
- return "uri_hash"
- case "header":
- return "header"
- default:
- return "round_robin"
- }
-}
-
-// buildReverseProxyBlockWithACL generates a reverse proxy site block with ACL configuration.
-// The ACL configuration is inserted before the reverse proxy directive, and handles
-// requests based on path patterns, IP rules, and authentication requirements.
-func (b *Builder) buildReverseProxyBlockWithACL(proxy *models.Proxy, aclAssignments []models.ProxyACLAssignment) (string, error) {
- if proxy.Upstreams == nil {
- return "", fmt.Errorf("reverse proxy requires at least one upstream")
- }
-
- upstreams, ok := proxy.Upstreams.([]interface{})
- if !ok || len(upstreams) == 0 {
- return "", fmt.Errorf("reverse proxy requires at least one upstream")
- }
-
- var sb strings.Builder
-
- // Site block with hostname
- siteAddr := formatSiteAddress(proxy.Hostname, proxy.SSLEnabled)
- sb.WriteString(fmt.Sprintf("%s {\n", siteAddr))
-
- // Import security snippet if block exploits is enabled
- if proxy.BlockExploits {
- sb.WriteString("\timport /etc/caddy/snippets/security.caddy\n\n")
- }
-
- // Generate ACL configuration using the ACL builder
- if b.aclBuilder != nil {
- aclConfig := b.aclBuilder.BuildACLConfig(proxy, aclAssignments)
- if aclConfig != "" {
- sb.WriteString("\t# ACL Configuration\n")
- sb.WriteString(aclConfig)
- }
- }
-
- // Add fallback reverse proxy for paths not covered by ACL
- // This handles requests that don't match any ACL path patterns
- sb.WriteString("\t# Fallback for unprotected paths\n")
-
- // Build upstream list
- upstreamAddrs, hasHTTPS := b.buildUpstreamList(upstreams)
-
- // reverse_proxy directive
- sb.WriteString(fmt.Sprintf("\treverse_proxy %s {\n", upstreamAddrs))
-
- // Load balancing config
- if len(proxy.LoadBalancing) > 0 {
- b.writeLoadBalancingConfig(&sb, proxy.LoadBalancing)
- }
-
- // Health checks
- if len(proxy.LoadBalancing) > 0 {
- if healthChecks, ok := proxy.LoadBalancing["health_checks"].(map[string]interface{}); ok {
- if enabled, _ := healthChecks["enabled"].(bool); enabled {
- b.writeHealthCheckConfig(&sb, healthChecks)
- }
- }
- }
-
- // Transport config for HTTPS upstreams
- if hasHTTPS || proxy.TLSInsecureSkipVerify {
- b.writeTransportConfig(&sb, hasHTTPS, proxy.TLSInsecureSkipVerify)
- }
-
- // Standard headers
- sb.WriteString("\t\theader_up X-Real-IP {remote_host}\n")
- sb.WriteString("\t\theader_up X-Forwarded-For {remote_host}\n")
- sb.WriteString("\t\theader_up X-Forwarded-Proto {scheme}\n")
- sb.WriteString("\t\theader_up X-Forwarded-Host {host}\n")
-
- // Custom headers
- if len(proxy.CustomHeaders) > 0 {
- for key, value := range proxy.CustomHeaders {
- if strVal, ok := value.(string); ok {
- sb.WriteString(fmt.Sprintf("\t\theader_up %s %q\n", key, strVal))
- }
- }
- }
-
- sb.WriteString("\t}\n") // Close reverse_proxy
- sb.WriteString("}\n") // Close site block
-
- return sb.String(), nil
-}
diff --git a/backend/internal/caddy/caddyfile/static.go b/backend/internal/caddy/caddyfile/static.go
deleted file mode 100644
index 06615e0..0000000
--- a/backend/internal/caddy/caddyfile/static.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package caddyfile
-
-import (
- "fmt"
- "strings"
-
- "github.com/aloks98/waygates/backend/internal/models"
-)
-
-// buildStaticBlock generates a static file server site block
-func (b *Builder) buildStaticBlock(proxy *models.Proxy) (string, error) {
- if len(proxy.StaticConfig) == 0 {
- return "", fmt.Errorf("static proxy requires static configuration")
- }
-
- rootPath, _ := proxy.StaticConfig["root_path"].(string)
- if rootPath == "" {
- return "", fmt.Errorf("static proxy requires root_path")
- }
-
- var sb strings.Builder
-
- // Site block with hostname (use http:// prefix if SSL disabled)
- siteAddr := formatSiteAddress(proxy.Hostname, proxy.SSLEnabled)
- sb.WriteString(fmt.Sprintf("%s {\n", siteAddr))
-
- // Import security snippet if block exploits is enabled
- if proxy.BlockExploits {
- sb.WriteString("\timport /etc/caddy/snippets/security.caddy\n\n")
- }
-
- // Root directive
- sb.WriteString(fmt.Sprintf("\troot * %s\n", rootPath))
-
- // Template rendering (must come before file_server)
- if templateRendering, ok := proxy.StaticConfig["template_rendering"].(bool); ok && templateRendering {
- sb.WriteString("\ttemplates\n")
- }
-
- // SPA support with try_files
- if tryFiles, ok := proxy.StaticConfig["try_files"].([]interface{}); ok && len(tryFiles) > 0 {
- var files []string
- for _, f := range tryFiles {
- if s, ok := f.(string); ok {
- files = append(files, s)
- }
- }
- if len(files) > 0 {
- sb.WriteString(fmt.Sprintf("\ttry_files {path} %s\n", strings.Join(files, " ")))
- }
- }
-
- // File server directive
- sb.WriteString("\tfile_server")
-
- // Add browse option if directory listing is enabled
- if browse, ok := proxy.StaticConfig["browse"].(bool); ok && browse {
- sb.WriteString(" browse")
- }
-
- sb.WriteString(" {\n")
-
- // Index file
- if indexFile, ok := proxy.StaticConfig["index_file"].(string); ok && indexFile != "" {
- sb.WriteString(fmt.Sprintf("\t\tindex %s\n", indexFile))
- }
-
- sb.WriteString("\t}\n") // Close file_server
- sb.WriteString("}\n") // Close site block
-
- return sb.String(), nil
-}
diff --git a/backend/internal/caddy/config/acl_builder.go b/backend/internal/caddy/config/acl_builder.go
new file mode 100644
index 0000000..ca76ba1
--- /dev/null
+++ b/backend/internal/caddy/config/acl_builder.go
@@ -0,0 +1,578 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+import (
+ "fmt"
+ "net/url"
+ "sort"
+
+ "go.uber.org/zap"
+
+ "github.com/aloks98/waygates/backend/internal/models"
+)
+
+// ACLBuilder builds ACL routes for authentication and authorization.
+type ACLBuilder struct {
+ logger *zap.Logger
+ waygatesVerifyURL string
+ waygatesLoginURL string
+}
+
+// NewACLBuilder creates a new ACL builder.
+func NewACLBuilder(logger *zap.Logger) *ACLBuilder {
+ if logger == nil {
+ logger = zap.NewNop()
+ }
+ return &ACLBuilder{
+ logger: logger,
+ }
+}
+
+// SetWaygatesURLs sets the Waygates authentication URLs.
+func (b *ACLBuilder) SetWaygatesURLs(verifyURL, loginURL string) *ACLBuilder {
+ b.waygatesVerifyURL = verifyURL
+ b.waygatesLoginURL = loginURL
+ return b
+}
+
+// Default headers to copy from Waygates forward auth responses
+var waygatesDefaultHeaders = []string{
+ "X-Auth-User",
+ "X-Auth-User-ID",
+ "X-Auth-User-Email",
+}
+
+// Provider-specific default headers
+var providerDefaultHeaders = map[string][]string{
+ models.ACLProviderTypeAuthelia: {
+ "Remote-User",
+ "Remote-Groups",
+ "Remote-Name",
+ "Remote-Email",
+ },
+ models.ACLProviderTypeAuthentik: {
+ "X-authentik-username",
+ "X-authentik-groups",
+ "X-authentik-email",
+ "X-authentik-name",
+ "X-authentik-uid",
+ },
+}
+
+// BuildACLRoutes builds authentication routes for a proxy.
+// Returns routes that should be placed BEFORE the main proxy route.
+func (b *ACLBuilder) BuildACLRoutes(
+ hostname string,
+ pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+) ([]*HTTPRoute, error) {
+ if group == nil {
+ return nil, nil
+ }
+
+ // Analyze configured auth methods
+ hasIPRules := len(group.IPRules) > 0
+ hasBasicAuth := len(group.BasicAuthUsers) > 0
+ hasWaygatesAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
+ hasOAuthProviders := group.WaygatesAuth != nil && len(group.WaygatesAuth.AllowedProviders) > 0
+ hasExternalProviders := len(group.ExternalProviders) > 0
+
+ // If no auth methods configured, skip
+ if !hasIPRules && !hasBasicAuth && !hasWaygatesAuth && !hasOAuthProviders && !hasExternalProviders {
+ return nil, nil
+ }
+
+ // Build routes based on combination mode
+ switch group.CombinationMode {
+ case models.ACLCombinationModeAll:
+ return b.buildAllModeRoutes(hostname, pathPattern, group, upstreamHandler)
+ case models.ACLCombinationModeIPBypass:
+ return b.buildIPBypassModeRoutes(hostname, pathPattern, group, upstreamHandler)
+ default: // "any" mode is default
+ return b.buildAnyModeRoutes(hostname, pathPattern, group, upstreamHandler)
+ }
+}
+
+// buildAnyModeRoutes builds routes for ANY mode (OR logic).
+// Any auth method can grant access.
+func (b *ACLBuilder) buildAnyModeRoutes(
+ hostname string,
+ pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+) ([]*HTTPRoute, error) {
+ var routes []*HTTPRoute
+
+ // Categorize IP rules
+ bypassIPs, allowIPs, denyIPs := categorizeIPRules(group.IPRules)
+
+ // 1. IP deny route - highest priority
+ if len(denyIPs) > 0 {
+ route := b.buildIPDenyRoute(hostname, pathPattern, denyIPs)
+ routes = append(routes, route)
+ }
+
+ // 2. IP bypass route - skip all auth
+ if len(bypassIPs) > 0 {
+ route := b.buildIPBypassRoute(hostname, pathPattern, bypassIPs, upstreamHandler)
+ routes = append(routes, route)
+ }
+
+ // 3. IP allow route - grant access without auth
+ if len(allowIPs) > 0 {
+ route := b.buildIPAllowRoute(hostname, pathPattern, allowIPs, upstreamHandler)
+ routes = append(routes, route)
+ }
+
+ // 4. Static assets bypass route
+ route := b.buildStaticAssetsBypassRoute(hostname, upstreamHandler)
+ routes = append(routes, route)
+
+ // 5. Authentication route for remaining requests
+ authRoute := b.buildAuthRoute(hostname, pathPattern, group, upstreamHandler, bypassIPs, allowIPs)
+ if authRoute != nil {
+ routes = append(routes, authRoute)
+ }
+
+ return routes, nil
+}
+
+// buildAllModeRoutes builds routes for ALL mode (AND logic).
+// All auth methods must pass.
+func (b *ACLBuilder) buildAllModeRoutes(
+ hostname string,
+ pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+) ([]*HTTPRoute, error) {
+ var routes []*HTTPRoute
+
+ bypassIPs, allowIPs, denyIPs := categorizeIPRules(group.IPRules)
+
+ // 1. IP deny route
+ if len(denyIPs) > 0 {
+ route := b.buildIPDenyRoute(hostname, pathPattern, denyIPs)
+ routes = append(routes, route)
+ }
+
+ // 2. For ALL mode, combine bypass and allow IPs - requests must come from these IPs
+ allAllowedIPs := make([]string, 0, len(bypassIPs)+len(allowIPs))
+ allAllowedIPs = append(allAllowedIPs, bypassIPs...)
+ allAllowedIPs = append(allAllowedIPs, allowIPs...)
+ if len(allAllowedIPs) > 0 {
+ // Deny requests NOT from allowed IPs
+ route := b.buildIPDenyNotInListRoute(hostname, pathPattern, allAllowedIPs)
+ routes = append(routes, route)
+ }
+
+ // 3. Static assets bypass
+ route := b.buildStaticAssetsBypassRoute(hostname, upstreamHandler)
+ routes = append(routes, route)
+
+ // 4. Auth route for requests from allowed IPs
+ authRoute := b.buildAuthRouteWithIPRestriction(hostname, pathPattern, group, upstreamHandler, allAllowedIPs)
+ if authRoute != nil {
+ routes = append(routes, authRoute)
+ }
+
+ return routes, nil
+}
+
+// buildIPBypassModeRoutes builds routes for IP_BYPASS mode.
+// Similar to ANY mode with specific IP bypass handling.
+func (b *ACLBuilder) buildIPBypassModeRoutes(
+ hostname string,
+ pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+) ([]*HTTPRoute, error) {
+ // IP bypass mode is functionally the same as ANY mode
+ return b.buildAnyModeRoutes(hostname, pathPattern, group, upstreamHandler)
+}
+
+// buildIPDenyRoute creates a route that denies requests from specific IPs.
+func (b *ACLBuilder) buildIPDenyRoute(hostname, pathPattern string, denyIPs []string) *HTTPRoute {
+ matcher := NewHostMatcher(hostname)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+ AddRemoteIPToMatcher(matcher, denyIPs...)
+
+ handler := NewStaticResponseHandler(403, "Forbidden")
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: []HTTPHandler{ToHTTPHandler(handler)},
+ Terminal: true,
+ }
+}
+
+// buildIPDenyNotInListRoute creates a route that denies requests NOT from allowed IPs.
+func (b *ACLBuilder) buildIPDenyNotInListRoute(hostname, pathPattern string, allowedIPs []string) *HTTPRoute {
+ // Create a matcher for NOT in the allowed IP list
+ notMatcher := NewNotMatcher(NewRemoteIPMatcher(allowedIPs...))
+ matcher := CombineMatchers(NewHostMatcher(hostname), notMatcher)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+
+ handler := NewStaticResponseHandler(403, "Forbidden")
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: []HTTPHandler{ToHTTPHandler(handler)},
+ Terminal: true,
+ }
+}
+
+// buildIPBypassRoute creates a route that bypasses auth for specific IPs.
+func (b *ACLBuilder) buildIPBypassRoute(hostname, pathPattern string, bypassIPs []string, upstreamHandler *ReverseProxyHandler) *HTTPRoute {
+ matcher := NewHostMatcher(hostname)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+ AddRemoteIPToMatcher(matcher, bypassIPs...)
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: []HTTPHandler{handlerToMap(upstreamHandler)},
+ Terminal: true,
+ }
+}
+
+// buildIPAllowRoute creates a route that allows requests from specific IPs without auth.
+func (b *ACLBuilder) buildIPAllowRoute(hostname, pathPattern string, allowIPs []string, upstreamHandler *ReverseProxyHandler) *HTTPRoute {
+ matcher := NewHostMatcher(hostname)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+ AddRemoteIPToMatcher(matcher, allowIPs...)
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: []HTTPHandler{handlerToMap(upstreamHandler)},
+ Terminal: true,
+ }
+}
+
+// buildStaticAssetsBypassRoute creates a route that bypasses auth for static assets.
+func (b *ACLBuilder) buildStaticAssetsBypassRoute(hostname string, upstreamHandler *ReverseProxyHandler) *HTTPRoute {
+ // Combine static asset paths and extensions
+ paths := append(StaticAssetPaths(), StaticAssetExtensions()...)
+
+ matcher := CombineMatchers(
+ NewHostMatcher(hostname),
+ NewPathMatcher(paths...),
+ )
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: []HTTPHandler{handlerToMap(upstreamHandler)},
+ Terminal: true,
+ }
+}
+
+// buildAuthRoute creates an authentication route.
+func (b *ACLBuilder) buildAuthRoute(
+ hostname, pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+ bypassIPs, allowIPs []string,
+) *HTTPRoute {
+ // Determine auth type
+ hasWaygatesAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
+ hasOAuthProviders := group.WaygatesAuth != nil && len(group.WaygatesAuth.AllowedProviders) > 0
+ hasExternalProviders := len(group.ExternalProviders) > 0
+ hasBasicAuth := len(group.BasicAuthUsers) > 0
+
+ hasSecureAuth := hasWaygatesAuth || hasOAuthProviders || hasExternalProviders
+
+ // Build matcher excluding bypass and allow IPs
+ matcher := NewHostMatcher(hostname)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+
+ excludeIPs := make([]string, 0, len(bypassIPs)+len(allowIPs))
+ excludeIPs = append(excludeIPs, bypassIPs...)
+ excludeIPs = append(excludeIPs, allowIPs...)
+ if len(excludeIPs) > 0 {
+ notMatcher := NewNotMatcher(NewRemoteIPMatcher(excludeIPs...))
+ matcher = CombineMatchers(matcher, notMatcher)
+ }
+
+ var handlers []HTTPHandler
+
+ if hasBasicAuth && !hasSecureAuth {
+ // Only basic auth configured
+ authHandler := b.buildBasicAuthHandler(group.BasicAuthUsers)
+ handlers = append(handlers, ToHTTPHandler(authHandler))
+ } else if hasSecureAuth {
+ // Forward auth
+ forwardAuthHandler := b.buildForwardAuthHandler(group)
+ handlers = append(handlers, forwardAuthHandler)
+ }
+
+ // Add upstream handler
+ handlers = append(handlers, handlerToMap(upstreamHandler))
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: handlers,
+ Terminal: true,
+ }
+}
+
+// buildAuthRouteWithIPRestriction creates an auth route that requires requests from specific IPs.
+func (b *ACLBuilder) buildAuthRouteWithIPRestriction(
+ hostname, pathPattern string,
+ group *models.ACLGroup,
+ upstreamHandler *ReverseProxyHandler,
+ allowedIPs []string,
+) *HTTPRoute {
+ hasWaygatesAuth := group.WaygatesAuth != nil && group.WaygatesAuth.Enabled
+ hasOAuthProviders := group.WaygatesAuth != nil && len(group.WaygatesAuth.AllowedProviders) > 0
+ hasExternalProviders := len(group.ExternalProviders) > 0
+ hasBasicAuth := len(group.BasicAuthUsers) > 0
+
+ hasSecureAuth := hasWaygatesAuth || hasOAuthProviders || hasExternalProviders
+
+ // Build matcher for allowed IPs
+ matcher := NewHostMatcher(hostname)
+ if pathPattern != "" && pathPattern != "/*" {
+ AddPathToMatcher(matcher, pathPattern)
+ }
+ if len(allowedIPs) > 0 {
+ AddRemoteIPToMatcher(matcher, allowedIPs...)
+ }
+
+ var handlers []HTTPHandler
+
+ if hasBasicAuth && !hasSecureAuth {
+ authHandler := b.buildBasicAuthHandler(group.BasicAuthUsers)
+ handlers = append(handlers, ToHTTPHandler(authHandler))
+ } else if hasSecureAuth {
+ forwardAuthHandler := b.buildForwardAuthHandler(group)
+ handlers = append(handlers, forwardAuthHandler)
+ }
+
+ handlers = append(handlers, handlerToMap(upstreamHandler))
+
+ return &HTTPRoute{
+ Match: []MatcherSet{matcher},
+ Handle: handlers,
+ Terminal: true,
+ }
+}
+
+// buildBasicAuthHandler creates a basic auth handler.
+func (b *ACLBuilder) buildBasicAuthHandler(users []models.ACLBasicAuthUser) *AuthenticationHandler {
+ accounts := make([]*BasicAuthAccount, len(users))
+ for i, user := range users {
+ accounts[i] = NewBasicAuthAccount(user.Username, user.PasswordHash)
+ }
+
+ return NewAuthenticationHandler(accounts, "Protected")
+}
+
+// buildForwardAuthHandler creates a forward auth handler for Waygates or external providers.
+func (b *ACLBuilder) buildForwardAuthHandler(group *models.ACLGroup) HTTPHandler {
+ // Determine which provider to use
+ if len(group.ExternalProviders) > 0 {
+ return b.buildExternalProviderHandler(group.ExternalProviders[0])
+ }
+
+ // Use Waygates forward auth
+ return b.buildWaygatesForwardAuthHandler()
+}
+
+// buildWaygatesForwardAuthHandler creates a Waygates forward auth handler.
+func (b *ACLBuilder) buildWaygatesForwardAuthHandler() HTTPHandler {
+ // Extract host:port from URL for Caddy's Dial field
+ dialAddr := extractDialAddress(b.waygatesVerifyURL)
+
+ // Waygates forward auth is implemented as a reverse_proxy with specific configuration
+ return HTTPHandler{
+ "handler": HandlerReverseProxy,
+ "upstreams": []*Upstream{
+ {Dial: dialAddr},
+ },
+ "headers": &HeadersConfig{
+ Request: &HeaderOps{
+ Set: map[string][]string{
+ "X-Forwarded-Method": {"{http.request.method}"},
+ "X-Forwarded-Proto": {"{http.request.scheme}"},
+ "X-Forwarded-Host": {"{http.request.host}"},
+ "X-Forwarded-Uri": {"{http.request.uri}"},
+ },
+ },
+ },
+ "rewrite": &RewriteHeaders{
+ URI: "/api/auth/acl/verify",
+ },
+ "handle_response": []*HandleResponse{
+ // On 2xx (successful auth): copy headers and continue to upstream
+ {
+ Match: &ResponseMatch{
+ StatusCode: []int{200, 201, 202, 203, 204, 205, 206},
+ },
+ Routes: []*HTTPRoute{
+ {
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewCopyResponseHeadersHandler(waygatesDefaultHeaders)),
+ },
+ },
+ },
+ },
+ // On 401 (unauthorized): redirect to login
+ {
+ Match: &ResponseMatch{
+ StatusCode: []int{401},
+ },
+ Routes: []*HTTPRoute{
+ {
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewRedirectHandler(
+ fmt.Sprintf("%s?redirect={http.request.scheme}://{http.request.host}{http.request.uri}", b.waygatesLoginURL),
+ 302,
+ )),
+ },
+ },
+ },
+ },
+ // On 403 (forbidden): show access denied
+ {
+ Match: &ResponseMatch{
+ StatusCode: []int{403},
+ },
+ Routes: []*HTTPRoute{
+ {
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewStaticResponseHandler(403, "Access Denied")),
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+// buildExternalProviderHandler creates an external provider forward auth handler.
+func (b *ACLBuilder) buildExternalProviderHandler(provider models.ACLExternalProvider) HTTPHandler {
+ // Get headers to copy
+ headers := provider.HeadersToCopy
+ if len(headers) == 0 {
+ if defaultHeaders, ok := providerDefaultHeaders[provider.ProviderType]; ok {
+ headers = defaultHeaders
+ }
+ }
+
+ // Extract host:port from URL for Caddy's Dial field
+ dialAddr := extractDialAddress(provider.VerifyURL)
+
+ handler := HTTPHandler{
+ "handler": HandlerReverseProxy,
+ "upstreams": []*Upstream{
+ {Dial: dialAddr},
+ },
+ "headers": &HeadersConfig{
+ Request: &HeaderOps{
+ Set: map[string][]string{
+ "X-Forwarded-Method": {"{http.request.method}"},
+ "X-Forwarded-Proto": {"{http.request.scheme}"},
+ "X-Forwarded-Host": {"{http.request.host}"},
+ "X-Forwarded-Uri": {"{http.request.uri}"},
+ },
+ },
+ },
+ "handle_response": []*HandleResponse{
+ // On 2xx (successful auth): copy headers and continue to upstream
+ {
+ Match: &ResponseMatch{
+ StatusCode: []int{200, 201, 202, 203, 204, 205, 206},
+ },
+ Routes: []*HTTPRoute{
+ {
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewCopyResponseHeadersHandler(headers)),
+ },
+ },
+ },
+ },
+ },
+ }
+
+ return handler
+}
+
+// categorizeIPRules separates IP rules by type.
+func categorizeIPRules(rules []models.ACLIPRule) (bypass, allow, deny []string) {
+ // Sort by priority first
+ sorted := make([]models.ACLIPRule, len(rules))
+ copy(sorted, rules)
+ sort.Slice(sorted, func(i, j int) bool {
+ return sorted[i].Priority < sorted[j].Priority
+ })
+
+ for _, rule := range sorted {
+ switch rule.RuleType {
+ case models.ACLIPRuleTypeBypass:
+ bypass = append(bypass, rule.CIDR)
+ case models.ACLIPRuleTypeAllow:
+ allow = append(allow, rule.CIDR)
+ case models.ACLIPRuleTypeDeny:
+ deny = append(deny, rule.CIDR)
+ }
+ }
+ return
+}
+
+// GetDefaultWaygatesHeaders returns the default headers copied from Waygates auth.
+func GetDefaultWaygatesHeaders() []string {
+ return waygatesDefaultHeaders
+}
+
+// GetProviderDefaultHeaders returns the default headers for a provider type.
+func GetProviderDefaultHeaders(providerType string) []string {
+ if headers, ok := providerDefaultHeaders[providerType]; ok {
+ return headers
+ }
+ return nil
+}
+
+// extractDialAddress extracts the host:port from a URL for Caddy's Dial field.
+// Input: "http://waygates:8080" or "https://auth.example.com:443/verify"
+// Output: "waygates:8080" or "auth.example.com:443"
+func extractDialAddress(rawURL string) string {
+ // If it's already just host:port, return as-is
+ if !containsScheme(rawURL) {
+ return rawURL
+ }
+
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ // If parsing fails, return the original (let Caddy handle the error)
+ return rawURL
+ }
+
+ host := parsed.Hostname()
+ port := parsed.Port()
+
+ // If no port specified, use default based on scheme
+ if port == "" {
+ switch parsed.Scheme {
+ case "https":
+ port = "443"
+ default:
+ port = "80"
+ }
+ }
+
+ return fmt.Sprintf("%s:%s", host, port)
+}
+
+// containsScheme checks if a string contains a URL scheme (e.g., "http://", "https://").
+func containsScheme(s string) bool {
+ return len(s) > 7 && (s[:7] == "http://" || (len(s) > 8 && s[:8] == "https://"))
+}
diff --git a/backend/internal/caddy/config/builder.go b/backend/internal/caddy/config/builder.go
new file mode 100644
index 0000000..fe7675f
--- /dev/null
+++ b/backend/internal/caddy/config/builder.go
@@ -0,0 +1,324 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+
+ "go.uber.org/zap"
+
+ "github.com/aloks98/waygates/backend/internal/models"
+)
+
+// Builder orchestrates the generation of Caddy JSON configuration.
+// It coordinates the HTTP, TLS, and ACL builders to produce a complete config.
+type Builder struct {
+ logger *zap.Logger
+ httpBuilder *HTTPBuilder
+ tlsBuilder *TLSBuilder
+ aclBuilder *ACLBuilder
+
+ // Configuration inputs
+ httpProxies []models.Proxy
+ aclGroups map[int64]*models.ACLGroup
+ aclAssigns map[int64][]models.ProxyACLAssignment
+ notFound *models.NotFoundSettings
+}
+
+// Settings holds the application settings for building the config.
+type Settings struct {
+ AdminEmail string
+ ACMEProvider string
+ StoragePath string
+
+ // Waygates auth URLs
+ WaygatesVerifyURL string
+ WaygatesLoginURL string
+
+ // DNS provider credentials (loaded from environment)
+ DNSCredentials map[string]string
+}
+
+// BuilderOption is a functional option for configuring the Builder.
+type BuilderOption func(*Builder)
+
+// NewBuilder creates a new configuration builder.
+func NewBuilder(opts ...BuilderOption) *Builder {
+ b := &Builder{
+ logger: zap.NewNop(),
+ aclGroups: make(map[int64]*models.ACLGroup),
+ aclAssigns: make(map[int64][]models.ProxyACLAssignment),
+ }
+
+ for _, opt := range opts {
+ opt(b)
+ }
+
+ // Initialize sub-builders
+ b.httpBuilder = NewHTTPBuilder(b.logger)
+ b.tlsBuilder = NewTLSBuilder(b.logger)
+
+ return b
+}
+
+// WithLogger sets the logger for the builder.
+func WithLogger(logger *zap.Logger) BuilderOption {
+ return func(b *Builder) {
+ if logger != nil {
+ b.logger = logger
+ }
+ }
+}
+
+// WithACLBuilder sets the ACL builder.
+func WithACLBuilder(aclBuilder *ACLBuilder) BuilderOption {
+ return func(b *Builder) {
+ b.aclBuilder = aclBuilder
+ }
+}
+
+// SetSettings sets the application settings.
+func (b *Builder) SetSettings(settings *Settings) *Builder {
+ b.tlsBuilder.SetSettings(settings)
+ if b.aclBuilder != nil && settings != nil {
+ b.aclBuilder.SetWaygatesURLs(settings.WaygatesVerifyURL, settings.WaygatesLoginURL)
+ }
+ return b
+}
+
+// SetHTTPProxies sets the HTTP proxies to include in the configuration.
+func (b *Builder) SetHTTPProxies(proxies []models.Proxy) *Builder {
+ b.httpProxies = proxies
+ return b
+}
+
+// SetACLGroups sets the ACL groups for authentication configuration.
+func (b *Builder) SetACLGroups(groups []models.ACLGroup) *Builder {
+ b.aclGroups = make(map[int64]*models.ACLGroup)
+ for i := range groups {
+ b.aclGroups[int64(groups[i].ID)] = &groups[i]
+ }
+ return b
+}
+
+// SetACLAssignments sets the proxy ACL assignments.
+func (b *Builder) SetACLAssignments(assignments []models.ProxyACLAssignment) *Builder {
+ b.aclAssigns = make(map[int64][]models.ProxyACLAssignment)
+ for _, a := range assignments {
+ if a.Enabled {
+ b.aclAssigns[int64(a.ProxyID)] = append(b.aclAssigns[int64(a.ProxyID)], a)
+ }
+ }
+ return b
+}
+
+// SetNotFoundSettings sets the 404 response configuration.
+func (b *Builder) SetNotFoundSettings(settings *models.NotFoundSettings) *Builder {
+ b.notFound = settings
+ return b
+}
+
+// Build generates the complete Caddy configuration.
+func (b *Builder) Build() (*CaddyConfig, error) {
+ config := &CaddyConfig{
+ Admin: &AdminConfig{
+ Listen: "localhost:2019",
+ },
+ Storage: &StorageConfig{
+ Module: "file_system",
+ Root: "/data",
+ },
+ Apps: &AppsConfig{},
+ }
+
+ // Build HTTP routes for all proxies
+ routes, err := b.buildHTTPRoutes()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build HTTP routes: %w", err)
+ }
+
+ // Add catch-all route
+ catchAllRoute := b.buildCatchAllRoute()
+ if catchAllRoute != nil {
+ routes = append(routes, catchAllRoute)
+ }
+
+ // Build HTTP app if we have routes
+ if len(routes) > 0 {
+ httpApp := NewHTTPApp()
+ server := NewHTTPServer(":443", ":80")
+ server.AddRoutes(routes...)
+ httpApp.AddServer(DefaultServerName, server)
+ config.Apps.HTTP = httpApp
+ }
+
+ // Collect domains for TLS and build TLS app
+ domains := b.collectTLSDomains()
+ if len(domains) > 0 {
+ tlsApp, err := b.tlsBuilder.Build(domains)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build TLS config: %w", err)
+ }
+ config.Apps.TLS = tlsApp
+ }
+
+ return config, nil
+}
+
+// BuildJSON generates the Caddy configuration as formatted JSON bytes.
+func (b *Builder) BuildJSON() ([]byte, error) {
+ config, err := b.Build()
+ if err != nil {
+ return nil, err
+ }
+ return json.MarshalIndent(config, "", " ")
+}
+
+// BuildCompactJSON generates the Caddy configuration as compact JSON bytes.
+func (b *Builder) BuildCompactJSON() ([]byte, error) {
+ config, err := b.Build()
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(config)
+}
+
+// buildHTTPRoutes builds routes for all HTTP proxies.
+func (b *Builder) buildHTTPRoutes() ([]*HTTPRoute, error) {
+ var routes []*HTTPRoute
+
+ for i := range b.httpProxies {
+ proxy := &b.httpProxies[i]
+ if !proxy.IsActive {
+ continue
+ }
+
+ proxyRoutes, err := b.buildProxyRoutes(proxy)
+ if err != nil {
+ b.logger.Warn("Failed to build routes for proxy",
+ zap.Int("proxy_id", proxy.ID),
+ zap.String("proxy_name", proxy.Name),
+ zap.Error(err),
+ )
+ continue
+ }
+
+ routes = append(routes, proxyRoutes...)
+ }
+
+ return routes, nil
+}
+
+// buildProxyRoutes builds routes for a single proxy.
+func (b *Builder) buildProxyRoutes(proxy *models.Proxy) ([]*HTTPRoute, error) {
+ var routes []*HTTPRoute
+
+ // Add security routes if BlockExploits is enabled
+ if proxy.BlockExploits {
+ securityRoutes := SecurityRoutesForHost(proxy.Hostname)
+ routes = append(routes, securityRoutes...)
+ b.logger.Debug("Added security routes for proxy",
+ zap.String("hostname", proxy.Hostname),
+ zap.Int("security_routes", len(securityRoutes)))
+ }
+
+ // Check for ACL assignments
+ assignments := b.aclAssigns[int64(proxy.ID)]
+ hasACL := len(assignments) > 0 && b.aclBuilder != nil
+
+ var proxyRoutes []*HTTPRoute
+ var err error
+
+ switch proxy.Type {
+ case models.ProxyTypeReverseProxy:
+ if hasACL {
+ proxyRoutes, err = b.httpBuilder.BuildReverseProxyRoutesWithACL(proxy, assignments, b.aclGroups, b.aclBuilder)
+ } else {
+ proxyRoutes, err = b.httpBuilder.BuildReverseProxyRoutes(proxy)
+ }
+
+ case models.ProxyTypeRedirect:
+ proxyRoutes, err = b.httpBuilder.BuildRedirectRoutes(proxy)
+
+ case models.ProxyTypeStatic:
+ proxyRoutes, err = b.httpBuilder.BuildStaticRoutes(proxy)
+
+ default:
+ return nil, fmt.Errorf("unknown proxy type: %s", proxy.Type)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ routes = append(routes, proxyRoutes...)
+ return routes, nil
+}
+
+// buildCatchAllRoute builds the catch-all route for unmatched requests.
+func (b *Builder) buildCatchAllRoute() *HTTPRoute {
+ if b.notFound == nil {
+ return NewCatchAllRoute()
+ }
+
+ if b.notFound.Mode == "redirect" && b.notFound.RedirectURL != "" {
+ return NewCatchAllRedirectRoute(b.notFound.RedirectURL)
+ }
+
+ return NewCatchAllRoute()
+}
+
+// collectTLSDomains collects all domains that need TLS certificates.
+func (b *Builder) collectTLSDomains() []string {
+ domainSet := make(map[string]bool)
+
+ for i := range b.httpProxies {
+ proxy := &b.httpProxies[i]
+ if !proxy.IsActive {
+ continue
+ }
+ // Only collect domains for SSL-enabled proxies
+ if proxy.SSLEnabled {
+ domainSet[proxy.Hostname] = true
+ }
+ }
+
+ domains := make([]string, 0, len(domainSet))
+ for domain := range domainSet {
+ domains = append(domains, domain)
+ }
+
+ // Sort domains for deterministic JSON output
+ sort.Strings(domains)
+
+ return domains
+}
+
+// BuildSingleProxy generates JSON configuration for a single proxy.
+// This is useful for validation or preview purposes.
+func (b *Builder) BuildSingleProxy(proxy *models.Proxy) (*CaddyConfig, error) {
+ routes, err := b.buildProxyRoutes(proxy)
+ if err != nil {
+ return nil, err
+ }
+
+ config := &CaddyConfig{
+ Apps: &AppsConfig{
+ HTTP: &HTTPApp{
+ Servers: map[string]*HTTPServer{
+ DefaultServerName: {
+ Listen: []string{":443"},
+ Routes: routes,
+ },
+ },
+ },
+ },
+ }
+
+ if proxy.SSLEnabled {
+ config.Apps.TLS = NewTLSApp([]string{proxy.Hostname})
+ }
+
+ return config, nil
+}
diff --git a/backend/internal/caddy/config/builder_test.go b/backend/internal/caddy/config/builder_test.go
new file mode 100644
index 0000000..9dcb454
--- /dev/null
+++ b/backend/internal/caddy/config/builder_test.go
@@ -0,0 +1,1769 @@
+package config
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/aloks98/waygates/backend/internal/models"
+)
+
+// =============================================================================
+// Test Helpers
+// =============================================================================
+
+// newTestLogger creates a no-op logger for testing.
+func newTestLogger() *zap.Logger {
+ return zap.NewNop()
+}
+
+// createTestProxy creates a test proxy with the given parameters.
+func createTestProxy(id int, name, hostname, proxyType string, isActive, sslEnabled bool) models.Proxy {
+ return models.Proxy{
+ ID: id,
+ Name: name,
+ Hostname: hostname,
+ Type: proxyType,
+ IsActive: isActive,
+ SSLEnabled: sslEnabled,
+ }
+}
+
+// createReverseProxy creates a reverse proxy with upstreams.
+func createReverseProxy(id int, name, hostname string, upstreams []interface{}, isActive, sslEnabled bool) models.Proxy {
+ proxy := createTestProxy(id, name, hostname, models.ProxyTypeReverseProxy, isActive, sslEnabled)
+ proxy.Upstreams = upstreams
+ return proxy
+}
+
+// createRedirectProxy creates a redirect proxy.
+func createRedirectProxy(id int, name, hostname, target string, statusCode int, isActive, sslEnabled bool) models.Proxy {
+ proxy := createTestProxy(id, name, hostname, models.ProxyTypeRedirect, isActive, sslEnabled)
+ proxy.RedirectConfig = models.JSONField{
+ "target": target,
+ "status_code": float64(statusCode),
+ }
+ return proxy
+}
+
+// createStaticProxy creates a static file server proxy.
+func createStaticProxy(id int, name, hostname, rootPath string, isActive, sslEnabled bool) models.Proxy {
+ proxy := createTestProxy(id, name, hostname, models.ProxyTypeStatic, isActive, sslEnabled)
+ proxy.StaticConfig = models.JSONField{
+ "root_path": rootPath,
+ }
+ return proxy
+}
+
+// createTestUpstream creates test upstream data.
+func createTestUpstream(host string, port int, scheme string) map[string]interface{} {
+ return map[string]interface{}{
+ "host": host,
+ "port": float64(port),
+ "scheme": scheme,
+ }
+}
+
+// createTestACLGroup creates a test ACL group.
+func createTestACLGroup(id int, name, combinationMode string) models.ACLGroup {
+ return models.ACLGroup{
+ ID: id,
+ Name: name,
+ CombinationMode: combinationMode,
+ }
+}
+
+// createTestIPRule creates a test IP rule.
+func createTestIPRule(ruleType, cidr string, priority int) models.ACLIPRule {
+ return models.ACLIPRule{
+ RuleType: ruleType,
+ CIDR: cidr,
+ Priority: priority,
+ }
+}
+
+// createTestBasicAuthUser creates a test basic auth user.
+func createTestBasicAuthUser(username, passwordHash string) models.ACLBasicAuthUser {
+ return models.ACLBasicAuthUser{
+ Username: username,
+ PasswordHash: passwordHash,
+ }
+}
+
+// createTestExternalProvider creates a test external auth provider.
+func createTestExternalProvider(providerType, name, verifyURL string) models.ACLExternalProvider {
+ return models.ACLExternalProvider{
+ ProviderType: providerType,
+ Name: name,
+ VerifyURL: verifyURL,
+ }
+}
+
+// createTestWaygatesAuth creates a test Waygates auth config.
+func createTestWaygatesAuth(enabled bool, providers []string) *models.ACLWaygatesAuth {
+ return &models.ACLWaygatesAuth{
+ Enabled: enabled,
+ AllowedProviders: providers,
+ }
+}
+
+// =============================================================================
+// Builder Tests
+// =============================================================================
+
+func TestNewBuilder(t *testing.T) {
+ tests := []struct {
+ name string
+ opts []BuilderOption
+ wantLog bool
+ }{
+ {
+ name: "default builder",
+ opts: nil,
+ wantLog: false,
+ },
+ {
+ name: "with logger",
+ opts: []BuilderOption{WithLogger(newTestLogger())},
+ wantLog: true,
+ },
+ {
+ name: "with nil logger uses nop logger",
+ opts: []BuilderOption{WithLogger(nil)},
+ wantLog: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewBuilder(tt.opts...)
+ require.NotNil(t, b)
+ require.NotNil(t, b.logger)
+ require.NotNil(t, b.httpBuilder)
+ require.NotNil(t, b.tlsBuilder)
+ require.NotNil(t, b.aclGroups)
+ require.NotNil(t, b.aclAssigns)
+ })
+ }
+}
+
+func TestBuilder_Build_EmptyProxies(t *testing.T) {
+ b := NewBuilder(WithLogger(newTestLogger()))
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Should have admin config
+ assert.NotNil(t, config.Admin)
+ assert.Equal(t, "localhost:2019", config.Admin.Listen)
+
+ // Should have storage config
+ assert.NotNil(t, config.Storage)
+ assert.Equal(t, "file_system", config.Storage.Module)
+ assert.Equal(t, "/data", config.Storage.Root)
+
+ // Should have a catch-all route even with no proxies
+ assert.NotNil(t, config.Apps)
+ assert.NotNil(t, config.Apps.HTTP)
+ assert.Contains(t, config.Apps.HTTP.Servers, DefaultServerName)
+ assert.Len(t, config.Apps.HTTP.Servers[DefaultServerName].Routes, 1)
+}
+
+func TestBuilder_Build_WithActiveReverseProxy(t *testing.T) {
+ upstreams := []interface{}{
+ createTestUpstream("backend.local", 8080, "http"),
+ }
+
+ proxy := createReverseProxy(1, "test-proxy", "example.com", upstreams, true, true)
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies([]models.Proxy{proxy})
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Should have HTTP routes
+ assert.NotNil(t, config.Apps.HTTP)
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+
+ // Should have at least 2 routes (proxy + catch-all)
+ assert.GreaterOrEqual(t, len(server.Routes), 2)
+
+ // Should have TLS config for SSL-enabled proxy
+ assert.NotNil(t, config.Apps.TLS)
+ assert.Contains(t, config.Apps.TLS.Certificates.Automate, "example.com")
+}
+
+func TestBuilder_Build_WithSSLEnabledProxy_CollectsDomains(t *testing.T) {
+ proxies := []models.Proxy{
+ createReverseProxy(1, "proxy1", "example.com", []interface{}{createTestUpstream("backend1", 8080, "http")}, true, true),
+ createReverseProxy(2, "proxy2", "api.example.com", []interface{}{createTestUpstream("backend2", 8080, "http")}, true, true),
+ createReverseProxy(3, "proxy3", "internal.example.com", []interface{}{createTestUpstream("backend3", 8080, "http")}, true, false),
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies(proxies)
+
+ config, err := b.Build()
+ require.NoError(t, err)
+
+ // Only SSL-enabled proxies should have domains in TLS config
+ assert.NotNil(t, config.Apps.TLS)
+ tlsDomains := config.Apps.TLS.Certificates.Automate
+ assert.Contains(t, tlsDomains, "example.com")
+ assert.Contains(t, tlsDomains, "api.example.com")
+ assert.NotContains(t, tlsDomains, "internal.example.com")
+}
+
+func TestBuilder_Build_WithInactiveProxies_SkipsThem(t *testing.T) {
+ proxies := []models.Proxy{
+ createReverseProxy(1, "active-proxy", "active.example.com", []interface{}{createTestUpstream("backend", 8080, "http")}, true, true),
+ createReverseProxy(2, "inactive-proxy", "inactive.example.com", []interface{}{createTestUpstream("backend", 8080, "http")}, false, true),
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies(proxies)
+
+ config, err := b.Build()
+ require.NoError(t, err)
+
+ // TLS should only include active proxy domain
+ assert.NotNil(t, config.Apps.TLS)
+ tlsDomains := config.Apps.TLS.Certificates.Automate
+ assert.Contains(t, tlsDomains, "active.example.com")
+ assert.NotContains(t, tlsDomains, "inactive.example.com")
+}
+
+func TestBuilder_Build_WithACLAssignments(t *testing.T) {
+ upstreams := []interface{}{
+ createTestUpstream("backend.local", 8080, "http"),
+ }
+
+ proxy := createReverseProxy(1, "protected-proxy", "secure.example.com", upstreams, true, true)
+
+ aclGroup := createTestACLGroup(1, "test-acl", models.ACLCombinationModeAny)
+ aclGroup.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword"),
+ }
+
+ assignment := models.ProxyACLAssignment{
+ ID: 1,
+ ProxyID: 1,
+ ACLGroupID: 1,
+ PathPattern: "/*",
+ Priority: 0,
+ Enabled: true,
+ }
+
+ aclBuilder := NewACLBuilder(newTestLogger())
+ aclBuilder.SetWaygatesURLs("http://localhost:8080/verify", "http://localhost:8080/login")
+
+ b := NewBuilder(WithLogger(newTestLogger()), WithACLBuilder(aclBuilder))
+ b.SetHTTPProxies([]models.Proxy{proxy})
+ b.SetACLGroups([]models.ACLGroup{aclGroup})
+ b.SetACLAssignments([]models.ProxyACLAssignment{assignment})
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Should have routes with ACL
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+ assert.Greater(t, len(server.Routes), 1)
+}
+
+func TestBuilder_Build_WithNotFoundRedirect(t *testing.T) {
+ notFoundSettings := &models.NotFoundSettings{
+ Mode: "redirect",
+ RedirectURL: "https://home.example.com",
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetNotFoundSettings(notFoundSettings)
+
+ config, err := b.Build()
+ require.NoError(t, err)
+
+ // Should have catch-all redirect route
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+ require.NotEmpty(t, server.Routes)
+
+ // Last route should be the catch-all redirect
+ lastRoute := server.Routes[len(server.Routes)-1]
+ require.NotEmpty(t, lastRoute.Handle)
+
+ // Check that handler has Location header for redirect
+ handler := lastRoute.Handle[0]
+ if headers, ok := handler["headers"].(map[string][]string); ok {
+ assert.Contains(t, headers, "Location")
+ }
+}
+
+func TestBuilder_Build_WithNotFoundDefault(t *testing.T) {
+ notFoundSettings := &models.NotFoundSettings{
+ Mode: "default",
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetNotFoundSettings(notFoundSettings)
+
+ config, err := b.Build()
+ require.NoError(t, err)
+
+ // Should have catch-all 404 route
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+ require.NotEmpty(t, server.Routes)
+
+ lastRoute := server.Routes[len(server.Routes)-1]
+ require.NotEmpty(t, lastRoute.Handle)
+
+ // Check that handler returns 404
+ handler := lastRoute.Handle[0]
+ assert.Equal(t, 404, handler["status_code"])
+}
+
+func TestBuilder_BuildJSON(t *testing.T) {
+ b := NewBuilder(WithLogger(newTestLogger()))
+
+ jsonBytes, err := b.BuildJSON()
+ require.NoError(t, err)
+ require.NotEmpty(t, jsonBytes)
+
+ // Should be valid JSON
+ var result map[string]interface{}
+ err = json.Unmarshal(jsonBytes, &result)
+ require.NoError(t, err)
+
+ // Should have expected keys
+ assert.Contains(t, result, "admin")
+ assert.Contains(t, result, "storage")
+ assert.Contains(t, result, "apps")
+}
+
+func TestBuilder_BuildCompactJSON(t *testing.T) {
+ b := NewBuilder(WithLogger(newTestLogger()))
+
+ jsonBytes, err := b.BuildCompactJSON()
+ require.NoError(t, err)
+ require.NotEmpty(t, jsonBytes)
+
+ // Should be valid compact JSON (no newlines/indentation)
+ assert.NotContains(t, string(jsonBytes), "\n")
+ assert.NotContains(t, string(jsonBytes), " ")
+}
+
+func TestBuilder_BuildSingleProxy(t *testing.T) {
+ upstreams := []interface{}{
+ createTestUpstream("backend.local", 8080, "http"),
+ }
+ proxy := createReverseProxy(1, "test-proxy", "example.com", upstreams, true, true)
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+
+ config, err := b.BuildSingleProxy(&proxy)
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Should have HTTP routes for the proxy
+ assert.NotNil(t, config.Apps.HTTP)
+
+ // Should have TLS for SSL-enabled proxy
+ assert.NotNil(t, config.Apps.TLS)
+}
+
+func TestBuilder_BuildSingleProxy_UnknownType_ReturnsError(t *testing.T) {
+ proxy := createTestProxy(1, "unknown-proxy", "example.com", "unknown_type", true, true)
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+
+ config, err := b.BuildSingleProxy(&proxy)
+ assert.Error(t, err)
+ assert.Nil(t, config)
+ assert.Contains(t, err.Error(), "unknown proxy type")
+}
+
+func TestBuilder_SetSettings(t *testing.T) {
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderHTTP,
+ WaygatesVerifyURL: "http://localhost:8080/verify",
+ WaygatesLoginURL: "http://localhost:8080/login",
+ }
+
+ aclBuilder := NewACLBuilder(newTestLogger())
+ b := NewBuilder(WithLogger(newTestLogger()), WithACLBuilder(aclBuilder))
+ b.SetSettings(settings)
+
+ // Verify settings were applied to sub-builders
+ assert.NotNil(t, b.tlsBuilder.settings)
+ assert.Equal(t, settings.WaygatesVerifyURL, aclBuilder.waygatesVerifyURL)
+ assert.Equal(t, settings.WaygatesLoginURL, aclBuilder.waygatesLoginURL)
+}
+
+func TestBuilder_SetACLGroups(t *testing.T) {
+ groups := []models.ACLGroup{
+ createTestACLGroup(1, "group1", models.ACLCombinationModeAny),
+ createTestACLGroup(2, "group2", models.ACLCombinationModeAll),
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetACLGroups(groups)
+
+ assert.Len(t, b.aclGroups, 2)
+ assert.NotNil(t, b.aclGroups[1])
+ assert.NotNil(t, b.aclGroups[2])
+ assert.Equal(t, "group1", b.aclGroups[1].Name)
+ assert.Equal(t, "group2", b.aclGroups[2].Name)
+}
+
+func TestBuilder_SetACLAssignments_OnlyEnabled(t *testing.T) {
+ assignments := []models.ProxyACLAssignment{
+ {ID: 1, ProxyID: 1, ACLGroupID: 1, Enabled: true},
+ {ID: 2, ProxyID: 1, ACLGroupID: 2, Enabled: false},
+ {ID: 3, ProxyID: 2, ACLGroupID: 1, Enabled: true},
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetACLAssignments(assignments)
+
+ // Only enabled assignments should be stored
+ assert.Len(t, b.aclAssigns[1], 1)
+ assert.Len(t, b.aclAssigns[2], 1)
+}
+
+// =============================================================================
+// HTTPBuilder Tests
+// =============================================================================
+
+func TestNewHTTPBuilder(t *testing.T) {
+ tests := []struct {
+ name string
+ logger *zap.Logger
+ }{
+ {
+ name: "with logger",
+ logger: newTestLogger(),
+ },
+ {
+ name: "with nil logger",
+ logger: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewHTTPBuilder(tt.logger)
+ require.NotNil(t, b)
+ require.NotNil(t, b.logger)
+ })
+ }
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutes(t *testing.T) {
+ tests := []struct {
+ name string
+ proxy models.Proxy
+ wantErr bool
+ errContain string
+ }{
+ {
+ name: "valid reverse proxy",
+ proxy: createReverseProxy(1, "test", "example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true),
+ wantErr: false,
+ },
+ {
+ name: "multiple upstreams",
+ proxy: createReverseProxy(1, "test", "example.com",
+ []interface{}{
+ createTestUpstream("backend1", 8080, "http"),
+ createTestUpstream("backend2", 8081, "http"),
+ }, true, true),
+ wantErr: false,
+ },
+ {
+ name: "nil upstreams",
+ proxy: createTestProxy(1, "test", "example.com", models.ProxyTypeReverseProxy, true, true),
+ wantErr: true,
+ errContain: "requires at least one upstream",
+ },
+ {
+ name: "empty upstreams array",
+ proxy: createReverseProxy(1, "test", "example.com",
+ []interface{}{}, true, true),
+ wantErr: true,
+ errContain: "requires at least one upstream",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewHTTPBuilder(newTestLogger())
+
+ routes, err := b.BuildReverseProxyRoutes(&tt.proxy)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ if tt.errContain != "" {
+ assert.Contains(t, err.Error(), tt.errContain)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+ assert.True(t, routes[0].Terminal)
+
+ // Check host matcher
+ require.NotEmpty(t, routes[0].Match)
+ hostMatcher := routes[0].Match[0]["host"]
+ assert.Contains(t, hostMatcher, tt.proxy.Hostname)
+ })
+ }
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutes_WithCustomHeaders(t *testing.T) {
+ proxy := createReverseProxy(1, "test", "example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true)
+ proxy.CustomHeaders = models.JSONField{
+ "X-Custom-Header": "custom-value",
+ "X-Another": "another-value",
+ }
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildReverseProxyRoutes(&proxy)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Check that handler has custom headers
+ handler := routes[0].Handle[0]
+ assert.NotNil(t, handler["headers"])
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutes_WithLoadBalancing(t *testing.T) {
+ proxy := createReverseProxy(1, "test", "example.com",
+ []interface{}{
+ createTestUpstream("backend1", 8080, "http"),
+ createTestUpstream("backend2", 8081, "http"),
+ }, true, true)
+ proxy.LoadBalancing = models.JSONField{
+ "strategy": "round_robin",
+ "health_checks": map[string]interface{}{
+ "enabled": true,
+ "path": "/health",
+ "interval": "30s",
+ "timeout": "5s",
+ },
+ }
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildReverseProxyRoutes(&proxy)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Check that handler has load balancing config
+ handler := routes[0].Handle[0]
+ assert.NotNil(t, handler["load_balancing"])
+ assert.NotNil(t, handler["health_checks"])
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutes_WithHTTPSUpstream(t *testing.T) {
+ proxy := createReverseProxy(1, "test", "example.com",
+ []interface{}{createTestUpstream("backend", 443, "https")}, true, true)
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildReverseProxyRoutes(&proxy)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Should have transport config for HTTPS
+ handler := routes[0].Handle[0]
+ assert.NotNil(t, handler["transport"])
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutes_WithTLSInsecureSkipVerify(t *testing.T) {
+ proxy := createReverseProxy(1, "test", "example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true)
+ proxy.TLSInsecureSkipVerify = true
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildReverseProxyRoutes(&proxy)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Should have transport config with insecure skip verify
+ handler := routes[0].Handle[0]
+ assert.NotNil(t, handler["transport"])
+}
+
+func TestHTTPBuilder_BuildReverseProxyRoutesWithACL(t *testing.T) {
+ proxy := createReverseProxy(1, "test", "example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true)
+
+ aclGroup := createTestACLGroup(1, "test-acl", models.ACLCombinationModeAny)
+ aclGroup.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword"),
+ }
+
+ assignments := []models.ProxyACLAssignment{
+ {ID: 1, ProxyID: 1, ACLGroupID: 1, PathPattern: "/*", Priority: 0, Enabled: true},
+ }
+
+ aclGroups := map[int64]*models.ACLGroup{
+ 1: &aclGroup,
+ }
+
+ aclBuilder := NewACLBuilder(newTestLogger())
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildReverseProxyRoutesWithACL(&proxy, assignments, aclGroups, aclBuilder)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Should have multiple routes (ACL routes + fallback)
+ assert.Greater(t, len(routes), 1)
+}
+
+func TestHTTPBuilder_BuildRedirectRoutes(t *testing.T) {
+ tests := []struct {
+ name string
+ proxy models.Proxy
+ wantErr bool
+ errContain string
+ }{
+ {
+ name: "valid redirect",
+ proxy: createRedirectProxy(1, "redirect", "old.example.com", "https://new.example.com", 301, true, true),
+ wantErr: false,
+ },
+ {
+ name: "redirect with preserve path",
+ proxy: func() models.Proxy {
+ p := createRedirectProxy(1, "redirect", "old.example.com", "https://new.example.com", 302, true, true)
+ p.RedirectConfig["preserve_path"] = true
+ return p
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "redirect with preserve query",
+ proxy: func() models.Proxy {
+ p := createRedirectProxy(1, "redirect", "old.example.com", "https://new.example.com", 302, true, true)
+ p.RedirectConfig["preserve_query"] = true
+ return p
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "missing redirect config",
+ proxy: func() models.Proxy {
+ p := createTestProxy(1, "redirect", "old.example.com", models.ProxyTypeRedirect, true, true)
+ p.RedirectConfig = nil
+ return p
+ }(),
+ wantErr: true,
+ errContain: "redirect config is required",
+ },
+ {
+ name: "empty redirect config",
+ proxy: func() models.Proxy {
+ p := createTestProxy(1, "redirect", "old.example.com", models.ProxyTypeRedirect, true, true)
+ p.RedirectConfig = models.JSONField{}
+ return p
+ }(),
+ wantErr: true,
+ errContain: "redirect config is required",
+ },
+ {
+ name: "missing target",
+ proxy: func() models.Proxy {
+ p := createTestProxy(1, "redirect", "old.example.com", models.ProxyTypeRedirect, true, true)
+ p.RedirectConfig = models.JSONField{
+ "status_code": float64(301),
+ }
+ return p
+ }(),
+ wantErr: true,
+ errContain: "redirect target is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewHTTPBuilder(newTestLogger())
+
+ routes, err := b.BuildRedirectRoutes(&tt.proxy)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ if tt.errContain != "" {
+ assert.Contains(t, err.Error(), tt.errContain)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Check handler type
+ handler := routes[0].Handle[0]
+ assert.Equal(t, HandlerStaticResponse, handler["handler"])
+ assert.NotNil(t, handler["headers"])
+ })
+ }
+}
+
+func TestHTTPBuilder_BuildRedirectRoutes_DefaultStatusCode(t *testing.T) {
+ proxy := createTestProxy(1, "redirect", "old.example.com", models.ProxyTypeRedirect, true, true)
+ proxy.RedirectConfig = models.JSONField{
+ "target": "https://new.example.com",
+ // No status_code - should default to 302
+ }
+
+ b := NewHTTPBuilder(newTestLogger())
+ routes, err := b.BuildRedirectRoutes(&proxy)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ handler := routes[0].Handle[0]
+ assert.Equal(t, 302, handler["status_code"])
+}
+
+func TestHTTPBuilder_BuildStaticRoutes(t *testing.T) {
+ tests := []struct {
+ name string
+ proxy models.Proxy
+ wantErr bool
+ errContain string
+ }{
+ {
+ name: "valid static",
+ proxy: createStaticProxy(1, "static", "static.example.com", "/var/www/html", true, true),
+ wantErr: false,
+ },
+ {
+ name: "static with index file",
+ proxy: func() models.Proxy {
+ p := createStaticProxy(1, "static", "static.example.com", "/var/www/html", true, true)
+ p.StaticConfig["index_file"] = "index.html"
+ return p
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "static with browse",
+ proxy: func() models.Proxy {
+ p := createStaticProxy(1, "static", "static.example.com", "/var/www/html", true, true)
+ p.StaticConfig["browse"] = true
+ return p
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "static with try_files (SPA)",
+ proxy: func() models.Proxy {
+ p := createStaticProxy(1, "static", "static.example.com", "/var/www/html", true, true)
+ p.StaticConfig["try_files"] = []interface{}{"{path}", "/index.html"}
+ return p
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "missing static config",
+ proxy: func() models.Proxy {
+ p := createTestProxy(1, "static", "static.example.com", models.ProxyTypeStatic, true, true)
+ p.StaticConfig = nil
+ return p
+ }(),
+ wantErr: true,
+ errContain: "static config is required",
+ },
+ {
+ name: "missing root_path",
+ proxy: func() models.Proxy {
+ p := createTestProxy(1, "static", "static.example.com", models.ProxyTypeStatic, true, true)
+ p.StaticConfig = models.JSONField{
+ "browse": true,
+ }
+ return p
+ }(),
+ wantErr: true,
+ errContain: "root_path is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewHTTPBuilder(newTestLogger())
+
+ routes, err := b.BuildStaticRoutes(&tt.proxy)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ if tt.errContain != "" {
+ assert.Contains(t, err.Error(), tt.errContain)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+ })
+ }
+}
+
+func TestHTTPBuilder_MapLBStrategy(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"round_robin", "round_robin"},
+ {"least_conn", "least_conn"},
+ {"random", "random"},
+ {"first", "first"},
+ {"ip_hash", "ip_hash"},
+ {"uri_hash", "uri_hash"},
+ {"header", "header"},
+ {"unknown", "round_robin"},
+ {"", "round_robin"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := mapLBStrategy(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// TLSBuilder Tests
+// =============================================================================
+
+func TestNewTLSBuilder(t *testing.T) {
+ tests := []struct {
+ name string
+ logger *zap.Logger
+ }{
+ {
+ name: "with logger",
+ logger: newTestLogger(),
+ },
+ {
+ name: "with nil logger",
+ logger: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewTLSBuilder(tt.logger)
+ require.NotNil(t, b)
+ require.NotNil(t, b.logger)
+ })
+ }
+}
+
+func TestTLSBuilder_Build_NoDomains(t *testing.T) {
+ b := NewTLSBuilder(newTestLogger())
+
+ tlsApp, err := b.Build(nil)
+ require.NoError(t, err)
+ assert.Nil(t, tlsApp)
+
+ tlsApp, err = b.Build([]string{})
+ require.NoError(t, err)
+ assert.Nil(t, tlsApp)
+}
+
+func TestTLSBuilder_Build_BasicDomains(t *testing.T) {
+ b := NewTLSBuilder(newTestLogger())
+
+ domains := []string{"example.com", "api.example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ assert.NotNil(t, tlsApp.Certificates)
+ assert.ElementsMatch(t, domains, tlsApp.Certificates.Automate)
+}
+
+func TestTLSBuilder_Build_WithHTTPProvider(t *testing.T) {
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderHTTP,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+ require.NotEmpty(t, tlsApp.Automation.Policies)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ assert.Equal(t, "acme", issuer.Module)
+ assert.Equal(t, "admin@example.com", issuer.Email)
+}
+
+func TestTLSBuilder_Build_WithOffProvider(t *testing.T) {
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderOff,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ // With "off" provider, no automation should be configured
+ assert.Nil(t, tlsApp.Automation)
+}
+
+func TestTLSBuilder_Build_WithCloudflareProvider(t *testing.T) {
+ t.Setenv("CLOUDFLARE_API_TOKEN", "test-cf-token")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderCloudflare,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+ require.NotEmpty(t, tlsApp.Automation.Policies)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ assert.NotNil(t, issuer.Challenges)
+ assert.NotNil(t, issuer.Challenges.DNS)
+
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderCloudflare)
+ require.True(t, ok)
+ assert.Equal(t, "cloudflare", provider.Name)
+ assert.Equal(t, "test-cf-token", provider.APIToken)
+}
+
+func TestTLSBuilder_Build_WithCloudflareProvider_MissingCredentials(t *testing.T) {
+ // Set to empty to simulate missing credentials
+ t.Setenv("CLOUDFLARE_API_TOKEN", "")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderCloudflare,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ // Should not have automation when credentials are missing
+ assert.Nil(t, tlsApp.Automation)
+}
+
+func TestTLSBuilder_Build_WithRoute53Provider(t *testing.T) {
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderRoute53,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ assert.NotNil(t, issuer.Challenges)
+ assert.NotNil(t, issuer.Challenges.DNS)
+
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderRoute53)
+ require.True(t, ok)
+ assert.Equal(t, "route53", provider.Name)
+}
+
+func TestTLSBuilder_Build_WithDuckDNSProvider(t *testing.T) {
+ t.Setenv("DUCKDNS_API_TOKEN", "test-duckdns-token")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderDuckDNS,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.duckdns.org"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderDuckDNS)
+ require.True(t, ok)
+ assert.Equal(t, "duckdns", provider.Name)
+ assert.Equal(t, "test-duckdns-token", provider.APIToken)
+}
+
+func TestTLSBuilder_Build_WithDigitalOceanProvider(t *testing.T) {
+ t.Setenv("DO_AUTH_TOKEN", "test-do-token")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderDigitalOcean,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderDigitalOcean)
+ require.True(t, ok)
+ assert.Equal(t, "digitalocean", provider.Name)
+ assert.Equal(t, "test-do-token", provider.AuthToken)
+}
+
+func TestTLSBuilder_Build_WithHetznerProvider(t *testing.T) {
+ t.Setenv("HETZNER_API_TOKEN", "test-hetzner-token")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderHetzner,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderHetzner)
+ require.True(t, ok)
+ assert.Equal(t, "hetzner", provider.Name)
+ assert.Equal(t, "test-hetzner-token", provider.APIToken)
+}
+
+func TestTLSBuilder_Build_WithPorkbunProvider(t *testing.T) {
+ t.Setenv("PORKBUN_API_KEY", "test-porkbun-key")
+ t.Setenv("PORKBUN_API_SECRET_KEY", "test-porkbun-secret")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderPorkbun,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderPorkbun)
+ require.True(t, ok)
+ assert.Equal(t, "porkbun", provider.Name)
+ assert.Equal(t, "test-porkbun-key", provider.APIKey)
+ assert.Equal(t, "test-porkbun-secret", provider.APISecretKey)
+}
+
+func TestTLSBuilder_Build_WithVultrProvider(t *testing.T) {
+ t.Setenv("VULTR_API_KEY", "test-vultr-key")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderVultr,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderVultr)
+ require.True(t, ok)
+ assert.Equal(t, "vultr", provider.Name)
+ assert.Equal(t, "test-vultr-key", provider.APIKey)
+}
+
+func TestTLSBuilder_Build_WithNamecheapProvider(t *testing.T) {
+ t.Setenv("NAMECHEAP_API_KEY", "test-namecheap-key")
+ t.Setenv("NAMECHEAP_API_USER", "test-namecheap-user")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderNamecheap,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderNamecheap)
+ require.True(t, ok)
+ assert.Equal(t, "namecheap", provider.Name)
+ assert.Equal(t, "test-namecheap-key", provider.APIKey)
+ assert.Equal(t, "test-namecheap-user", provider.User)
+}
+
+func TestTLSBuilder_Build_WithOVHProvider(t *testing.T) {
+ t.Setenv("OVH_ENDPOINT", "ovh-eu")
+ t.Setenv("OVH_APPLICATION_KEY", "test-ovh-app-key")
+ t.Setenv("OVH_APPLICATION_SECRET", "test-ovh-app-secret")
+ t.Setenv("OVH_CONSUMER_KEY", "test-ovh-consumer-key")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderOVH,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderOVH)
+ require.True(t, ok)
+ assert.Equal(t, "ovh", provider.Name)
+ assert.Equal(t, "ovh-eu", provider.Endpoint)
+}
+
+func TestTLSBuilder_Build_WithAzureProvider(t *testing.T) {
+ t.Setenv("AZURE_TENANT_ID", "test-tenant")
+ t.Setenv("AZURE_CLIENT_ID", "test-client")
+ t.Setenv("AZURE_CLIENT_SECRET", "test-secret")
+ t.Setenv("AZURE_SUBSCRIPTION_ID", "test-subscription")
+ t.Setenv("AZURE_RESOURCE_GROUP", "test-rg")
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderAzure,
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ provider, ok := issuer.Challenges.DNS.Provider.(*DNSProviderAzure)
+ require.True(t, ok)
+ assert.Equal(t, "azure", provider.Name)
+ assert.Equal(t, "test-tenant", provider.TenantID)
+ assert.Equal(t, "test-client", provider.ClientID)
+}
+
+func TestTLSBuilder_Build_WithUnknownProvider_FallsBackToHTTP(t *testing.T) {
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: "unknown_provider",
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ domains := []string{"example.com"}
+ tlsApp, err := b.Build(domains)
+
+ require.NoError(t, err)
+ require.NotNil(t, tlsApp)
+ require.NotNil(t, tlsApp.Automation)
+
+ issuer := tlsApp.Automation.Policies[0].Issuers[0]
+ assert.Equal(t, "acme", issuer.Module)
+ assert.Nil(t, issuer.Challenges) // No DNS challenge for fallback
+}
+
+func TestTLSBuilder_GetCredential_FromSettings(t *testing.T) {
+ settings := &Settings{
+ DNSCredentials: map[string]string{
+ "TEST_KEY": "from-settings",
+ },
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ // Should prefer settings over env
+ value := b.getCredential("TEST_KEY")
+ assert.Equal(t, "from-settings", value)
+}
+
+func TestTLSBuilder_GetCredential_FromEnv(t *testing.T) {
+ t.Setenv("TEST_CREDENTIAL_KEY", "from-env")
+
+ settings := &Settings{
+ DNSCredentials: nil, // No settings credentials
+ }
+
+ b := NewTLSBuilder(newTestLogger())
+ b.SetSettings(settings)
+
+ value := b.getCredential("TEST_CREDENTIAL_KEY")
+ assert.Equal(t, "from-env", value)
+}
+
+// =============================================================================
+// ACLBuilder Tests
+// =============================================================================
+
+func TestNewACLBuilder(t *testing.T) {
+ tests := []struct {
+ name string
+ logger *zap.Logger
+ }{
+ {
+ name: "with logger",
+ logger: newTestLogger(),
+ },
+ {
+ name: "with nil logger",
+ logger: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := NewACLBuilder(tt.logger)
+ require.NotNil(t, b)
+ require.NotNil(t, b.logger)
+ })
+ }
+}
+
+func TestACLBuilder_SetWaygatesURLs(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ b.SetWaygatesURLs("http://localhost:8080/verify", "http://localhost:8080/login")
+
+ assert.Equal(t, "http://localhost:8080/verify", b.waygatesVerifyURL)
+ assert.Equal(t, "http://localhost:8080/login", b.waygatesLoginURL)
+}
+
+func TestACLBuilder_BuildACLRoutes_NilGroup(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", nil, upstreamHandler)
+
+ require.NoError(t, err)
+ assert.Nil(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_NoAuthConfigured(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ // Group with no auth methods
+ group := createTestACLGroup(1, "empty-acl", models.ACLCombinationModeAny)
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ assert.Nil(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_AnyMode_WithIPRules(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "ip-acl", models.ACLCombinationModeAny)
+ group.IPRules = []models.ACLIPRule{
+ createTestIPRule(models.ACLIPRuleTypeDeny, "10.0.0.0/8", 1),
+ createTestIPRule(models.ACLIPRuleTypeBypass, "192.168.1.0/24", 2),
+ createTestIPRule(models.ACLIPRuleTypeAllow, "172.16.0.0/12", 3),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Should have routes for deny, bypass, allow, static assets
+ assert.GreaterOrEqual(t, len(routes), 4)
+}
+
+func TestACLBuilder_BuildACLRoutes_AnyMode_WithBasicAuth(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "basic-auth-acl", models.ACLCombinationModeAny)
+ group.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword1"),
+ createTestBasicAuthUser("user", "$2a$12$hashedpassword2"),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+
+ // Check that one route has authentication handler
+ foundAuth := false
+ for _, route := range routes {
+ for _, handler := range route.Handle {
+ if handler["handler"] == HandlerAuthentication {
+ foundAuth = true
+ break
+ }
+ }
+ }
+ assert.True(t, foundAuth, "Should have authentication handler")
+}
+
+func TestACLBuilder_BuildACLRoutes_AnyMode_WithWaygatesAuth(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ b.SetWaygatesURLs("http://localhost:8080/verify", "http://localhost:8080/login")
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "waygates-acl", models.ACLCombinationModeAny)
+ group.WaygatesAuth = createTestWaygatesAuth(true, []string{"google", "github"})
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_AnyMode_WithExternalProvider(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "external-acl", models.ACLCombinationModeAny)
+ group.ExternalProviders = []models.ACLExternalProvider{
+ createTestExternalProvider(models.ACLProviderTypeAuthelia, "authelia", "http://authelia.local/api/verify"),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_AllMode(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "all-mode-acl", models.ACLCombinationModeAll)
+ group.IPRules = []models.ACLIPRule{
+ createTestIPRule(models.ACLIPRuleTypeAllow, "192.168.1.0/24", 1),
+ }
+ group.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword"),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_IPBypassMode(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "ip-bypass-acl", models.ACLCombinationModeIPBypass)
+ group.IPRules = []models.ACLIPRule{
+ createTestIPRule(models.ACLIPRuleTypeBypass, "192.168.1.0/24", 1),
+ }
+ group.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword"),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+}
+
+func TestACLBuilder_BuildACLRoutes_WithPathPattern(t *testing.T) {
+ b := NewACLBuilder(newTestLogger())
+ upstreamHandler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ group := createTestACLGroup(1, "path-acl", models.ACLCombinationModeAny)
+ group.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("admin", "$2a$12$hashedpassword"),
+ }
+
+ routes, err := b.BuildACLRoutes("example.com", "/api/*", &group, upstreamHandler)
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+}
+
+func TestCategorizeIPRules(t *testing.T) {
+ rules := []models.ACLIPRule{
+ createTestIPRule(models.ACLIPRuleTypeBypass, "192.168.1.0/24", 3),
+ createTestIPRule(models.ACLIPRuleTypeDeny, "10.0.0.0/8", 1),
+ createTestIPRule(models.ACLIPRuleTypeAllow, "172.16.0.0/12", 2),
+ createTestIPRule(models.ACLIPRuleTypeBypass, "192.168.2.0/24", 4),
+ }
+
+ bypass, allow, deny := categorizeIPRules(rules)
+
+ // Should be sorted by priority and categorized
+ assert.Len(t, deny, 1)
+ assert.Contains(t, deny, "10.0.0.0/8")
+
+ assert.Len(t, allow, 1)
+ assert.Contains(t, allow, "172.16.0.0/12")
+
+ assert.Len(t, bypass, 2)
+ assert.Contains(t, bypass, "192.168.1.0/24")
+ assert.Contains(t, bypass, "192.168.2.0/24")
+}
+
+func TestGetDefaultWaygatesHeaders(t *testing.T) {
+ headers := GetDefaultWaygatesHeaders()
+
+ assert.Contains(t, headers, "X-Auth-User")
+ assert.Contains(t, headers, "X-Auth-User-ID")
+ assert.Contains(t, headers, "X-Auth-User-Email")
+}
+
+func TestGetProviderDefaultHeaders(t *testing.T) {
+ tests := []struct {
+ providerType string
+ expectedHeaders []string
+ }{
+ {
+ providerType: models.ACLProviderTypeAuthelia,
+ expectedHeaders: []string{"Remote-User", "Remote-Groups", "Remote-Name", "Remote-Email"},
+ },
+ {
+ providerType: models.ACLProviderTypeAuthentik,
+ expectedHeaders: []string{"X-authentik-username", "X-authentik-groups", "X-authentik-email"},
+ },
+ {
+ providerType: "unknown",
+ expectedHeaders: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.providerType, func(t *testing.T) {
+ headers := GetProviderDefaultHeaders(tt.providerType)
+ if tt.expectedHeaders == nil {
+ assert.Nil(t, headers)
+ } else {
+ for _, expected := range tt.expectedHeaders {
+ assert.Contains(t, headers, expected)
+ }
+ }
+ })
+ }
+}
+
+func TestExtractDialAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "http URL with port",
+ input: "http://waygates:8080",
+ expected: "waygates:8080",
+ },
+ {
+ name: "https URL with port",
+ input: "https://auth.example.com:443",
+ expected: "auth.example.com:443",
+ },
+ {
+ name: "http URL without port",
+ input: "http://waygates",
+ expected: "waygates:80",
+ },
+ {
+ name: "https URL without port",
+ input: "https://secure.example.com",
+ expected: "secure.example.com:443",
+ },
+ {
+ name: "URL with path",
+ input: "http://localhost:8080/verify",
+ expected: "localhost:8080",
+ },
+ {
+ name: "already host:port format",
+ input: "waygates:8080",
+ expected: "waygates:8080",
+ },
+ {
+ name: "IP address with port",
+ input: "http://192.168.1.100:9000",
+ expected: "192.168.1.100:9000",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := extractDialAddress(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// Integration Tests
+// =============================================================================
+
+func TestBuilder_FullIntegration_ReverseProxyWithACL(t *testing.T) {
+ // Create a complete configuration with reverse proxy and ACL
+ upstreams := []interface{}{
+ createTestUpstream("backend1.local", 8080, "http"),
+ createTestUpstream("backend2.local", 8081, "http"),
+ }
+
+ proxy := createReverseProxy(1, "api-proxy", "api.example.com", upstreams, true, true)
+ proxy.LoadBalancing = models.JSONField{
+ "strategy": "round_robin",
+ }
+ proxy.CustomHeaders = models.JSONField{
+ "X-API-Version": "v1",
+ }
+
+ aclGroup := createTestACLGroup(1, "api-acl", models.ACLCombinationModeAny)
+ aclGroup.IPRules = []models.ACLIPRule{
+ createTestIPRule(models.ACLIPRuleTypeDeny, "10.0.0.0/8", 1),
+ createTestIPRule(models.ACLIPRuleTypeBypass, "192.168.1.0/24", 2),
+ }
+ aclGroup.BasicAuthUsers = []models.ACLBasicAuthUser{
+ createTestBasicAuthUser("api-user", "$2a$12$hashedpassword"),
+ }
+
+ assignment := models.ProxyACLAssignment{
+ ID: 1,
+ ProxyID: 1,
+ ACLGroupID: 1,
+ PathPattern: "/api/*",
+ Priority: 0,
+ Enabled: true,
+ }
+
+ notFoundSettings := &models.NotFoundSettings{
+ Mode: "redirect",
+ RedirectURL: "https://home.example.com",
+ }
+
+ settings := &Settings{
+ AdminEmail: "admin@example.com",
+ ACMEProvider: ACMEProviderHTTP,
+ WaygatesVerifyURL: "http://localhost:8080/verify",
+ WaygatesLoginURL: "http://localhost:8080/login",
+ }
+
+ aclBuilder := NewACLBuilder(newTestLogger())
+
+ b := NewBuilder(WithLogger(newTestLogger()), WithACLBuilder(aclBuilder))
+ b.SetSettings(settings)
+ b.SetHTTPProxies([]models.Proxy{proxy})
+ b.SetACLGroups([]models.ACLGroup{aclGroup})
+ b.SetACLAssignments([]models.ProxyACLAssignment{assignment})
+ b.SetNotFoundSettings(notFoundSettings)
+
+ // Build the configuration
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Verify admin config
+ assert.Equal(t, "localhost:2019", config.Admin.Listen)
+
+ // Verify storage config
+ assert.Equal(t, "file_system", config.Storage.Module)
+
+ // Verify HTTP app
+ assert.NotNil(t, config.Apps.HTTP)
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+ assert.NotEmpty(t, server.Routes)
+
+ // Verify TLS app
+ assert.NotNil(t, config.Apps.TLS)
+ assert.Contains(t, config.Apps.TLS.Certificates.Automate, "api.example.com")
+
+ // Build JSON and verify it's valid
+ jsonBytes, err := b.BuildJSON()
+ require.NoError(t, err)
+
+ var result map[string]interface{}
+ err = json.Unmarshal(jsonBytes, &result)
+ require.NoError(t, err)
+ assert.Contains(t, result, "admin")
+ assert.Contains(t, result, "storage")
+ assert.Contains(t, result, "apps")
+}
+
+func TestBuilder_FullIntegration_MultipleProxyTypes(t *testing.T) {
+ proxies := []models.Proxy{
+ createReverseProxy(1, "api", "api.example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true),
+ createRedirectProxy(2, "old-site", "old.example.com",
+ "https://new.example.com", 301, true, true),
+ createStaticProxy(3, "docs", "docs.example.com",
+ "/var/www/docs", true, true),
+ }
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies(proxies)
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Should have routes for all active proxies
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+ assert.GreaterOrEqual(t, len(server.Routes), 4) // 3 proxies + catch-all
+
+ // TLS should include all SSL-enabled domains
+ tlsDomains := config.Apps.TLS.Certificates.Automate
+ assert.Contains(t, tlsDomains, "api.example.com")
+ assert.Contains(t, tlsDomains, "old.example.com")
+ assert.Contains(t, tlsDomains, "docs.example.com")
+}
+
+func TestBuilder_FullIntegration_SecurityRoutes(t *testing.T) {
+ // Create a proxy with BlockExploits enabled
+ proxy := createReverseProxy(1, "secure-api", "secure.example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true)
+ proxy.BlockExploits = true
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies([]models.Proxy{proxy})
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+
+ // Should have security routes (6) + proxy route (1) + catch-all (1) = 8 routes
+ assert.GreaterOrEqual(t, len(server.Routes), 8, "Expected at least 8 routes (6 security + 1 proxy + 1 catch-all)")
+
+ // Verify security routes are present (they should be first)
+ // Check first route is a security route (SQL injection)
+ firstRoute := server.Routes[0]
+ require.Len(t, firstRoute.Match, 1, "First route should have 1 matcher")
+
+ // Check that the route has host matching for our hostname
+ hostMatcher, hasHost := firstRoute.Match[0]["host"]
+ if hasHost {
+ hosts, ok := hostMatcher.([]string)
+ require.True(t, ok)
+ assert.Contains(t, hosts, "secure.example.com")
+ }
+
+ // Verify security route has 403 response
+ require.Len(t, firstRoute.Handle, 1)
+ assert.Equal(t, "static_response", firstRoute.Handle[0]["handler"])
+ assert.Equal(t, 403, firstRoute.Handle[0]["status_code"])
+}
+
+func TestBuilder_FullIntegration_NoSecurityRoutesWhenDisabled(t *testing.T) {
+ // Create a proxy with BlockExploits disabled
+ proxy := createReverseProxy(1, "api", "api.example.com",
+ []interface{}{createTestUpstream("backend", 8080, "http")}, true, true)
+ proxy.BlockExploits = false
+
+ b := NewBuilder(WithLogger(newTestLogger()))
+ b.SetHTTPProxies([]models.Proxy{proxy})
+
+ config, err := b.Build()
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ server := config.Apps.HTTP.Servers[DefaultServerName]
+ require.NotNil(t, server)
+
+ // Should have only proxy route (1) + catch-all (1) = 2 routes
+ assert.Equal(t, 2, len(server.Routes), "Expected 2 routes (1 proxy + 1 catch-all)")
+
+ // First route should be the proxy route, not a security route
+ firstRoute := server.Routes[0]
+ if len(firstRoute.Handle) > 0 {
+ // Security routes return static_response with 403
+ // Proxy routes use reverse_proxy or subroute handler
+ handler := firstRoute.Handle[0]["handler"]
+ assert.NotEqual(t, "static_response", handler, "First route should not be a security route")
+ }
+}
diff --git a/backend/internal/caddy/config/http.go b/backend/internal/caddy/config/http.go
new file mode 100644
index 0000000..450f9d1
--- /dev/null
+++ b/backend/internal/caddy/config/http.go
@@ -0,0 +1,247 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+// DefaultServerName is the default HTTP server name.
+const DefaultServerName = "srv0"
+
+// Common port configurations.
+const (
+ PortHTTP = 80
+ PortHTTPS = 443
+)
+
+// NewHTTPApp creates a new HTTP application configuration.
+func NewHTTPApp() *HTTPApp {
+ return &HTTPApp{
+ Servers: make(map[string]*HTTPServer),
+ }
+}
+
+// NewHTTPServer creates a new HTTP server with the given listen addresses.
+func NewHTTPServer(listen ...string) *HTTPServer {
+ return &HTTPServer{
+ Listen: listen,
+ Routes: make([]*HTTPRoute, 0),
+ }
+}
+
+// NewHTTPRoute creates a new HTTP route.
+func NewHTTPRoute() *HTTPRoute {
+ return &HTTPRoute{
+ Match: make([]MatcherSet, 0),
+ Handle: make([]HTTPHandler, 0),
+ }
+}
+
+// NewHTTPRouteWithMatch creates a new HTTP route with matchers.
+func NewHTTPRouteWithMatch(matchers ...MatcherSet) *HTTPRoute {
+ return &HTTPRoute{
+ Match: matchers,
+ Handle: make([]HTTPHandler, 0),
+ }
+}
+
+// AddServer adds a server to the HTTP app.
+func (a *HTTPApp) AddServer(name string, server *HTTPServer) *HTTPApp {
+ if a.Servers == nil {
+ a.Servers = make(map[string]*HTTPServer)
+ }
+ a.Servers[name] = server
+ return a
+}
+
+// AddRoute adds a route to the HTTP server.
+func (s *HTTPServer) AddRoute(route *HTTPRoute) *HTTPServer {
+ s.Routes = append(s.Routes, route)
+ return s
+}
+
+// AddRoutes adds multiple routes to the HTTP server.
+func (s *HTTPServer) AddRoutes(routes ...*HTTPRoute) *HTTPServer {
+ s.Routes = append(s.Routes, routes...)
+ return s
+}
+
+// WithAutoHTTPS configures automatic HTTPS for the server.
+func (s *HTTPServer) WithAutoHTTPS(config *AutoHTTPSConfig) *HTTPServer {
+ s.AutoHTTPS = config
+ return s
+}
+
+// DisableAutoHTTPS disables automatic HTTPS for the server.
+func (s *HTTPServer) DisableAutoHTTPS() *HTTPServer {
+ s.AutoHTTPS = &AutoHTTPSConfig{
+ Disabled: true,
+ }
+ return s
+}
+
+// AddMatch adds a matcher set to the route.
+func (r *HTTPRoute) AddMatch(matchers ...MatcherSet) *HTTPRoute {
+ r.Match = append(r.Match, matchers...)
+ return r
+}
+
+// AddHandler adds a handler to the route.
+func (r *HTTPRoute) AddHandler(handler HTTPHandler) *HTTPRoute {
+ r.Handle = append(r.Handle, handler)
+ return r
+}
+
+// SetTerminal marks the route as terminal.
+func (r *HTTPRoute) SetTerminal(terminal bool) *HTTPRoute {
+ r.Terminal = terminal
+ return r
+}
+
+// NewReverseProxyRoute creates a route for reverse proxying to the given upstreams.
+func NewReverseProxyRoute(hosts []string, upstreams []*Upstream) *HTTPRoute {
+ handler := NewReverseProxyHandler(upstreams...)
+ handler.Headers = &HeadersConfig{
+ Request: StandardProxyHeaders(),
+ }
+
+ route := NewHTTPRoute()
+ if len(hosts) > 0 {
+ route.AddMatch(NewHostMatcher(hosts...))
+ }
+ route.AddHandler(ToHTTPHandler(handler))
+
+ return route
+}
+
+// NewRedirectRoute creates a route for redirecting to a target URL.
+func NewRedirectRoute(hosts []string, targetURL string, statusCode int) *HTTPRoute {
+ handler := NewRedirectHandler(targetURL, statusCode)
+
+ route := NewHTTPRoute()
+ if len(hosts) > 0 {
+ route.AddMatch(NewHostMatcher(hosts...))
+ }
+ route.AddHandler(ToHTTPHandler(handler))
+
+ return route
+}
+
+// NewStaticFileRoute creates a route for serving static files.
+func NewStaticFileRoute(hosts []string, rootPath string, indexNames []string) *HTTPRoute {
+ handler := NewFileServerHandler(rootPath)
+ if len(indexNames) > 0 {
+ handler.WithIndexNames(indexNames...)
+ }
+
+ route := NewHTTPRoute()
+ if len(hosts) > 0 {
+ route.AddMatch(NewHostMatcher(hosts...))
+ }
+ route.AddHandler(ToHTTPHandler(handler))
+
+ return route
+}
+
+// NewErrorRoute creates a route that responds with an error.
+func NewErrorRoute(hosts []string, statusCode int, body string) *HTTPRoute {
+ handler := NewStaticResponseHandler(statusCode, body)
+
+ route := NewHTTPRoute()
+ if len(hosts) > 0 {
+ route.AddMatch(NewHostMatcher(hosts...))
+ }
+ route.AddHandler(ToHTTPHandler(handler))
+
+ return route
+}
+
+// NewCatchAllRoute creates a catch-all route that responds with a 404.
+func NewCatchAllRoute() *HTTPRoute {
+ return &HTTPRoute{
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewStaticResponseHandler(404, "Not Found")),
+ },
+ }
+}
+
+// NewCatchAllRedirectRoute creates a catch-all route that redirects.
+func NewCatchAllRedirectRoute(targetURL string) *HTTPRoute {
+ return &HTTPRoute{
+ Handle: []HTTPHandler{
+ ToHTTPHandler(NewRedirectHandler(targetURL, 302)),
+ },
+ }
+}
+
+// BuildDefaultServer creates a default HTTP server with standard configuration.
+func BuildDefaultServer(routes []*HTTPRoute) *HTTPServer {
+ return &HTTPServer{
+ Listen: []string{":443"},
+ Routes: routes,
+ }
+}
+
+// BuildHTTPOnlyServer creates an HTTP-only server (no TLS).
+func BuildHTTPOnlyServer(routes []*HTTPRoute) *HTTPServer {
+ return &HTTPServer{
+ Listen: []string{":80"},
+ Routes: routes,
+ AutoHTTPS: &AutoHTTPSConfig{
+ Disabled: true,
+ },
+ }
+}
+
+// ListenAddress formats a listen address with optional host and port.
+func ListenAddress(host string, port int) string {
+ if host == "" {
+ return ":" + itoa(port)
+ }
+ return host + ":" + itoa(port)
+}
+
+// GroupRoutesByHost groups routes by their host matcher.
+// Routes without a host matcher are placed in an empty string key.
+func GroupRoutesByHost(routes []*HTTPRoute) map[string][]*HTTPRoute {
+ grouped := make(map[string][]*HTTPRoute)
+
+ for _, route := range routes {
+ hosts := extractHosts(route)
+ if len(hosts) == 0 {
+ grouped[""] = append(grouped[""], route)
+ } else {
+ for _, host := range hosts {
+ grouped[host] = append(grouped[host], route)
+ }
+ }
+ }
+
+ return grouped
+}
+
+// extractHosts extracts host names from a route's matchers.
+func extractHosts(route *HTTPRoute) []string {
+ var hosts []string
+ for _, matchSet := range route.Match {
+ if hostMatch, ok := matchSet["host"].(MatchHost); ok {
+ hosts = append(hosts, hostMatch...)
+ }
+ if hostMatch, ok := matchSet["host"].([]string); ok {
+ hosts = append(hosts, hostMatch...)
+ }
+ }
+ return hosts
+}
+
+// CollectDomainsFromRoutes collects all unique domains from routes for TLS configuration.
+func CollectDomainsFromRoutes(routes []*HTTPRoute) []string {
+ domainSet := make(map[string]bool)
+ for _, route := range routes {
+ for _, host := range extractHosts(route) {
+ domainSet[host] = true
+ }
+ }
+
+ domains := make([]string, 0, len(domainSet))
+ for domain := range domainSet {
+ domains = append(domains, domain)
+ }
+ return domains
+}
diff --git a/backend/internal/caddy/config/http_builder.go b/backend/internal/caddy/config/http_builder.go
new file mode 100644
index 0000000..889e925
--- /dev/null
+++ b/backend/internal/caddy/config/http_builder.go
@@ -0,0 +1,452 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+import (
+ "fmt"
+ "sort"
+
+ "go.uber.org/zap"
+
+ "github.com/aloks98/waygates/backend/internal/models"
+)
+
+// HTTPBuilder builds HTTP routes from proxy configurations.
+type HTTPBuilder struct {
+ logger *zap.Logger
+}
+
+// NewHTTPBuilder creates a new HTTP builder.
+func NewHTTPBuilder(logger *zap.Logger) *HTTPBuilder {
+ if logger == nil {
+ logger = zap.NewNop()
+ }
+ return &HTTPBuilder{
+ logger: logger,
+ }
+}
+
+// BuildReverseProxyRoutes builds routes for a reverse proxy.
+func (b *HTTPBuilder) BuildReverseProxyRoutes(proxy *models.Proxy) ([]*HTTPRoute, error) {
+ if proxy.Upstreams == nil {
+ return nil, fmt.Errorf("reverse proxy requires at least one upstream")
+ }
+
+ upstreams, err := b.parseUpstreams(proxy.Upstreams)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(upstreams) == 0 {
+ return nil, fmt.Errorf("reverse proxy requires at least one upstream")
+ }
+
+ // Build the reverse proxy handler
+ handler := b.buildReverseProxyHandler(proxy, upstreams)
+
+ // Create the route
+ route := NewHTTPRoute()
+ route.AddMatch(NewHostMatcher(proxy.Hostname))
+ route.AddHandler(handlerToMap(handler))
+ route.SetTerminal(true)
+
+ return []*HTTPRoute{route}, nil
+}
+
+// BuildReverseProxyRoutesWithACL builds routes for a reverse proxy with ACL protection.
+func (b *HTTPBuilder) BuildReverseProxyRoutesWithACL(
+ proxy *models.Proxy,
+ assignments []models.ProxyACLAssignment,
+ aclGroups map[int64]*models.ACLGroup,
+ aclBuilder *ACLBuilder,
+) ([]*HTTPRoute, error) {
+ if proxy.Upstreams == nil {
+ return nil, fmt.Errorf("reverse proxy requires at least one upstream")
+ }
+
+ upstreams, err := b.parseUpstreams(proxy.Upstreams)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(upstreams) == 0 {
+ return nil, fmt.Errorf("reverse proxy requires at least one upstream")
+ }
+
+ hostname := proxy.Hostname
+ handler := b.buildReverseProxyHandler(proxy, upstreams)
+
+ // Sort assignments by priority (lower number = higher priority)
+ sortedAssignments := make([]models.ProxyACLAssignment, len(assignments))
+ copy(sortedAssignments, assignments)
+ sort.Slice(sortedAssignments, func(i, j int) bool {
+ return sortedAssignments[i].Priority < sortedAssignments[j].Priority
+ })
+
+ // Build ACL routes
+ var routes []*HTTPRoute
+ for _, assignment := range sortedAssignments {
+ group, ok := aclGroups[int64(assignment.ACLGroupID)]
+ if !ok {
+ b.logger.Warn("ACL group not found for assignment",
+ zap.Int("assignment_id", assignment.ID),
+ zap.Int("group_id", assignment.ACLGroupID),
+ )
+ continue
+ }
+
+ aclRoutes, err := aclBuilder.BuildACLRoutes(hostname, assignment.PathPattern, group, handler)
+ if err != nil {
+ b.logger.Warn("Failed to build ACL routes",
+ zap.Int("assignment_id", assignment.ID),
+ zap.Error(err),
+ )
+ continue
+ }
+
+ routes = append(routes, aclRoutes...)
+ }
+
+ // Add fallback route for paths not covered by ACL
+ fallbackRoute := NewHTTPRoute()
+ fallbackRoute.AddMatch(NewHostMatcher(hostname))
+ fallbackRoute.AddHandler(handlerToMap(handler))
+ fallbackRoute.SetTerminal(true)
+ routes = append(routes, fallbackRoute)
+
+ return routes, nil
+}
+
+// BuildRedirectRoutes builds routes for a redirect proxy.
+func (b *HTTPBuilder) BuildRedirectRoutes(proxy *models.Proxy) ([]*HTTPRoute, error) {
+ redirectConfig, err := b.parseRedirectConfig(proxy.RedirectConfig)
+ if err != nil {
+ return nil, fmt.Errorf("invalid redirect config: %w", err)
+ }
+
+ targetURL := redirectConfig.Target
+ if redirectConfig.PreservePath {
+ targetURL += "{uri}"
+ } else if redirectConfig.PreserveQuery {
+ targetURL += "{query}"
+ }
+
+ statusCode := redirectConfig.StatusCode
+ if statusCode == 0 {
+ statusCode = 302 // Default to temporary redirect
+ }
+
+ handler := NewRedirectHandler(targetURL, statusCode)
+
+ route := NewHTTPRoute()
+ route.AddMatch(NewHostMatcher(proxy.Hostname))
+ route.AddHandler(ToHTTPHandler(handler))
+ route.SetTerminal(true)
+
+ return []*HTTPRoute{route}, nil
+}
+
+// BuildStaticRoutes builds routes for a static file server proxy.
+func (b *HTTPBuilder) BuildStaticRoutes(proxy *models.Proxy) ([]*HTTPRoute, error) {
+ staticConfig, err := b.parseStaticConfig(proxy.StaticConfig)
+ if err != nil {
+ return nil, fmt.Errorf("invalid static config: %w", err)
+ }
+
+ handler := NewFileServerHandler(staticConfig.RootPath)
+
+ if staticConfig.IndexFile != "" {
+ handler.WithIndexNames(staticConfig.IndexFile)
+ }
+
+ if staticConfig.Browse {
+ handler.WithBrowse("")
+ }
+
+ hostname := proxy.Hostname
+ var routes []*HTTPRoute
+
+ // If try_files is configured (for SPAs), add a rewrite route
+ if len(staticConfig.TryFiles) > 0 {
+ // For SPA support, we need to try files in order
+ // This is typically: try_files {path} /index.html
+ rewriteHandler := &RewriteHandler{
+ Handler: HandlerRewrite,
+ URI: staticConfig.TryFiles[len(staticConfig.TryFiles)-1], // Usually /index.html
+ }
+
+ rewriteRoute := NewHTTPRoute()
+ rewriteRoute.AddMatch(NewHostMatcher(hostname))
+ rewriteRoute.AddHandler(HTTPHandler{
+ "handler": rewriteHandler.Handler,
+ "uri": rewriteHandler.URI,
+ })
+ routes = append(routes, rewriteRoute)
+ }
+
+ // Main file server route
+ route := NewHTTPRoute()
+ route.AddMatch(NewHostMatcher(hostname))
+ route.AddHandler(ToHTTPHandler(handler))
+ route.SetTerminal(true)
+ routes = append(routes, route)
+
+ return routes, nil
+}
+
+// buildReverseProxyHandler builds a reverse proxy handler with all configurations.
+func (b *HTTPBuilder) buildReverseProxyHandler(proxy *models.Proxy, upstreams []*Upstream) *ReverseProxyHandler {
+ handler := NewReverseProxyHandler(upstreams...)
+
+ // Add standard proxy headers
+ handler.Headers = &HeadersConfig{
+ Request: StandardProxyHeaders(),
+ }
+
+ // Add custom headers
+ if len(proxy.CustomHeaders) > 0 {
+ for key, value := range proxy.CustomHeaders {
+ if strVal, ok := value.(string); ok {
+ handler.Headers.Request.SetHeader(key, strVal)
+ }
+ }
+ }
+
+ // Configure load balancing
+ if len(proxy.LoadBalancing) > 0 {
+ b.configureLoadBalancing(handler, proxy.LoadBalancing)
+ }
+
+ // Configure TLS transport if needed
+ hasHTTPS := b.hasHTTPSUpstream(proxy.Upstreams)
+ if hasHTTPS || proxy.TLSInsecureSkipVerify {
+ handler.Transport = &HTTPTransport{
+ Protocol: "http", // Required by Caddy to identify the transport module
+ TLS: &TLSConfig{
+ InsecureSkipVerify: proxy.TLSInsecureSkipVerify,
+ },
+ }
+ }
+
+ return handler
+}
+
+// configureLoadBalancing configures load balancing on a reverse proxy handler.
+func (b *HTTPBuilder) configureLoadBalancing(handler *ReverseProxyHandler, lb models.JSONField) {
+ strategy, _ := lb["strategy"].(string)
+ if strategy == "" {
+ return
+ }
+
+ handler.LoadBalancing = &LoadBalancing{
+ SelectionPolicy: &SelectionPolicy{
+ Policy: mapLBStrategy(strategy),
+ },
+ }
+
+ // Configure health checks if enabled
+ if healthChecks, ok := lb["health_checks"].(map[string]interface{}); ok {
+ if enabled, _ := healthChecks["enabled"].(bool); enabled {
+ handler.HealthChecks = &HealthChecks{
+ Active: &ActiveHealthCheck{},
+ }
+
+ if path, ok := healthChecks["path"].(string); ok && path != "" {
+ handler.HealthChecks.Active.Path = path
+ }
+ if interval, ok := healthChecks["interval"].(string); ok && interval != "" {
+ handler.HealthChecks.Active.Interval = Duration(interval)
+ }
+ if timeout, ok := healthChecks["timeout"].(string); ok && timeout != "" {
+ handler.HealthChecks.Active.Timeout = Duration(timeout)
+ }
+ }
+ }
+}
+
+// parseUpstreams parses upstream configuration from interface{}.
+func (b *HTTPBuilder) parseUpstreams(upstreamsRaw interface{}) ([]*Upstream, error) {
+ upstreamsList, ok := upstreamsRaw.([]interface{})
+ if !ok {
+ return nil, fmt.Errorf("upstreams must be an array")
+ }
+
+ var upstreams []*Upstream
+ for _, up := range upstreamsList {
+ upstreamMap, ok := up.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ host, _ := upstreamMap["host"].(string)
+ port, _ := upstreamMap["port"].(float64)
+ scheme, _ := upstreamMap["scheme"].(string)
+
+ if host == "" {
+ continue
+ }
+
+ dial := host
+ if port > 0 {
+ dial = fmt.Sprintf("%s:%d", host, int(port))
+ }
+
+ // For HTTPS upstreams, Caddy needs to know to use TLS
+ // This is handled at the transport level, not the dial address
+ _ = scheme // Used to detect HTTPS for transport config
+
+ upstreams = append(upstreams, &Upstream{
+ Dial: dial,
+ })
+ }
+
+ return upstreams, nil
+}
+
+// hasHTTPSUpstream checks if any upstream uses HTTPS.
+func (b *HTTPBuilder) hasHTTPSUpstream(upstreamsRaw interface{}) bool {
+ upstreamsList, ok := upstreamsRaw.([]interface{})
+ if !ok {
+ return false
+ }
+
+ for _, up := range upstreamsList {
+ upstreamMap, ok := up.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ scheme, _ := upstreamMap["scheme"].(string)
+ if scheme == "https" {
+ return true
+ }
+ }
+
+ return false
+}
+
+// RedirectConfig represents redirect proxy configuration.
+type RedirectConfig struct {
+ Target string `json:"target"`
+ StatusCode int `json:"status_code"`
+ PreservePath bool `json:"preserve_path"`
+ PreserveQuery bool `json:"preserve_query"`
+}
+
+// parseRedirectConfig parses redirect configuration from JSONField.
+func (b *HTTPBuilder) parseRedirectConfig(config models.JSONField) (*RedirectConfig, error) {
+ if len(config) == 0 {
+ return nil, fmt.Errorf("redirect config is required")
+ }
+
+ rc := &RedirectConfig{}
+
+ if target, ok := config["target"].(string); ok {
+ rc.Target = target
+ } else {
+ return nil, fmt.Errorf("redirect target is required")
+ }
+
+ if statusCode, ok := config["status_code"].(float64); ok {
+ rc.StatusCode = int(statusCode)
+ }
+
+ if preservePath, ok := config["preserve_path"].(bool); ok {
+ rc.PreservePath = preservePath
+ }
+
+ if preserveQuery, ok := config["preserve_query"].(bool); ok {
+ rc.PreserveQuery = preserveQuery
+ }
+
+ return rc, nil
+}
+
+// StaticConfig represents static file server configuration.
+type StaticConfig struct {
+ RootPath string `json:"root_path"`
+ IndexFile string `json:"index_file"`
+ TryFiles []string `json:"try_files"`
+ TemplateRendering bool `json:"template_rendering"`
+ Browse bool `json:"browse"`
+}
+
+// parseStaticConfig parses static file server configuration from JSONField.
+func (b *HTTPBuilder) parseStaticConfig(config models.JSONField) (*StaticConfig, error) {
+ if len(config) == 0 {
+ return nil, fmt.Errorf("static config is required")
+ }
+
+ sc := &StaticConfig{}
+
+ if rootPath, ok := config["root_path"].(string); ok {
+ sc.RootPath = rootPath
+ } else {
+ return nil, fmt.Errorf("root_path is required for static file server")
+ }
+
+ if indexFile, ok := config["index_file"].(string); ok {
+ sc.IndexFile = indexFile
+ }
+
+ if tryFiles, ok := config["try_files"].([]interface{}); ok {
+ for _, tf := range tryFiles {
+ if s, ok := tf.(string); ok {
+ sc.TryFiles = append(sc.TryFiles, s)
+ }
+ }
+ }
+
+ if templateRendering, ok := config["template_rendering"].(bool); ok {
+ sc.TemplateRendering = templateRendering
+ }
+
+ if browse, ok := config["browse"].(bool); ok {
+ sc.Browse = browse
+ }
+
+ return sc, nil
+}
+
+// mapLBStrategy maps strategy names to Caddy's policy names.
+func mapLBStrategy(strategy string) string {
+ switch strategy {
+ case "round_robin":
+ return "round_robin"
+ case "least_conn":
+ return "least_conn"
+ case "random":
+ return "random"
+ case "first":
+ return "first"
+ case "ip_hash":
+ return "ip_hash"
+ case "uri_hash":
+ return "uri_hash"
+ case "header":
+ return "header"
+ default:
+ return "round_robin"
+ }
+}
+
+// handlerToMap converts a ReverseProxyHandler to HTTPHandler map.
+func handlerToMap(h *ReverseProxyHandler) HTTPHandler {
+ result := HTTPHandler{
+ "handler": h.Handler,
+ "upstreams": h.Upstreams,
+ }
+
+ if h.LoadBalancing != nil {
+ result["load_balancing"] = h.LoadBalancing
+ }
+ if h.HealthChecks != nil {
+ result["health_checks"] = h.HealthChecks
+ }
+ if h.Transport != nil {
+ result["transport"] = h.Transport
+ }
+ if h.Headers != nil {
+ result["headers"] = h.Headers
+ }
+
+ return result
+}
diff --git a/backend/internal/caddy/config/http_handlers.go b/backend/internal/caddy/config/http_handlers.go
new file mode 100644
index 0000000..311659a
--- /dev/null
+++ b/backend/internal/caddy/config/http_handlers.go
@@ -0,0 +1,588 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+// Handler module names as constants.
+const (
+ HandlerReverseProxy = "reverse_proxy"
+ HandlerStaticResponse = "static_response"
+ HandlerFileServer = "file_server"
+ HandlerSubroute = "subroute"
+ HandlerForwardAuth = "forward_auth"
+ HandlerAuthentication = "authentication"
+ HandlerHeaders = "headers"
+ HandlerRewrite = "rewrite"
+ HandlerError = "error"
+)
+
+// ReverseProxyHandler configures the reverse_proxy handler.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/reverse_proxy/
+type ReverseProxyHandler struct {
+ Handler string `json:"handler"` // Must be "reverse_proxy"
+ Upstreams []*Upstream `json:"upstreams,omitempty"`
+ LoadBalancing *LoadBalancing `json:"load_balancing,omitempty"`
+ HealthChecks *HealthChecks `json:"health_checks,omitempty"`
+ Transport *HTTPTransport `json:"transport,omitempty"`
+ Headers *HeadersConfig `json:"headers,omitempty"`
+ FlushInterval Duration `json:"flush_interval,omitempty"`
+ BufferRequests bool `json:"buffer_requests,omitempty"`
+ BufferResponses bool `json:"buffer_responses,omitempty"`
+ MaxBufferSize int64 `json:"max_buffer_size,omitempty"`
+ TrustedProxies []string `json:"trusted_proxies,omitempty"`
+}
+
+// Duration is a custom type for Caddy duration strings.
+type Duration string
+
+// Upstream represents a single upstream server.
+type Upstream struct {
+ Dial string `json:"dial,omitempty"`
+ MaxRequests int `json:"max_requests,omitempty"`
+ LookupSRV string `json:"lookup_srv,omitempty"`
+}
+
+// LoadBalancing configures load balancing for reverse proxy.
+type LoadBalancing struct {
+ SelectionPolicy *SelectionPolicy `json:"selection_policy,omitempty"`
+ TryDuration Duration `json:"try_duration,omitempty"`
+ TryInterval Duration `json:"try_interval,omitempty"`
+ RetryMatch []MatcherSet `json:"retry_match,omitempty"`
+}
+
+// SelectionPolicy configures the upstream selection policy.
+type SelectionPolicy struct {
+ Policy string `json:"policy,omitempty"` // round_robin, least_conn, random, first, ip_hash, uri_hash, header
+ Header string `json:"header,omitempty"` // For header policy
+}
+
+// HealthChecks configures health checking for upstreams.
+type HealthChecks struct {
+ Active *ActiveHealthCheck `json:"active,omitempty"`
+ Passive *PassiveHealthCheck `json:"passive,omitempty"`
+}
+
+// ActiveHealthCheck configures active health checking.
+type ActiveHealthCheck struct {
+ Path string `json:"path,omitempty"`
+ URI string `json:"uri,omitempty"`
+ Port int `json:"port,omitempty"`
+ Headers map[string][]string `json:"headers,omitempty"`
+ Interval Duration `json:"interval,omitempty"`
+ Timeout Duration `json:"timeout,omitempty"`
+ MaxSize int64 `json:"max_size,omitempty"`
+ ExpectStatus int `json:"expect_status,omitempty"`
+ ExpectBody string `json:"expect_body,omitempty"`
+}
+
+// PassiveHealthCheck configures passive health checking.
+type PassiveHealthCheck struct {
+ FailDuration Duration `json:"fail_duration,omitempty"`
+ MaxFails int `json:"max_fails,omitempty"`
+ UnhealthyStatus []int `json:"unhealthy_status,omitempty"`
+ UnhealthyLatency Duration `json:"unhealthy_latency,omitempty"`
+}
+
+// HTTPTransport configures the HTTP transport for reverse proxy.
+type HTTPTransport struct {
+ Protocol string `json:"protocol,omitempty"` // Must be "http" for Caddy to recognize the transport module
+ Resolver *DNSResolver `json:"resolver,omitempty"`
+ TLS *TLSConfig `json:"tls,omitempty"`
+ KeepAlive *KeepAlive `json:"keep_alive,omitempty"`
+ Compression bool `json:"compression,omitempty"`
+ MaxConnsPerHost int `json:"max_conns_per_host,omitempty"`
+ MaxIdleConnsPerHost int `json:"max_idle_conns_per_host,omitempty"`
+ MaxResponseHeaderSize int64 `json:"max_response_header_size,omitempty"`
+ DialTimeout Duration `json:"dial_timeout,omitempty"`
+ ReadBufferSize int `json:"read_buffer_size,omitempty"`
+ WriteBufferSize int `json:"write_buffer_size,omitempty"`
+ ResponseHeaderTimeout Duration `json:"response_header_timeout,omitempty"`
+ ExpectContinueTimeout Duration `json:"expect_continue_timeout,omitempty"`
+ Versions []string `json:"versions,omitempty"`
+}
+
+// DNSResolver configures DNS resolution for reverse proxy.
+type DNSResolver struct {
+ Addresses []string `json:"addresses,omitempty"`
+}
+
+// TLSConfig configures TLS for upstream connections.
+type TLSConfig struct {
+ RootCAPool []string `json:"root_ca_pool,omitempty"`
+ RootCAPemFiles []string `json:"root_ca_pem_files,omitempty"`
+ ClientCertificateFile string `json:"client_certificate_file,omitempty"`
+ ClientCertificateKeyFile string `json:"client_certificate_key_file,omitempty"`
+ InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
+ ServerName string `json:"server_name,omitempty"`
+ Renegotiation string `json:"renegotiation,omitempty"`
+}
+
+// KeepAlive configures keep-alive for HTTP transport.
+type KeepAlive struct {
+ Enabled *bool `json:"enabled,omitempty"`
+ ProbeInterval Duration `json:"probe_interval,omitempty"`
+ MaxIdleConns int `json:"max_idle_conns,omitempty"`
+ MaxIdleConnsPerHost int `json:"max_idle_conns_per_host,omitempty"`
+ IdleConnTimeout Duration `json:"idle_conn_timeout,omitempty"`
+}
+
+// HeadersConfig configures header manipulation for reverse proxy.
+type HeadersConfig struct {
+ Request *HeaderOps `json:"request,omitempty"`
+ Response *HeaderOps `json:"response,omitempty"`
+}
+
+// HeaderOps defines header operations.
+type HeaderOps struct {
+ Set map[string][]string `json:"set,omitempty"`
+ Add map[string][]string `json:"add,omitempty"`
+ Delete []string `json:"delete,omitempty"`
+ Replace map[string][]ReplacementOp `json:"replace,omitempty"`
+}
+
+// ReplacementOp defines a header replacement operation.
+type ReplacementOp struct {
+ Search string `json:"search,omitempty"`
+ SearchRegexp string `json:"search_regexp,omitempty"`
+ Replace string `json:"replace,omitempty"`
+}
+
+// StaticResponseHandler configures the static_response handler.
+// Used for redirects, error responses, and simple static content.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/static_response/
+type StaticResponseHandler struct {
+ Handler string `json:"handler"` // Must be "static_response"
+ StatusCode int `json:"status_code,omitempty"`
+ Headers map[string][]string `json:"headers,omitempty"`
+ Body string `json:"body,omitempty"`
+ Close bool `json:"close,omitempty"`
+}
+
+// FileServerHandler configures the file_server handler.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/file_server/
+type FileServerHandler struct {
+ Handler string `json:"handler"` // Must be "file_server"
+ Root string `json:"root,omitempty"`
+ IndexNames []string `json:"index_names,omitempty"`
+ Browse *Browse `json:"browse,omitempty"`
+ CanonicalURIs bool `json:"canonical_uris,omitempty"`
+ PassThru bool `json:"pass_thru,omitempty"`
+ Hide []string `json:"hide,omitempty"`
+}
+
+// Browse configures directory browsing for file_server.
+type Browse struct {
+ TemplateFile string `json:"template_file,omitempty"`
+}
+
+// SubrouteHandler configures the subroute handler for nested routing.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/subroute/
+type SubrouteHandler struct {
+ Handler string `json:"handler"` // Must be "subroute"
+ Routes []*HTTPRoute `json:"routes,omitempty"`
+}
+
+// ForwardAuthHandler configures forward_auth for authentication.
+// See: https://caddyserver.com/docs/caddyfile/directives/forward_auth
+type ForwardAuthHandler struct {
+ Handler string `json:"handler"` // Must be "reverse_proxy"
+ Upstreams []*Upstream `json:"upstreams,omitempty"`
+ Headers *HeadersConfig `json:"headers,omitempty"`
+ HandleResponse []*HandleResponse `json:"handle_response,omitempty"`
+ RewriteHeaders *RewriteHeaders `json:"rewrite,omitempty"`
+}
+
+// HandleResponse configures how to handle responses from forward_auth.
+type HandleResponse struct {
+ Match *ResponseMatch `json:"match,omitempty"`
+ Routes []*HTTPRoute `json:"routes,omitempty"`
+ StatusCode int `json:"status_code,omitempty"`
+}
+
+// CopyResponseHeadersHandler copies headers from upstream response to the request.
+// This handler can only be used inside reverse_proxy's handle_response routes.
+type CopyResponseHeadersHandler struct {
+ Handler string `json:"handler"` // Must be "copy_response_headers"
+ Include []string `json:"include,omitempty"`
+ Exclude []string `json:"exclude,omitempty"`
+}
+
+// NewCopyResponseHeadersHandler creates a new copy_response_headers handler.
+func NewCopyResponseHeadersHandler(headers []string) *CopyResponseHeadersHandler {
+ return &CopyResponseHeadersHandler{
+ Handler: "copy_response_headers",
+ Include: headers,
+ }
+}
+
+// ResponseMatch matches response attributes.
+type ResponseMatch struct {
+ StatusCode []int `json:"status_code,omitempty"`
+ Headers map[string][]string `json:"headers,omitempty"`
+}
+
+// RewriteHeaders configures header rewriting.
+type RewriteHeaders struct {
+ Method string `json:"method,omitempty"`
+ URI string `json:"uri,omitempty"`
+}
+
+// AuthenticationHandler configures HTTP Basic Authentication.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/authentication/
+type AuthenticationHandler struct {
+ Handler string `json:"handler"` // Must be "authentication"
+ Providers *AuthenticationProviders `json:"providers,omitempty"`
+}
+
+// AuthenticationProviders contains authentication provider configurations.
+type AuthenticationProviders struct {
+ HTTPBasic *HTTPBasicAuth `json:"http_basic,omitempty"`
+}
+
+// HTTPBasicAuth configures HTTP Basic Authentication.
+type HTTPBasicAuth struct {
+ Accounts []*BasicAuthAccount `json:"accounts,omitempty"`
+ Realm string `json:"realm,omitempty"`
+ HashCache *HashCache `json:"hash_cache,omitempty"`
+}
+
+// BasicAuthAccount represents a user account for basic auth.
+type BasicAuthAccount struct {
+ Username string `json:"username"`
+ Password string `json:"password"` // bcrypt hash
+}
+
+// HashCache configures caching for password hash verification.
+type HashCache struct {
+ Enabled bool `json:"enabled,omitempty"`
+}
+
+// HeadersHandler configures the headers handler.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/headers/
+type HeadersHandler struct {
+ Handler string `json:"handler"` // Must be "headers"
+ Request *HeaderOps `json:"request,omitempty"`
+ Response *HeaderOps `json:"response,omitempty"`
+}
+
+// RewriteHandler configures the rewrite handler.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/rewrite/
+type RewriteHandler struct {
+ Handler string `json:"handler"` // Must be "rewrite"
+ Method string `json:"method,omitempty"`
+ URI string `json:"uri,omitempty"`
+ StripPathPrefix string `json:"strip_path_prefix,omitempty"`
+ StripPathSuffix string `json:"strip_path_suffix,omitempty"`
+ URISubstring []SubstringReplacement `json:"uri_substring,omitempty"`
+ PathRegexp []RegexpReplacement `json:"path_regexp,omitempty"`
+}
+
+// SubstringReplacement configures substring replacement in rewrite.
+type SubstringReplacement struct {
+ Find string `json:"find"`
+ Replace string `json:"replace"`
+ Limit int `json:"limit,omitempty"`
+}
+
+// RegexpReplacement configures regexp replacement in rewrite.
+type RegexpReplacement struct {
+ Find string `json:"find"`
+ Replace string `json:"replace"`
+}
+
+// ErrorHandler configures the error handler.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/handle/error/
+type ErrorHandler struct {
+ Handler string `json:"handler"` // Must be "error"
+ Error string `json:"error,omitempty"`
+ StatusCode int `json:"status_code,omitempty"`
+}
+
+// NewReverseProxyHandler creates a new reverse proxy handler.
+func NewReverseProxyHandler(upstreams ...*Upstream) *ReverseProxyHandler {
+ return &ReverseProxyHandler{
+ Handler: HandlerReverseProxy,
+ Upstreams: upstreams,
+ }
+}
+
+// NewUpstream creates a new upstream configuration.
+func NewUpstream(dial string) *Upstream {
+ return &Upstream{
+ Dial: dial,
+ }
+}
+
+// NewUpstreamFromHostPort creates an upstream from host and port.
+func NewUpstreamFromHostPort(host string, port int) *Upstream {
+ return &Upstream{
+ Dial: formatDialAddress(host, port),
+ }
+}
+
+// formatDialAddress formats a dial address from host and port.
+func formatDialAddress(host string, port int) string {
+ if port == 0 {
+ return host
+ }
+ return host + ":" + itoa(port)
+}
+
+// itoa is a simple int to string conversion.
+func itoa(i int) string {
+ if i == 0 {
+ return "0"
+ }
+ // Simple implementation for positive integers
+ var buf [20]byte
+ n := len(buf)
+ neg := i < 0
+ if neg {
+ i = -i
+ }
+ for i > 0 {
+ n--
+ buf[n] = byte('0' + i%10)
+ i /= 10
+ }
+ if neg {
+ n--
+ buf[n] = '-'
+ }
+ return string(buf[n:])
+}
+
+// WithLoadBalancing adds load balancing to a reverse proxy handler.
+func (h *ReverseProxyHandler) WithLoadBalancing(policy string) *ReverseProxyHandler {
+ h.LoadBalancing = &LoadBalancing{
+ SelectionPolicy: &SelectionPolicy{
+ Policy: policy,
+ },
+ }
+ return h
+}
+
+// WithHealthChecks adds health checks to a reverse proxy handler.
+func (h *ReverseProxyHandler) WithHealthChecks(path string, interval, timeout Duration) *ReverseProxyHandler {
+ h.HealthChecks = &HealthChecks{
+ Active: &ActiveHealthCheck{
+ Path: path,
+ Interval: interval,
+ Timeout: timeout,
+ },
+ }
+ return h
+}
+
+// WithTLSTransport adds TLS transport configuration to a reverse proxy handler.
+func (h *ReverseProxyHandler) WithTLSTransport(insecureSkipVerify bool) *ReverseProxyHandler {
+ h.Transport = &HTTPTransport{
+ Protocol: "http", // Required by Caddy to identify the transport module
+ TLS: &TLSConfig{
+ InsecureSkipVerify: insecureSkipVerify,
+ },
+ }
+ return h
+}
+
+// WithHeaders adds header configuration to a reverse proxy handler.
+func (h *ReverseProxyHandler) WithHeaders(request, response *HeaderOps) *ReverseProxyHandler {
+ h.Headers = &HeadersConfig{
+ Request: request,
+ Response: response,
+ }
+ return h
+}
+
+// NewStaticResponseHandler creates a static response handler.
+func NewStaticResponseHandler(statusCode int, body string) *StaticResponseHandler {
+ return &StaticResponseHandler{
+ Handler: HandlerStaticResponse,
+ StatusCode: statusCode,
+ Body: body,
+ }
+}
+
+// NewRedirectHandler creates a redirect response handler.
+func NewRedirectHandler(location string, statusCode int) *StaticResponseHandler {
+ return &StaticResponseHandler{
+ Handler: HandlerStaticResponse,
+ StatusCode: statusCode,
+ Headers: map[string][]string{
+ "Location": {location},
+ },
+ }
+}
+
+// NewFileServerHandler creates a new file server handler.
+func NewFileServerHandler(root string) *FileServerHandler {
+ return &FileServerHandler{
+ Handler: HandlerFileServer,
+ Root: root,
+ }
+}
+
+// WithIndexNames sets the index file names for a file server handler.
+func (h *FileServerHandler) WithIndexNames(names ...string) *FileServerHandler {
+ h.IndexNames = names
+ return h
+}
+
+// WithBrowse enables directory browsing for a file server handler.
+func (h *FileServerHandler) WithBrowse(templateFile string) *FileServerHandler {
+ h.Browse = &Browse{
+ TemplateFile: templateFile,
+ }
+ return h
+}
+
+// NewSubrouteHandler creates a new subroute handler.
+func NewSubrouteHandler(routes ...*HTTPRoute) *SubrouteHandler {
+ return &SubrouteHandler{
+ Handler: HandlerSubroute,
+ Routes: routes,
+ }
+}
+
+// NewAuthenticationHandler creates a new authentication handler with basic auth.
+func NewAuthenticationHandler(accounts []*BasicAuthAccount, realm string) *AuthenticationHandler {
+ return &AuthenticationHandler{
+ Handler: HandlerAuthentication,
+ Providers: &AuthenticationProviders{
+ HTTPBasic: &HTTPBasicAuth{
+ Accounts: accounts,
+ Realm: realm,
+ },
+ },
+ }
+}
+
+// NewBasicAuthAccount creates a new basic auth account.
+func NewBasicAuthAccount(username, passwordHash string) *BasicAuthAccount {
+ return &BasicAuthAccount{
+ Username: username,
+ Password: passwordHash,
+ }
+}
+
+// NewHeadersHandler creates a new headers handler.
+func NewHeadersHandler(request, response *HeaderOps) *HeadersHandler {
+ return &HeadersHandler{
+ Handler: HandlerHeaders,
+ Request: request,
+ Response: response,
+ }
+}
+
+// NewRequestHeaderOps creates header operations for requests.
+func NewRequestHeaderOps() *HeaderOps {
+ return &HeaderOps{
+ Set: make(map[string][]string),
+ Add: make(map[string][]string),
+ }
+}
+
+// SetHeader sets a header value.
+func (h *HeaderOps) SetHeader(name string, values ...string) *HeaderOps {
+ if h.Set == nil {
+ h.Set = make(map[string][]string)
+ }
+ h.Set[name] = values
+ return h
+}
+
+// AddHeader adds a header value.
+func (h *HeaderOps) AddHeader(name string, values ...string) *HeaderOps {
+ if h.Add == nil {
+ h.Add = make(map[string][]string)
+ }
+ h.Add[name] = values
+ return h
+}
+
+// DeleteHeader adds a header to delete.
+func (h *HeaderOps) DeleteHeader(names ...string) *HeaderOps {
+ h.Delete = append(h.Delete, names...)
+ return h
+}
+
+// StandardProxyHeaders returns header operations for standard proxy headers.
+// These headers inform the upstream server about the original request.
+func StandardProxyHeaders() *HeaderOps {
+ return &HeaderOps{
+ Set: map[string][]string{
+ "X-Real-IP": {"{http.request.remote.host}"},
+ "X-Forwarded-For": {"{http.request.remote.host}"},
+ "X-Forwarded-Proto": {"{http.request.scheme}"},
+ "X-Forwarded-Host": {"{http.request.host}"},
+ },
+ }
+}
+
+// ToHTTPHandler converts a typed handler to the generic HTTPHandler map.
+func ToHTTPHandler(handler interface{}) HTTPHandler {
+ switch h := handler.(type) {
+ case *ReverseProxyHandler:
+ return HTTPHandler{
+ "handler": h.Handler,
+ "upstreams": h.Upstreams,
+ "load_balancing": h.LoadBalancing,
+ "health_checks": h.HealthChecks,
+ "transport": h.Transport,
+ "headers": h.Headers,
+ }
+ case *StaticResponseHandler:
+ result := HTTPHandler{
+ "handler": h.Handler,
+ "status_code": h.StatusCode,
+ }
+ if h.Body != "" {
+ result["body"] = h.Body
+ }
+ if len(h.Headers) > 0 {
+ result["headers"] = h.Headers
+ }
+ return result
+ case *FileServerHandler:
+ result := HTTPHandler{
+ "handler": h.Handler,
+ }
+ if h.Root != "" {
+ result["root"] = h.Root
+ }
+ if len(h.IndexNames) > 0 {
+ result["index_names"] = h.IndexNames
+ }
+ if h.Browse != nil {
+ result["browse"] = h.Browse
+ }
+ return result
+ case *SubrouteHandler:
+ return HTTPHandler{
+ "handler": h.Handler,
+ "routes": h.Routes,
+ }
+ case *AuthenticationHandler:
+ return HTTPHandler{
+ "handler": h.Handler,
+ "providers": h.Providers,
+ }
+ case *HeadersHandler:
+ return HTTPHandler{
+ "handler": h.Handler,
+ "request": h.Request,
+ "response": h.Response,
+ }
+ case *CopyResponseHeadersHandler:
+ result := HTTPHandler{
+ "handler": h.Handler,
+ }
+ if len(h.Include) > 0 {
+ result["include"] = h.Include
+ }
+ if len(h.Exclude) > 0 {
+ result["exclude"] = h.Exclude
+ }
+ return result
+ default:
+ return nil
+ }
+}
diff --git a/backend/internal/caddy/config/http_handlers_test.go b/backend/internal/caddy/config/http_handlers_test.go
new file mode 100644
index 0000000..295766d
--- /dev/null
+++ b/backend/internal/caddy/config/http_handlers_test.go
@@ -0,0 +1,351 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// =============================================================================
+// Upstream Tests
+// =============================================================================
+
+func TestNewUpstream(t *testing.T) {
+ dial := "localhost:8080"
+ upstream := NewUpstream(dial)
+
+ require.NotNil(t, upstream)
+ assert.Equal(t, dial, upstream.Dial)
+}
+
+func TestNewUpstreamFromHostPort(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ expected string
+ }{
+ {
+ name: "localhost with port",
+ host: "localhost",
+ port: 8080,
+ expected: "localhost:8080",
+ },
+ {
+ name: "IP with port",
+ host: "192.168.1.1",
+ port: 3000,
+ expected: "192.168.1.1:3000",
+ },
+ {
+ name: "hostname with port",
+ host: "backend.local",
+ port: 443,
+ expected: "backend.local:443",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := NewUpstreamFromHostPort(tt.host, tt.port)
+
+ require.NotNil(t, upstream)
+ assert.Equal(t, tt.expected, upstream.Dial)
+ })
+ }
+}
+
+func TestFormatDialAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ expected string
+ }{
+ {
+ name: "with port",
+ host: "localhost",
+ port: 8080,
+ expected: "localhost:8080",
+ },
+ {
+ name: "zero port returns just host",
+ host: "backend.local",
+ port: 0,
+ expected: "backend.local",
+ },
+ {
+ name: "standard HTTP port",
+ host: "example.com",
+ port: 80,
+ expected: "example.com:80",
+ },
+ {
+ name: "standard HTTPS port",
+ host: "secure.example.com",
+ port: 443,
+ expected: "secure.example.com:443",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := formatDialAddress(tt.host, tt.port)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestItoa(t *testing.T) {
+ tests := []struct {
+ name string
+ input int
+ expected string
+ }{
+ {"zero", 0, "0"},
+ {"positive single digit", 5, "5"},
+ {"positive multi digit", 123, "123"},
+ {"standard port", 8080, "8080"},
+ {"large number", 65535, "65535"},
+ {"negative number", -42, "-42"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := itoa(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// =============================================================================
+// ReverseProxyHandler Tests
+// =============================================================================
+
+func TestReverseProxyHandler_WithLoadBalancing(t *testing.T) {
+ handler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ result := handler.WithLoadBalancing("round_robin")
+
+ assert.Same(t, handler, result)
+ require.NotNil(t, handler.LoadBalancing)
+ require.NotNil(t, handler.LoadBalancing.SelectionPolicy)
+ assert.Equal(t, "round_robin", handler.LoadBalancing.SelectionPolicy.Policy)
+}
+
+func TestReverseProxyHandler_WithHealthChecks(t *testing.T) {
+ handler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+
+ result := handler.WithHealthChecks("/health", "30s", "5s")
+
+ assert.Same(t, handler, result)
+ require.NotNil(t, handler.HealthChecks)
+ require.NotNil(t, handler.HealthChecks.Active)
+ assert.Equal(t, "/health", handler.HealthChecks.Active.Path)
+ assert.Equal(t, Duration("30s"), handler.HealthChecks.Active.Interval)
+ assert.Equal(t, Duration("5s"), handler.HealthChecks.Active.Timeout)
+}
+
+func TestReverseProxyHandler_WithTLSTransport(t *testing.T) {
+ t.Run("with insecure skip verify", func(t *testing.T) {
+ handler := NewReverseProxyHandler(&Upstream{Dial: "localhost:443"})
+
+ result := handler.WithTLSTransport(true)
+
+ assert.Same(t, handler, result)
+ require.NotNil(t, handler.Transport)
+ assert.Equal(t, "http", handler.Transport.Protocol)
+ require.NotNil(t, handler.Transport.TLS)
+ assert.True(t, handler.Transport.TLS.InsecureSkipVerify)
+ })
+
+ t.Run("without insecure skip verify", func(t *testing.T) {
+ handler := NewReverseProxyHandler(&Upstream{Dial: "localhost:443"})
+
+ result := handler.WithTLSTransport(false)
+
+ assert.Same(t, handler, result)
+ require.NotNil(t, handler.Transport)
+ require.NotNil(t, handler.Transport.TLS)
+ assert.False(t, handler.Transport.TLS.InsecureSkipVerify)
+ })
+}
+
+func TestReverseProxyHandler_WithHeaders(t *testing.T) {
+ handler := NewReverseProxyHandler(&Upstream{Dial: "localhost:8080"})
+ request := NewRequestHeaderOps()
+ request.SetHeader("X-Custom", "value")
+
+ response := NewRequestHeaderOps()
+ response.SetHeader("X-Response", "value")
+
+ result := handler.WithHeaders(request, response)
+
+ assert.Same(t, handler, result)
+ require.NotNil(t, handler.Headers)
+ assert.Equal(t, request, handler.Headers.Request)
+ assert.Equal(t, response, handler.Headers.Response)
+}
+
+// =============================================================================
+// HeaderOps Tests
+// =============================================================================
+
+func TestNewRequestHeaderOps(t *testing.T) {
+ ops := NewRequestHeaderOps()
+
+ require.NotNil(t, ops)
+ assert.NotNil(t, ops.Set)
+ assert.NotNil(t, ops.Add)
+ assert.Empty(t, ops.Set)
+ assert.Empty(t, ops.Add)
+}
+
+func TestHeaderOps_SetHeader(t *testing.T) {
+ t.Run("set header with existing map", func(t *testing.T) {
+ ops := NewRequestHeaderOps()
+
+ result := ops.SetHeader("X-Custom", "value1", "value2")
+
+ assert.Same(t, ops, result)
+ assert.Equal(t, []string{"value1", "value2"}, ops.Set["X-Custom"])
+ })
+
+ t.Run("set header with nil map", func(t *testing.T) {
+ ops := &HeaderOps{}
+
+ result := ops.SetHeader("X-Custom", "value")
+
+ assert.Same(t, ops, result)
+ require.NotNil(t, ops.Set)
+ assert.Equal(t, []string{"value"}, ops.Set["X-Custom"])
+ })
+}
+
+func TestHeaderOps_AddHeader(t *testing.T) {
+ t.Run("add header with existing map", func(t *testing.T) {
+ ops := NewRequestHeaderOps()
+
+ result := ops.AddHeader("X-Custom", "value1", "value2")
+
+ assert.Same(t, ops, result)
+ assert.Equal(t, []string{"value1", "value2"}, ops.Add["X-Custom"])
+ })
+
+ t.Run("add header with nil map", func(t *testing.T) {
+ ops := &HeaderOps{}
+
+ result := ops.AddHeader("X-Custom", "value")
+
+ assert.Same(t, ops, result)
+ require.NotNil(t, ops.Add)
+ assert.Equal(t, []string{"value"}, ops.Add["X-Custom"])
+ })
+}
+
+func TestHeaderOps_DeleteHeader(t *testing.T) {
+ ops := NewRequestHeaderOps()
+
+ result := ops.DeleteHeader("X-Remove-Me", "X-Also-Remove")
+
+ assert.Same(t, ops, result)
+ assert.Len(t, ops.Delete, 2)
+ assert.Contains(t, ops.Delete, "X-Remove-Me")
+ assert.Contains(t, ops.Delete, "X-Also-Remove")
+}
+
+// =============================================================================
+// Handler Factory Tests
+// =============================================================================
+
+func TestNewHeadersHandler(t *testing.T) {
+ request := NewRequestHeaderOps()
+ request.SetHeader("X-Request", "value")
+
+ response := NewRequestHeaderOps()
+ response.SetHeader("X-Response", "value")
+
+ handler := NewHeadersHandler(request, response)
+
+ require.NotNil(t, handler)
+ assert.Equal(t, HandlerHeaders, handler.Handler)
+ assert.Equal(t, request, handler.Request)
+ assert.Equal(t, response, handler.Response)
+}
+
+func TestNewSubrouteHandler(t *testing.T) {
+ route1 := NewHTTPRoute()
+ route2 := NewHTTPRoute()
+
+ handler := NewSubrouteHandler(route1, route2)
+
+ require.NotNil(t, handler)
+ assert.Equal(t, HandlerSubroute, handler.Handler)
+ assert.Len(t, handler.Routes, 2)
+ assert.Equal(t, route1, handler.Routes[0])
+ assert.Equal(t, route2, handler.Routes[1])
+}
+
+// =============================================================================
+// Matchers Tests
+// =============================================================================
+
+func TestNewPathREMatcher(t *testing.T) {
+ name := "image_files"
+ pattern := "\\.(jpg|png|gif)$"
+
+ matcher := NewPathREMatcher(name, pattern)
+
+ require.NotNil(t, matcher)
+ assert.Contains(t, matcher, "path_regexp")
+}
+
+func TestNewHeaderMatcher(t *testing.T) {
+ headers := map[string][]string{
+ "X-Custom-Header": {"value1", "value2"},
+ }
+
+ matcher := NewHeaderMatcher(headers)
+
+ require.NotNil(t, matcher)
+ assert.Contains(t, matcher, "header")
+}
+
+func TestNewMethodMatcher(t *testing.T) {
+ methods := []string{"GET", "POST"}
+
+ matcher := NewMethodMatcher(methods...)
+
+ require.NotNil(t, matcher)
+ assert.Contains(t, matcher, "method")
+}
+
+func TestNewHostPathMatcher(t *testing.T) {
+ host := "example.com"
+ paths := []string{"/api/*", "/v1/*"}
+
+ matcher := NewHostPathMatcher(host, paths...)
+
+ require.NotNil(t, matcher)
+ assert.Contains(t, matcher, "host")
+ assert.Contains(t, matcher, "path")
+}
+
+func TestAddHostToMatcher(t *testing.T) {
+ matcher := MatcherSet{}
+
+ AddHostToMatcher(matcher, "example.com", "api.example.com")
+
+ assert.Contains(t, matcher, "host")
+ hosts := matcher["host"].(MatchHost)
+ assert.Contains(t, hosts, "example.com")
+ assert.Contains(t, hosts, "api.example.com")
+}
+
+func TestNewStaticAssetMatcher(t *testing.T) {
+ matcher := NewStaticAssetMatcher()
+
+ require.NotNil(t, matcher)
+ assert.Contains(t, matcher, "path")
+}
diff --git a/backend/internal/caddy/config/http_matchers.go b/backend/internal/caddy/config/http_matchers.go
new file mode 100644
index 0000000..0ce54e5
--- /dev/null
+++ b/backend/internal/caddy/config/http_matchers.go
@@ -0,0 +1,181 @@
+// Package config provides typed Go structs for generating Caddy JSON configuration.
+package config
+
+// MatchHost matches requests by hostname.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/host/
+type MatchHost []string
+
+// MatchPath matches requests by URI path.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/path/
+type MatchPath []string
+
+// MatchPathRE matches requests by URI path using regular expressions.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/path_regexp/
+type MatchPathRE struct {
+ Name string `json:"name,omitempty"`
+ Pattern string `json:"pattern"`
+}
+
+// MatchRemoteIP matches requests by client IP address.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/remote_ip/
+type MatchRemoteIP struct {
+ Ranges []string `json:"ranges,omitempty"`
+}
+
+// MatchHeader matches requests by header values.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/header/
+type MatchHeader map[string][]string
+
+// MatchProtocol matches requests by protocol (http, https, grpc).
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/protocol/
+type MatchProtocol string
+
+// MatchMethod matches requests by HTTP method.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/method/
+type MatchMethod []string
+
+// MatchNot negates the matchers it contains.
+// See: https://caddyserver.com/docs/json/apps/http/servers/routes/match/not/
+type MatchNot []MatcherSet
+
+// NewHostMatcher creates a matcher set that matches the given hosts.
+func NewHostMatcher(hosts ...string) MatcherSet {
+ return MatcherSet{
+ "host": MatchHost(hosts),
+ }
+}
+
+// NewPathMatcher creates a matcher set that matches the given paths.
+func NewPathMatcher(paths ...string) MatcherSet {
+ return MatcherSet{
+ "path": MatchPath(paths),
+ }
+}
+
+// NewPathREMatcher creates a matcher set that matches paths using a regex pattern.
+func NewPathREMatcher(name, pattern string) MatcherSet {
+ return MatcherSet{
+ "path_regexp": &MatchPathRE{
+ Name: name,
+ Pattern: pattern,
+ },
+ }
+}
+
+// NewRemoteIPMatcher creates a matcher set that matches the given IP ranges.
+func NewRemoteIPMatcher(ranges ...string) MatcherSet {
+ return MatcherSet{
+ "remote_ip": &MatchRemoteIP{
+ Ranges: ranges,
+ },
+ }
+}
+
+// NewHeaderMatcher creates a matcher set that matches the given headers.
+func NewHeaderMatcher(headers map[string][]string) MatcherSet {
+ return MatcherSet{
+ "header": MatchHeader(headers),
+ }
+}
+
+// NewMethodMatcher creates a matcher set that matches the given HTTP methods.
+func NewMethodMatcher(methods ...string) MatcherSet {
+ return MatcherSet{
+ "method": MatchMethod(methods),
+ }
+}
+
+// NewNotMatcher creates a matcher set that negates the given matchers.
+func NewNotMatcher(matchers ...MatcherSet) MatcherSet {
+ return MatcherSet{
+ "not": MatchNot(matchers),
+ }
+}
+
+// NewHostPathMatcher creates a matcher set that matches both host and path.
+func NewHostPathMatcher(host string, paths ...string) MatcherSet {
+ return MatcherSet{
+ "host": MatchHost{host},
+ "path": MatchPath(paths),
+ }
+}
+
+// CombineMatchers merges multiple matcher sets into one.
+// If the same matcher type appears in multiple sets, only the first one is kept.
+func CombineMatchers(matchers ...MatcherSet) MatcherSet {
+ combined := make(MatcherSet)
+ for _, m := range matchers {
+ for k, v := range m {
+ if _, exists := combined[k]; !exists {
+ combined[k] = v
+ }
+ }
+ }
+ return combined
+}
+
+// AddHostToMatcher adds host matching to an existing matcher set.
+func AddHostToMatcher(m MatcherSet, hosts ...string) MatcherSet {
+ m["host"] = MatchHost(hosts)
+ return m
+}
+
+// AddPathToMatcher adds path matching to an existing matcher set.
+func AddPathToMatcher(m MatcherSet, paths ...string) MatcherSet {
+ m["path"] = MatchPath(paths)
+ return m
+}
+
+// AddRemoteIPToMatcher adds remote IP matching to an existing matcher set.
+func AddRemoteIPToMatcher(m MatcherSet, ranges ...string) MatcherSet {
+ m["remote_ip"] = &MatchRemoteIP{Ranges: ranges}
+ return m
+}
+
+// StaticAssetExtensions returns common static asset file extensions.
+// Used for bypassing authentication on static assets.
+func StaticAssetExtensions() []string {
+ return []string{
+ // Images
+ "*.ico", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.webp", "*.avif",
+ // Stylesheets
+ "*.css",
+ // Scripts
+ "*.js", "*.mjs",
+ // Fonts
+ "*.woff", "*.woff2", "*.ttf", "*.eot", "*.otf",
+ // Source maps
+ "*.map",
+ // Web manifests
+ "*.webmanifest", "*.json",
+ }
+}
+
+// StaticAssetPaths returns common static asset paths.
+// Used for bypassing authentication on static assets.
+func StaticAssetPaths() []string {
+ return []string{
+ // Common root files
+ "/favicon.ico",
+ "/robots.txt",
+ "/sitemap.xml",
+ "/manifest.json",
+ // Common static directories
+ "/static/*",
+ "/assets/*",
+ "/images/*",
+ "/css/*",
+ "/js/*",
+ "/fonts/*",
+ "/media/*",
+ // Well-known paths
+ "/.well-known/*",
+ }
+}
+
+// NewStaticAssetMatcher creates a matcher for common static assets.
+// This can be used to bypass authentication for static files.
+func NewStaticAssetMatcher() MatcherSet {
+ paths := append(StaticAssetPaths(), StaticAssetExtensions()...)
+ return NewPathMatcher(paths...)
+}
diff --git a/backend/internal/caddy/config/http_test.go b/backend/internal/caddy/config/http_test.go
new file mode 100644
index 0000000..b0ab7ef
--- /dev/null
+++ b/backend/internal/caddy/config/http_test.go
@@ -0,0 +1,528 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// =============================================================================
+// HTTP App and Server Tests
+// =============================================================================
+
+func TestNewHTTPApp(t *testing.T) {
+ app := NewHTTPApp()
+ require.NotNil(t, app)
+ assert.NotNil(t, app.Servers)
+ assert.Empty(t, app.Servers)
+}
+
+func TestNewHTTPServer(t *testing.T) {
+ tests := []struct {
+ name string
+ listen []string
+ want []string
+ }{
+ {
+ name: "single listen address",
+ listen: []string{":443"},
+ want: []string{":443"},
+ },
+ {
+ name: "multiple listen addresses",
+ listen: []string{":80", ":443"},
+ want: []string{":80", ":443"},
+ },
+ {
+ name: "no listen address",
+ listen: nil,
+ want: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := NewHTTPServer(tt.listen...)
+ require.NotNil(t, server)
+ assert.Equal(t, tt.want, server.Listen)
+ assert.NotNil(t, server.Routes)
+ assert.Empty(t, server.Routes)
+ })
+ }
+}
+
+func TestNewHTTPRoute(t *testing.T) {
+ route := NewHTTPRoute()
+ require.NotNil(t, route)
+ assert.NotNil(t, route.Match)
+ assert.Empty(t, route.Match)
+ assert.NotNil(t, route.Handle)
+ assert.Empty(t, route.Handle)
+}
+
+func TestNewHTTPRouteWithMatch(t *testing.T) {
+ hostMatcher := NewHostMatcher("example.com")
+ pathMatcher := NewPathMatcher("/api/*")
+
+ route := NewHTTPRouteWithMatch(hostMatcher, pathMatcher)
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 2)
+ assert.NotNil(t, route.Handle)
+ assert.Empty(t, route.Handle)
+}
+
+func TestHTTPApp_AddServer(t *testing.T) {
+ t.Run("add to existing servers map", func(t *testing.T) {
+ app := NewHTTPApp()
+ server := NewHTTPServer(":443")
+
+ result := app.AddServer("srv0", server)
+
+ assert.Same(t, app, result)
+ assert.Len(t, app.Servers, 1)
+ assert.Equal(t, server, app.Servers["srv0"])
+ })
+
+ t.Run("add to nil servers map", func(t *testing.T) {
+ app := &HTTPApp{Servers: nil}
+ server := NewHTTPServer(":443")
+
+ result := app.AddServer("srv0", server)
+
+ assert.Same(t, app, result)
+ assert.NotNil(t, app.Servers)
+ assert.Len(t, app.Servers, 1)
+ })
+
+ t.Run("add multiple servers", func(t *testing.T) {
+ app := NewHTTPApp()
+ server1 := NewHTTPServer(":443")
+ server2 := NewHTTPServer(":80")
+
+ app.AddServer("https", server1).AddServer("http", server2)
+
+ assert.Len(t, app.Servers, 2)
+ assert.Equal(t, server1, app.Servers["https"])
+ assert.Equal(t, server2, app.Servers["http"])
+ })
+}
+
+func TestHTTPServer_AddRoute(t *testing.T) {
+ server := NewHTTPServer(":443")
+ route := NewHTTPRoute()
+
+ result := server.AddRoute(route)
+
+ assert.Same(t, server, result)
+ assert.Len(t, server.Routes, 1)
+ assert.Equal(t, route, server.Routes[0])
+}
+
+func TestHTTPServer_AddRoutes(t *testing.T) {
+ server := NewHTTPServer(":443")
+ route1 := NewHTTPRoute()
+ route2 := NewHTTPRoute()
+
+ result := server.AddRoutes(route1, route2)
+
+ assert.Same(t, server, result)
+ assert.Len(t, server.Routes, 2)
+}
+
+func TestHTTPServer_WithAutoHTTPS(t *testing.T) {
+ server := NewHTTPServer(":443")
+ config := &AutoHTTPSConfig{
+ Disabled: false,
+ }
+
+ result := server.WithAutoHTTPS(config)
+
+ assert.Same(t, server, result)
+ assert.Equal(t, config, server.AutoHTTPS)
+}
+
+func TestHTTPServer_DisableAutoHTTPS(t *testing.T) {
+ server := NewHTTPServer(":443")
+
+ result := server.DisableAutoHTTPS()
+
+ assert.Same(t, server, result)
+ require.NotNil(t, server.AutoHTTPS)
+ assert.True(t, server.AutoHTTPS.Disabled)
+}
+
+func TestHTTPRoute_AddMatch(t *testing.T) {
+ route := NewHTTPRoute()
+ hostMatcher := NewHostMatcher("example.com")
+ pathMatcher := NewPathMatcher("/api/*")
+
+ result := route.AddMatch(hostMatcher, pathMatcher)
+
+ assert.Same(t, route, result)
+ assert.Len(t, route.Match, 2)
+}
+
+func TestHTTPRoute_AddHandler(t *testing.T) {
+ route := NewHTTPRoute()
+ handler := HTTPHandler{"handler": "static_response", "status_code": 200}
+
+ result := route.AddHandler(handler)
+
+ assert.Same(t, route, result)
+ assert.Len(t, route.Handle, 1)
+ assert.Equal(t, handler, route.Handle[0])
+}
+
+func TestHTTPRoute_SetTerminal(t *testing.T) {
+ tests := []struct {
+ name string
+ terminal bool
+ }{
+ {"set terminal true", true},
+ {"set terminal false", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ route := NewHTTPRoute()
+
+ result := route.SetTerminal(tt.terminal)
+
+ assert.Same(t, route, result)
+ assert.Equal(t, tt.terminal, route.Terminal)
+ })
+ }
+}
+
+// =============================================================================
+// Route Factory Tests
+// =============================================================================
+
+func TestNewReverseProxyRoute(t *testing.T) {
+ t.Run("with hosts", func(t *testing.T) {
+ hosts := []string{"example.com", "api.example.com"}
+ upstreams := []*Upstream{{Dial: "localhost:8080"}}
+
+ route := NewReverseProxyRoute(hosts, upstreams)
+
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 1)
+ assert.Len(t, route.Handle, 1)
+
+ // Check handler type
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerReverseProxy, handler["handler"])
+ })
+
+ t.Run("without hosts", func(t *testing.T) {
+ upstreams := []*Upstream{{Dial: "localhost:8080"}}
+
+ route := NewReverseProxyRoute(nil, upstreams)
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+ })
+}
+
+func TestNewRedirectRoute(t *testing.T) {
+ t.Run("with hosts", func(t *testing.T) {
+ hosts := []string{"old.example.com"}
+ targetURL := "https://new.example.com"
+ statusCode := 301
+
+ route := NewRedirectRoute(hosts, targetURL, statusCode)
+
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 1)
+ assert.Len(t, route.Handle, 1)
+
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerStaticResponse, handler["handler"])
+ assert.Equal(t, statusCode, handler["status_code"])
+ })
+
+ t.Run("without hosts", func(t *testing.T) {
+ route := NewRedirectRoute(nil, "https://example.com", 302)
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+ })
+}
+
+func TestNewStaticFileRoute(t *testing.T) {
+ t.Run("with hosts and index names", func(t *testing.T) {
+ hosts := []string{"docs.example.com"}
+ rootPath := "/var/www/docs"
+ indexNames := []string{"index.html", "default.html"}
+
+ route := NewStaticFileRoute(hosts, rootPath, indexNames)
+
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 1)
+ assert.Len(t, route.Handle, 1)
+
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerFileServer, handler["handler"])
+ })
+
+ t.Run("without index names", func(t *testing.T) {
+ route := NewStaticFileRoute([]string{"static.example.com"}, "/var/www/static", nil)
+
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 1)
+ assert.Len(t, route.Handle, 1)
+ })
+
+ t.Run("without hosts", func(t *testing.T) {
+ route := NewStaticFileRoute(nil, "/var/www", []string{"index.html"})
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+ })
+}
+
+func TestNewErrorRoute(t *testing.T) {
+ t.Run("with hosts", func(t *testing.T) {
+ hosts := []string{"error.example.com"}
+ statusCode := 503
+ body := "Service Unavailable"
+
+ route := NewErrorRoute(hosts, statusCode, body)
+
+ require.NotNil(t, route)
+ assert.Len(t, route.Match, 1)
+ assert.Len(t, route.Handle, 1)
+
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerStaticResponse, handler["handler"])
+ assert.Equal(t, statusCode, handler["status_code"])
+ assert.Equal(t, body, handler["body"])
+ })
+
+ t.Run("without hosts", func(t *testing.T) {
+ route := NewErrorRoute(nil, 500, "Internal Server Error")
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+ })
+}
+
+func TestNewCatchAllRoute(t *testing.T) {
+ route := NewCatchAllRoute()
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerStaticResponse, handler["handler"])
+ assert.Equal(t, 404, handler["status_code"])
+ assert.Equal(t, "Not Found", handler["body"])
+}
+
+func TestNewCatchAllRedirectRoute(t *testing.T) {
+ targetURL := "https://home.example.com"
+
+ route := NewCatchAllRedirectRoute(targetURL)
+
+ require.NotNil(t, route)
+ assert.Empty(t, route.Match)
+ assert.Len(t, route.Handle, 1)
+
+ handler := route.Handle[0]
+ assert.Equal(t, HandlerStaticResponse, handler["handler"])
+ assert.Equal(t, 302, handler["status_code"])
+}
+
+// =============================================================================
+// Server Builder Tests
+// =============================================================================
+
+func TestBuildDefaultServer(t *testing.T) {
+ route := NewHTTPRoute()
+ routes := []*HTTPRoute{route}
+
+ server := BuildDefaultServer(routes)
+
+ require.NotNil(t, server)
+ assert.Equal(t, []string{":443"}, server.Listen)
+ assert.Len(t, server.Routes, 1)
+}
+
+func TestBuildHTTPOnlyServer(t *testing.T) {
+ route := NewHTTPRoute()
+ routes := []*HTTPRoute{route}
+
+ server := BuildHTTPOnlyServer(routes)
+
+ require.NotNil(t, server)
+ assert.Equal(t, []string{":80"}, server.Listen)
+ assert.Len(t, server.Routes, 1)
+ require.NotNil(t, server.AutoHTTPS)
+ assert.True(t, server.AutoHTTPS.Disabled)
+}
+
+// =============================================================================
+// Utility Function Tests
+// =============================================================================
+
+func TestListenAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ expected string
+ }{
+ {
+ name: "empty host",
+ host: "",
+ port: 443,
+ expected: ":443",
+ },
+ {
+ name: "with host",
+ host: "0.0.0.0",
+ port: 8080,
+ expected: "0.0.0.0:8080",
+ },
+ {
+ name: "localhost",
+ host: "localhost",
+ port: 80,
+ expected: "localhost:80",
+ },
+ {
+ name: "ipv4 address",
+ host: "192.168.1.1",
+ port: 3000,
+ expected: "192.168.1.1:3000",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ListenAddress(tt.host, tt.port)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestGroupRoutesByHost(t *testing.T) {
+ t.Run("routes with different hosts", func(t *testing.T) {
+ route1 := NewHTTPRoute()
+ route1.AddMatch(NewHostMatcher("example.com"))
+
+ route2 := NewHTTPRoute()
+ route2.AddMatch(NewHostMatcher("api.example.com"))
+
+ route3 := NewHTTPRoute() // No host matcher
+
+ routes := []*HTTPRoute{route1, route2, route3}
+
+ grouped := GroupRoutesByHost(routes)
+
+ assert.Len(t, grouped["example.com"], 1)
+ assert.Len(t, grouped["api.example.com"], 1)
+ assert.Len(t, grouped[""], 1)
+ })
+
+ t.Run("route with multiple hosts", func(t *testing.T) {
+ route := NewHTTPRoute()
+ route.AddMatch(NewHostMatcher("example.com", "www.example.com"))
+
+ grouped := GroupRoutesByHost([]*HTTPRoute{route})
+
+ assert.Len(t, grouped["example.com"], 1)
+ assert.Len(t, grouped["www.example.com"], 1)
+ })
+
+ t.Run("empty routes", func(t *testing.T) {
+ grouped := GroupRoutesByHost([]*HTTPRoute{})
+ assert.Empty(t, grouped)
+ })
+}
+
+func TestExtractHosts(t *testing.T) {
+ t.Run("with MatchHost type", func(t *testing.T) {
+ route := NewHTTPRoute()
+ // Add host matcher as MatchHost type
+ route.Match = append(route.Match, MatcherSet{
+ "host": MatchHost{"example.com", "api.example.com"},
+ })
+
+ hosts := extractHosts(route)
+
+ assert.Len(t, hosts, 2)
+ assert.Contains(t, hosts, "example.com")
+ assert.Contains(t, hosts, "api.example.com")
+ })
+
+ t.Run("with string slice type", func(t *testing.T) {
+ route := NewHTTPRoute()
+ // Add host matcher as []string type
+ route.Match = append(route.Match, MatcherSet{
+ "host": []string{"static.example.com"},
+ })
+
+ hosts := extractHosts(route)
+
+ assert.Len(t, hosts, 1)
+ assert.Contains(t, hosts, "static.example.com")
+ })
+
+ t.Run("no host matcher", func(t *testing.T) {
+ route := NewHTTPRoute()
+ route.Match = append(route.Match, MatcherSet{
+ "path": []string{"/api/*"},
+ })
+
+ hosts := extractHosts(route)
+
+ assert.Empty(t, hosts)
+ })
+
+ t.Run("empty match", func(t *testing.T) {
+ route := NewHTTPRoute()
+ hosts := extractHosts(route)
+ assert.Empty(t, hosts)
+ })
+}
+
+func TestCollectDomainsFromRoutes(t *testing.T) {
+ t.Run("collect unique domains", func(t *testing.T) {
+ route1 := NewHTTPRoute()
+ route1.AddMatch(NewHostMatcher("example.com"))
+
+ route2 := NewHTTPRoute()
+ route2.AddMatch(NewHostMatcher("api.example.com"))
+
+ route3 := NewHTTPRoute()
+ route3.AddMatch(NewHostMatcher("example.com")) // Duplicate
+
+ routes := []*HTTPRoute{route1, route2, route3}
+
+ domains := CollectDomainsFromRoutes(routes)
+
+ assert.Len(t, domains, 2)
+ assert.Contains(t, domains, "example.com")
+ assert.Contains(t, domains, "api.example.com")
+ })
+
+ t.Run("empty routes", func(t *testing.T) {
+ domains := CollectDomainsFromRoutes([]*HTTPRoute{})
+ assert.Empty(t, domains)
+ })
+
+ t.Run("routes without hosts", func(t *testing.T) {
+ route := NewHTTPRoute()
+ route.AddMatch(NewPathMatcher("/api/*"))
+
+ domains := CollectDomainsFromRoutes([]*HTTPRoute{route})
+ assert.Empty(t, domains)
+ })
+}
diff --git a/backend/internal/caddy/config/security.go b/backend/internal/caddy/config/security.go
new file mode 100644
index 0000000..fa87fdd
--- /dev/null
+++ b/backend/internal/caddy/config/security.go
@@ -0,0 +1,152 @@
+package config
+
+// SecurityRoutes returns predefined security routes that block common exploits.
+// These routes should be added BEFORE the main proxy routes so they can intercept
+// malicious requests before they reach the backend.
+//
+// The rules block:
+// - SQL injection attempts
+// - File injection/path traversal
+// - XSS (Cross-Site Scripting) attacks
+// - PHP globals injection
+// - Malicious user agents
+// - Common vulnerability scanner paths
+func SecurityRoutes() []*HTTPRoute {
+ return []*HTTPRoute{
+ // Block SQL injection attempts in URI
+ {
+ Match: []MatcherSet{
+ {
+ "path_regexp": map[string]string{
+ "name": "sql_injection",
+ "pattern": `(?i)(union.*select|select.*from|insert.*into|delete.*from|drop.*table|update.*set)`,
+ },
+ },
+ },
+ Handle: []HTTPHandler{
+ {
+ "handler": "static_response",
+ "status_code": 403,
+ "body": "Forbidden - SQL injection detected",
+ "close": true,
+ },
+ },
+ Terminal: true,
+ },
+ // Block file injection/traversal attempts
+ {
+ Match: []MatcherSet{
+ {
+ "path_regexp": map[string]string{
+ "name": "file_injection",
+ "pattern": `(\.\./|\.\.\\|%2e%2e|%252e)`,
+ },
+ },
+ },
+ Handle: []HTTPHandler{
+ {
+ "handler": "static_response",
+ "status_code": 403,
+ "body": "Forbidden - Path traversal detected",
+ "close": true,
+ },
+ },
+ Terminal: true,
+ },
+ // Block common XSS patterns
+ {
+ Match: []MatcherSet{
+ {
+ "path_regexp": map[string]string{
+ "name": "xss_attack",
+ "pattern": `(?i)("
-
-# Should be blocked (Path traversal)
-curl "https://example.com/?file=../../../etc/passwd"
-
-# Should be blocked (Bad user agent)
-curl -A "libwww-perl/6.0" "https://example.com/"
-
-# Should work normally
-curl "https://example.com/?search=normal query"
-```
-
-## Customization
-
-To customize the security rules:
-
-1. Edit `conf/snippets/security.caddy`
-2. Validate the configuration: `make validate`
-3. Reload Caddy: `make restart`
-
-The changes will apply to all proxies that have `block_exploits: true` enabled.
diff --git a/conf/snippets/security.caddy b/conf/snippets/security.caddy
deleted file mode 100644
index ce9d9d0..0000000
--- a/conf/snippets/security.caddy
+++ /dev/null
@@ -1,50 +0,0 @@
-# Security snippet for blocking common exploits
-# Import this snippet with: import /etc/caddy/snippets/security.caddy
-
-# Block SQL injection attempts in URI
-@sql_injection {
- path_regexp sql_inj (?i)(union.*select|select.*from|insert.*into|delete.*from|drop.*table|update.*set)
-}
-respond @sql_injection "Forbidden - SQL injection detected" 403 {
- close
-}
-
-# Block file injection/traversal attempts
-@file_injection {
- path_regexp file_inj (\.\./|\.\.\\|%2e%2e|%252e)
-}
-respond @file_injection "Forbidden - Path traversal detected" 403 {
- close
-}
-
-# Block common XSS patterns
-@xss_attack {
- path_regexp xss (?i)(