Files
phishingclub/backend/service/proxy.go
Ronni Skansing 071b01ac49 improve config error types
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-12-05 22:59:14 +01:00

2696 lines
84 KiB
Go

package service
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/go-errors/errors"
"github.com/oapi-codegen/nullable"
"gopkg.in/yaml.v3"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
// Proxy is a Proxy service
type Proxy struct {
Common
ProxyRepository *repository.Proxy
DomainRepository *repository.Domain
CampaignRepository *repository.Campaign
CampaignTemplateService *CampaignTemplate
DomainService *Domain
ProxySessionManager *ProxySessionManager
}
// ProxyServiceConfig represents the YAML configuration for proxy
type ProxyServiceConfig struct {
Proxy string `yaml:"proxy,omitempty"`
Global *ProxyServiceRules `yaml:"global,omitempty"`
}
// ProxyServiceDomainConfig represents configuration for a specific domain mapping
type ProxyServiceDomainConfig struct {
To string `yaml:"to"`
Scheme string `yaml:"scheme,omitempty"` // http or https (defaults to https)
TLS *ProxyServiceTLSConfig `yaml:"tls,omitempty"`
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
Response []ProxyServiceResponseRule `yaml:"response,omitempty"`
RewriteURLs []ProxyServiceURLRewriteRule `yaml:"rewrite_urls,omitempty"`
}
// ProxyServiceRules represents capture and replace rules
// ProxyServiceRules represents global rules that apply to all hosts
type ProxyServiceRules struct {
TLS *ProxyServiceTLSConfig `yaml:"tls,omitempty"`
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
Impersonate *ProxyServiceImpersonateConfig `yaml:"impersonate,omitempty"`
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
Response []ProxyServiceResponseRule `yaml:"response,omitempty"`
RewriteURLs []ProxyServiceURLRewriteRule `yaml:"rewrite_urls,omitempty"`
}
// ProxyServiceTLSConfig represents TLS configuration for proxy domains
// TLS modes:
// - "managed": Use Let's Encrypt for automatic certificate management (DEFAULT)
// - "self-signed": Use automatically generated self-signed certificates
//
// Configuration can be set globally and overridden per-host:
//
// global:
// tls:
// mode: "managed"
// example.com:
// to: "phishing.com"
// tls:
// mode: "self-signed" # override global setting
type ProxyServiceTLSConfig struct {
Mode string `yaml:"mode"` // "managed" | "self-signed"
}
// ProxyServiceImpersonateConfig represents client impersonation configuration
type ProxyServiceImpersonateConfig struct {
Enabled bool `yaml:"enabled"` // enable surf browser impersonation based on ja4 fingerprint
RetainUA bool `yaml:"retain_ua"` // retain client's original user-agent instead of using surf's impersonated one
}
// ProxyServiceAccessControl represents access control configuration
type ProxyServiceAccessControl struct {
Mode string `yaml:"mode"` // "public" | "private"
OnDeny string `yaml:"on_deny,omitempty"` // "404" | "redirect:URL" | status code (only used for private mode)
}
// Access control modes:
// - "public": Allow all traffic (traditional proxy mode) - on_deny is ignored
// - "private": Strict IP-based mode like evilginx2 - whitelist IP after lure access, deny all others (DEFAULT)
// CompilePathPatterns compiles regex patterns for all capture and response rules
func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
// Compile global capture rule patterns
if config.Global != nil && config.Global.Capture != nil {
for i := range config.Global.Capture {
if err := compileCapturePath(&config.Global.Capture[i]); err != nil {
return err
}
}
}
// Compile global response rule patterns
if config.Global != nil && config.Global.Response != nil {
for i := range config.Global.Response {
if err := compileResponsePath(&config.Global.Response[i]); err != nil {
return err
}
}
}
// Compile host-specific capture rule patterns
for _, hostConfig := range config.Hosts {
if hostConfig != nil && hostConfig.Capture != nil {
for i := range hostConfig.Capture {
if err := compileCapturePath(&hostConfig.Capture[i]); err != nil {
return err
}
}
}
}
// Compile host-specific response rule patterns
for _, hostConfig := range config.Hosts {
if hostConfig != nil && hostConfig.Response != nil {
for i := range hostConfig.Response {
if err := compileResponsePath(&hostConfig.Response[i]); err != nil {
return err
}
}
}
}
return nil
}
// compileResponsePath compiles the path pattern for a response rule
func compileResponsePath(rule *ProxyServiceResponseRule) error {
if rule.Path != "" {
pathRe, err := regexp.Compile(rule.Path)
if err != nil {
return fmt.Errorf("invalid regex pattern for response path '%s': %w", rule.Path, err)
}
rule.PathRe = pathRe
}
return nil
}
// compileCapturePath compiles the path pattern for a capture rule
func compileCapturePath(rule *ProxyServiceCaptureRule) error {
if rule.Path != "" {
pathRe, err := regexp.Compile(rule.Path)
if err != nil {
return fmt.Errorf("invalid regex pattern for path '%s': %w", rule.Path, err)
}
rule.PathRe = pathRe
}
return nil
}
// ProxyServiceCaptureRule represents a capture rule
type ProxyServiceCaptureRule struct {
Name string `yaml:"name"`
Method string `yaml:"method,omitempty"`
Path string `yaml:"path,omitempty"`
Find interface{} `yaml:"find,omitempty"` // can be string or []string
Engine string `yaml:"engine,omitempty"`
From string `yaml:"from,omitempty"`
Required *bool `yaml:"required,omitempty"`
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
}
// GetFindAsStrings returns find field as a slice of strings
func (c *ProxyServiceCaptureRule) GetFindAsStrings() []string {
if c.Find == nil {
return []string{}
}
switch v := c.Find.(type) {
case string:
return []string{v}
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
case []string:
return v
default:
return []string{}
}
}
// GetFindAsString returns the first find value as a string
func (c *ProxyServiceCaptureRule) GetFindAsString() string {
finds := c.GetFindAsStrings()
if len(finds) > 0 {
return finds[0]
}
return ""
}
// ProxyServiceReplaceRule represents a replacement rule
type ProxyServiceReplaceRule struct {
Name string `yaml:"name,omitempty"`
Engine string `yaml:"engine,omitempty"` // "regex" (default) or "dom"
Find string `yaml:"find,omitempty"` // regex pattern (regex engine) or css selector (dom engine)
Replace string `yaml:"replace,omitempty"` // replacement value for both engines
Action string `yaml:"action,omitempty"` // dom action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove
Target string `yaml:"target,omitempty"` // target matching: "first", "last", "all" (default), "1,3,5", "2-4"
From string `yaml:"from,omitempty"`
}
// ProxyServiceURLRewriteRule represents a URL rewrite rule for anti-detection
type ProxyServiceURLRewriteRule struct {
Find string `yaml:"find"` // regex pattern to match path
Replace string `yaml:"replace"` // replacement path
Query []ProxyServiceURLRewriteQueryParam `yaml:"query,omitempty"`
Filter []string `yaml:"filter,omitempty"` // query parameters to keep (if empty, keep all)
}
// ProxyServiceURLRewriteQueryParam represents query parameter mapping
type ProxyServiceURLRewriteQueryParam struct {
Find string `yaml:"find"` // original parameter name
Replace string `yaml:"replace"` // new parameter name
}
// ProxyServiceResponseRule represents a response rule that allows custom responses for specific paths
//
// COMPLETE PROCESSING ORDER & PRECEDENCE:
// The proxy processes rules in this exact order:
//
// REQUEST PROCESSING:
// 1. Response rules (FIRST) - can short-circuit everything
// 2. Session creation/loading (SECOND) - creates session ID internally
// 3. Access control (THIRD) - can block forwarding (uses session existence)
// 4. Capture rules on request (headers, body, cookies)
// 5. Rewrite rules on request (URL params, body patching)
// 6. Request forwarded to target server
//
// RESPONSE PROCESSING:
// 7. Session cookie setting (for new sessions) - cookie sent to client
// 8. Capture rules on response (headers, body, cookies)
// 9. Rewrite rules on response (headers, body, URL replacement)
// 10. Final response returned to client
//
// PRECEDENCE RULES:
// - Response rules with forward: false → skip ALL other processing
// - Response rules with forward: true → capture/rewrite still apply
// - Session created before access control (affects hasSession logic)
// - Access control can block forwarding even with pending response
// - Cookie only set in response phase (not during session creation)
// - Capture rules always run (unless response rule short-circuits)
// - Rewrite rules always run (unless response rule short-circuits)
//
// PRACTICAL EXAMPLES:
//
// Example 1 - Fake API endpoint (response rule wins, bypasses everything):
//
// response:
// - path: "^/api/status$"
// body: '{"status": "ok"}'
// forward: false
// access:
// mode: "deny"
// paths: ["^/api/status$"]
// capture:
// - name: "api_data"
// path: "^/api/status$"
// Result: Returns {"status": "ok"} immediately, no access control, no capture, no forwarding
//
// Example 2 - Monitor + fake response (response + access rules apply):
//
// response:
// - path: "^/api/status$"
// body: '{"status": "ok"}'
// forward: true
// access:
// mode: "deny"
// paths: ["^/api/status$"]
// capture:
// - name: "api_data"
// path: "^/api/status$"
// Result: Creates session → captures request data → returns {"status": "ok"} → sets cookie → NOT forwarded (access blocks it)
//
// Example 3 - Full pipeline (all rules apply):
//
// response:
// - path: "^/api/status$"
// body: '{"status": "ok"}'
// forward: true
// access:
// mode: "allow"
// paths: ["^/api/"]
// capture:
// - name: "api_data"
// path: "^/api/status$"
// rewrite:
// - find: "original.com"
// replace: "phishing.com"
// Result: Creates session → captures data → rewrites content → forwards to target → captures response → sets cookie → ignores custom response
//
// RESPONSE RULE FEATURES:
// - forward: false (default) → replace normal proxy behavior
// - forward: true → provide response while still attempting to forward
// - Body content is used as-is (plain text/HTML/JSON/etc.)
type ProxyServiceResponseRule struct {
Path string `yaml:"path"` // regex pattern for request path
Status int `yaml:"status"` // HTTP status code (default: 200)
Headers map[string]string `yaml:"headers"` // response headers to set
Body string `yaml:"body"` // response body content (supports template variables)
Forward bool `yaml:"forward"` // whether to also forward requesto target (default: false)
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
}
// ProxyServiceConfigYAML represents the complete YAML configuration structure that matches the actual YAML format
//
// Example YAML configuration with access control and response rules:
//
// version: "0.0"
// global:
//
// # No access section = private mode by default (secure by default)
// capture:
// - name: "global_navigation"
// path: "/important"
// response:
// - path: "^/favicon\\.ico$"
// headers:
// Content-Type: "image/x-icon"
// body: "base64:AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAA=="
// forward: false
//
// example.com:
//
// to: "phishing-example.com"
// # No access section = private mode by default (secure by default)
// # To override with public mode or custom deny action:
// # access:
// # mode: "public" # Traditional proxy mode
// # Or:
// # access:
// # mode: "private"
// # on_deny: "https://example.com" # Clean redirect syntax
// response:
// - path: "^/robots\\.txt$"
// headers:
// Content-Type: "text/plain"
// body: |
// User-agent: *
// Disallow: /
// forward: false
// - path: "^/api/health$"
// headers:
// Content-Type: "application/json"
// body: '{"status": "ok", "timestamp": "{{timestamp}}"}'
// forward: true
// capture:
// - name: "login_capture"
// method: "POST"
// path: "/login"
// find: "password=(.*?)&"
// from: "request_body"
//
// Example proxy configuration with client impersonation:
//
// version: "0.0"
// proxy: "proxy.example.com:8080" # optional upstream proxy
// global:
// tls:
// mode: "managed"
// impersonate:
// enabled: true # enable surf browser impersonation (default: false)
// retain_ua: false # retain client's original user-agent (default: false)
// example.com:
// to: "target.com"
//
// When impersonate.enabled is true, the proxy will:
// - capture the client's ja4 tls fingerprint
// - detect the client's browser (chrome, firefox, safari, edge) and platform (windows, macos, linux, android, ios)
// - use surf library to replicate the exact tls fingerprint, http/2 settings, and header ordering
// - this makes proxied requests appear identical to the original client's browser
// When impersonate.retain_ua is true, the original client user-agent is preserved instead of using surf's impersonated one
type ProxyServiceConfigYAML struct {
Version string `yaml:"version,omitempty"`
Proxy string `yaml:"proxy,omitempty"`
Global *ProxyServiceRules `yaml:"global,omitempty"`
Hosts map[string]*ProxyServiceDomainConfig `yaml:",inline"` // inline allows domain names as top-level keys
}
// ValidateVersion validates that the version is supported
func ValidateVersion(config *ProxyServiceConfigYAML) error {
if config.Version != "0.0" {
return errors.New("only version 0.0 is supported")
}
return nil
}
// Create creates a new Proxy
func (m *Proxy) Create(
ctx context.Context,
session *model.Session,
proxy *model.Proxy,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Proxy.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
var companyID *uuid.UUID
if cid, err := proxy.CompanyID.Get(); err == nil {
companyID = &cid
}
// validate data
if err := proxy.Validate(); err != nil {
m.Logger.Errorw("failed to validate proxy", "error", err)
return nil, errs.Wrap(err)
}
// validate Proxy configuration
if err := m.validateProxyConfig(ctx, proxy); err != nil {
return nil, err
}
// check uniqueness
name := proxy.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.ProxyRepository.DB,
"proxies",
name.String(),
companyID,
nil,
)
if err != nil {
m.Logger.Errorw("failed to check proxy uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
m.Logger.Debugw("proxy name is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// create proxy
id, err := m.ProxyRepository.Insert(
ctx,
proxy,
)
if err != nil {
m.Logger.Errorw("failed to create proxy", "error", err)
return nil, errs.Wrap(err)
}
// create associated domains
err = m.createProxyDomains(ctx, session, id, proxy)
if err != nil {
// rollback proxy creation
m.ProxyRepository.DeleteByID(ctx, id)
m.Logger.Errorw("failed to create proxy domains", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
m.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets proxies
func (m *Proxy) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.ProxyOption,
) (*model.Result[model.Proxy], error) {
result := model.NewEmptyResult[model.Proxy]()
ae := NewAuditEvent("Proxy.GetAll", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = m.ProxyRepository.GetAll(
ctx,
companyID,
options,
)
if err != nil {
m.Logger.Errorw("failed to get proxies", "error", err)
return result, errs.Wrap(err)
}
// no audit log on read
return result, nil
}
// GetAllOverview gets proxies with limited data
func (m *Proxy) GetAllOverview(
companyID *uuid.UUID, // can be null
ctx context.Context,
session *model.Session,
queryArgs *vo.QueryArgs,
) (*model.Result[model.ProxyOverview], error) {
result := model.NewEmptyResult[model.ProxyOverview]()
ae := NewAuditEvent("Proxy.GetAllOverview", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get proxies
result, err = m.ProxyRepository.GetAllSubset(
ctx,
companyID,
&repository.ProxyOption{
QueryArgs: queryArgs,
},
)
if err != nil {
m.Logger.Errorw("failed to get proxies subset", "error", err)
return result, errs.Wrap(err)
}
// no audit log on read
return result, nil
}
// GetByID gets a Proxy by ID
func (m *Proxy) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.ProxyOption,
) (*model.Proxy, error) {
ae := NewAuditEvent("Proxy.GetByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get proxy
proxy, err := m.ProxyRepository.GetByID(
ctx,
id,
options,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
// return early this is not a an error
return nil, errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to get proxy by ID", "error", err)
return nil, errs.Wrap(err)
}
// apply defaults to Proxy configuration for display
if err := m.applyConfigurationDefaults(proxy); err != nil {
m.Logger.Errorw("failed to apply configuration defaults", "error", err)
// don't fail the request, just log the error
}
// no audit log on read
return proxy, nil
}
// UpdateByID updates a Proxy by ID
func (m *Proxy) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
proxy *model.Proxy,
) error {
ae := NewAuditEvent("Proxy.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return err
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get current
current, err := m.ProxyRepository.GetByID(
ctx,
id,
&repository.ProxyOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Debugw("failed to update proxy by ID", "error", err)
return err
}
if err != nil {
m.Logger.Errorw("failed to update proxy by ID", "error", err)
return err
}
// update proxy - if a field is present and not null, update it
if v, err := proxy.Name.Get(); err == nil {
// check uniqueness
var companyID *uuid.UUID
if cid, err := current.CompanyID.Get(); err == nil {
companyID = &cid
}
name := proxy.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.ProxyRepository.DB,
"proxies",
name.String(),
companyID,
id,
)
if err != nil {
m.Logger.Errorw("failed to check proxy uniqueness", "error", err)
return err
}
if !isOK {
m.Logger.Debugw("proxy name is already taken", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(v)
}
if v, err := proxy.Description.Get(); err == nil {
current.Description.Set(v)
}
if v, err := proxy.StartURL.Get(); err == nil {
current.StartURL.Set(v)
}
if v, err := proxy.ProxyConfig.Get(); err == nil {
current.ProxyConfig.Set(v)
}
// validate updated Proxy configuration
if err := m.validateProxyConfigForUpdate(ctx, current, id); err != nil {
return err
}
// update proxy
err = m.ProxyRepository.UpdateByID(
ctx,
id,
current,
)
if err != nil {
m.Logger.Errorw("failed to update proxy by ID", "error", err)
return err
}
// update associated domains
err = m.syncProxyDomains(ctx, session, id, current)
if err != nil {
m.Logger.Errorw("failed to sync proxy domains", "error", err)
return err
}
// clear all sessions for this proxy since config has changed
// this ensures sessions get the new config (capture rules, rewrite rules, etc.)
if m.ProxySessionManager != nil {
m.Logger.Debugw("clearing all sessions for updated proxy",
"proxyID", id.String(),
)
m.ProxySessionManager.ClearSessionsForProxy(id.String())
}
ae.Details["id"] = id.String()
m.AuditLogAuthorized(ae)
return nil
}
// validateProxyConfigForUpdate validates Proxy configuration during update, allowing same domains for same proxy
func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.Proxy, proxyID *uuid.UUID) error {
// validate Proxy configuration YAML
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("Proxy configuration is required"), "proxyConfig")
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return validate.WrapErrorWithField(errors.New("invalid YAML format: "+err.Error()), "proxyConfig")
}
// set default values
m.setProxyConfigDefaults(&config)
// compile regex patterns for capture and response rules
if err := CompilePathPatterns(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate version (after defaults are applied)
if err := ValidateVersion(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate that at least one domain mapping exists
if len(config.Hosts) == 0 {
return validate.WrapErrorWithField(errors.New("at least one domain mapping must be specified"), "proxyConfig")
}
// validate global uniqueness of capture names across all domains and global rules
if err := m.validateGlobalCaptureNameUniqueness(&config); err != nil {
return err
}
// ensure that the start URL domain is mentioned in the domain mappings
startURL, err := proxy.StartURL.Get()
if err == nil {
startURLStr := startURL.String()
var startDomain string
// extract domain from start URL
if strings.Contains(startURLStr, "://") {
// full URL like https://auth.example.com/login
parts := strings.Split(startURLStr, "://")
if len(parts) > 1 {
domainParts := strings.Split(parts[1], "/")
startDomain = domainParts[0]
}
} else if strings.Contains(startURLStr, "/") {
// domain/path format like auth.example.com/login
parts := strings.Split(startURLStr, "/")
startDomain = parts[0]
} else {
// just domain like auth.example.com
startDomain = startURLStr
}
// check if start domain is in the domain mappings
if startDomain != "" {
found := false
for originalDomain := range config.Hosts {
if originalDomain == startDomain {
found = true
break
}
}
if !found {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("start URL domain '%s' must be included in domain mappings", startDomain)),
"proxyConfig",
)
}
}
}
// validate each domain mapping
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain config for '%s' is nil", originalDomain)),
"proxyConfig",
)
}
// validate that 'to' is specified
if domainConfig.To == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("'to' field is required for domain '%s'", originalDomain)),
"proxyConfig",
)
}
// validate domain-specific access control
if err := m.validateAccessControl(domainConfig.Access); err != nil {
return err
}
// validate domain-specific capture rules
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
return err
}
// validate domain-specific rewrite rules
if err := m.validateReplaceRules(domainConfig.Rewrite); err != nil {
return err
}
// note: domain uniqueness validation is skipped during updates
// the syncProxyDomains method will handle domain management properly
}
// validate global rules
if config.Global != nil {
if err := m.validateAccessControl(config.Global.Access); err != nil {
return err
}
if err := m.validateCaptureRules(config.Global.Capture); err != nil {
return err
}
if err := m.validateReplaceRules(config.Global.Rewrite); err != nil {
return err
}
// validate global URL rewrite rules
if err := m.validateURLRewriteRules(config.Global.RewriteURLs); err != nil {
return err
}
// validate global response rules
if err := m.validateResponseRules(config.Global.Response); err != nil {
return err
}
}
return nil
}
// validateCaptureRules validates a slice of capture rules
func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) error {
// track capture names to prevent duplicates
captureNames := make(map[string]bool)
for _, capture := range captureRules {
if capture.Name == "" {
return validate.WrapErrorWithField(errors.New("capture rule name is required"), "proxyConfig")
}
// check for duplicate capture names
if captureNames[capture.Name] {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("duplicate capture rule name '%s' found - each capture rule must have a unique name", capture.Name)),
"proxyConfig",
)
}
captureNames[capture.Name] = true
if capture.Path == "" {
return validate.WrapErrorWithField(errors.New("capture rule path is required"), "proxyConfig")
}
// validate engine field
if capture.Engine != "" {
validEngines := []string{"regex", "header", "cookie", "json", "form", "urlencoded", "formdata", "multipart"}
valid := false
for _, validEngine := range validEngines {
if capture.Engine == validEngine {
valid = true
break
}
}
if !valid {
return validate.WrapErrorWithField(
errors.New("invalid 'engine' value in capture rule, must be one of: "+strings.Join(validEngines, ", ")),
"proxyConfig",
)
}
}
// allow empty find pattern for any method path-based navigation tracking
findStr := capture.GetFindAsString()
isNavigationTracking := capture.Path != "" && findStr == ""
if findStr == "" && !isNavigationTracking {
return validate.WrapErrorWithField(
errors.New("capture rule must have a find pattern, except for path-based navigation tracking"),
"proxyConfig",
)
}
if findStr != "" {
engine := capture.Engine
// backward compatibility: use 'from' to determine engine if not specified
if engine == "" && capture.From == "cookie" {
engine = "cookie"
}
if engine == "" {
engine = "regex"
}
// validate based on engine type
switch engine {
case "regex":
// validate regex pattern
if _, err := regexp.Compile(findStr); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in capture rule: "+err.Error()),
"proxyConfig",
)
}
case "header":
// header engine: find is the header name
if findStr == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with engine='header' must specify header name in 'find' field"),
"proxyConfig",
)
}
case "cookie":
// cookie engine: find is the cookie name
if findStr == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with engine='cookie' must specify cookie name in 'find' field"),
"proxyConfig",
)
}
// validate cookie name format (basic validation)
cookieName := findStr
invalidChars := []string{" ", "\t", "\n", "\r", "=", ";", ","}
for _, char := range invalidChars {
if strings.Contains(cookieName, char) {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("cookie name '%s' contains invalid character '%s'", cookieName, char)),
"proxyConfig",
)
}
}
case "json":
// json engine: find is the json path (e.g., "user.name" or "[0].user.name")
if findStr == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with engine='json' must specify JSON path in 'find' field"),
"proxyConfig",
)
}
case "form", "urlencoded", "formdata", "multipart":
// form engines: find is the form field name
if findStr == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("capture rule with engine='%s' must specify form field name in 'find' field", engine)),
"proxyConfig",
)
}
}
}
// 'from' field defaults to 'any' if not specified (handled in setProxyConfigDefaults)
// validate 'from' field if specified
if capture.From != "" {
validFromValues := []string{"request_body", "request_header", "response_body", "response_header", "cookie", "any"}
valid := false
for _, validFrom := range validFromValues {
if capture.From == validFrom {
valid = true
break
}
}
if !valid {
return validate.WrapErrorWithField(
errors.New("invalid 'from' value in capture rule, must be one of: "+strings.Join(validFromValues, ", ")),
"proxyConfig",
)
}
}
// validate cookie-specific rules (backward compatibility with from='cookie')
if capture.From == "cookie" {
findStr := capture.GetFindAsString()
if findStr == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with from='cookie' must specify cookie name in 'find' field"),
"proxyConfig",
)
}
// method should be specified for cookie captures
if capture.Method == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with from='cookie' should specify HTTP method"),
"proxyConfig",
)
}
}
}
return nil
}
// validateURLRewriteRules validates URL rewrite rules configuration
func (m *Proxy) validateURLRewriteRules(urlRewriteRules []ProxyServiceURLRewriteRule) error {
for i, rule := range urlRewriteRules {
// validate find pattern is not empty
if rule.Find == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d must have a find pattern", i)),
"proxyConfig",
)
}
// validate regex pattern
if _, err := regexp.Compile(rule.Find); err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d has invalid regex pattern: %s", i, err.Error())),
"proxyConfig",
)
}
// validate replace path is not empty
if rule.Replace == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d must have a replace path", i)),
"proxyConfig",
)
}
// validate query parameter mappings
queryParamNames := make(map[string]bool)
for j, queryParam := range rule.Query {
if queryParam.Find == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d, query param at index %d must have a find parameter name", i, j)),
"proxyConfig",
)
}
if queryParam.Replace == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d, query param at index %d must have a replace parameter name", i, j)),
"proxyConfig",
)
}
// check for duplicate query parameter mappings
if queryParamNames[queryParam.Find] {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("URL rewrite rule at index %d has duplicate query parameter mapping for '%s'", i, queryParam.Find)),
"proxyConfig",
)
}
queryParamNames[queryParam.Find] = true
}
// note: it's valid to both filter and map the same parameter
// the filter determines which parameters are allowed through,
// and mappings rename them during the process
}
return nil
}
// validateResponseRules validates response rules configuration
func (m *Proxy) validateResponseRules(responseRules []ProxyServiceResponseRule) error {
for i, rule := range responseRules {
// validate path is not empty
if rule.Path == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d must have a path", i)),
"proxyConfig",
)
}
// validate regex pattern
if _, err := regexp.Compile(rule.Path); err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d has invalid regex pattern '%s': %v", i, rule.Path, err)),
"proxyConfig",
)
}
// validate status code if specified
if rule.Status != 0 {
if rule.Status < 100 || rule.Status > 599 {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d has invalid status code %d (must be 100-599)", i, rule.Status)),
"proxyConfig",
)
}
}
// validate headers
for headerName, headerValue := range rule.Headers {
if headerName == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d has empty header name", i)),
"proxyConfig",
)
}
if strings.Contains(headerName, ":") || strings.Contains(headerName, "\n") || strings.Contains(headerName, "\r") {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d has invalid header name '%s'", i, headerName)),
"proxyConfig",
)
}
if strings.Contains(headerValue, "\n") || strings.Contains(headerValue, "\r") {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("response rule at index %d has invalid header value for '%s'", i, headerName)),
"proxyConfig",
)
}
}
}
return nil
}
// setProxyConfigDefaults sets default values for Proxy configuration after YAML parsing
func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) {
// set default version to 0.0 if not specified
if config.Version == "" {
config.Version = "0.0"
}
// set defaults for global TLS config
if config.Global != nil && config.Global.TLS != nil {
// set default mode to managed if not specified
if config.Global.TLS.Mode == "" {
config.Global.TLS.Mode = "managed"
}
}
for domain, domainConfig := range config.Hosts {
// set defaults for domain TLS config
if domainConfig != nil && domainConfig.TLS != nil {
// set default mode to managed if not specified
if domainConfig.TLS.Mode == "" {
domainConfig.TLS.Mode = "managed"
}
}
if domainConfig != nil && domainConfig.Capture != nil {
for i := range domainConfig.Capture {
// set default required to true if not specified
if domainConfig.Capture[i].Required == nil {
trueValue := true
domainConfig.Capture[i].Required = &trueValue
}
// set default 'from' to 'any' if not specified
if domainConfig.Capture[i].From == "" {
domainConfig.Capture[i].From = "any"
}
// set default engine based on from field for backward compatibility
if domainConfig.Capture[i].Engine == "" {
if domainConfig.Capture[i].From == "cookie" {
domainConfig.Capture[i].Engine = "cookie"
} else {
domainConfig.Capture[i].Engine = "regex"
}
}
}
}
if domainConfig != nil && domainConfig.Response != nil {
for i := range domainConfig.Response {
// set default status to 200 if not specified
if domainConfig.Response[i].Status == 0 {
domainConfig.Response[i].Status = 200
}
}
}
// set defaults for domain access control
if domainConfig != nil && domainConfig.Access != nil {
// set default mode to private if not specified
if domainConfig.Access.Mode == "" {
domainConfig.Access.Mode = "private"
}
// set default deny action for private mode if not specified
if domainConfig.Access.Mode == "private" && domainConfig.Access.OnDeny == "" {
domainConfig.Access.OnDeny = "404"
}
}
// clean up rewrite rules based on engine type
if domainConfig.Rewrite != nil {
for i := range domainConfig.Rewrite {
m.cleanupRewriteRule(&domainConfig.Rewrite[i])
}
}
config.Hosts[domain] = domainConfig
}
// set defaults for global capture rules
if config.Global != nil && config.Global.Capture != nil {
for i := range config.Global.Capture {
// set default required to true if not specified
if config.Global.Capture[i].Required == nil {
trueValue := true
config.Global.Capture[i].Required = &trueValue
}
// set default 'from' to 'any' if not specified
if config.Global.Capture[i].From == "" {
config.Global.Capture[i].From = "any"
}
// set default engine based on from field for backward compatibility
if config.Global.Capture[i].Engine == "" {
if config.Global.Capture[i].From == "cookie" {
config.Global.Capture[i].Engine = "cookie"
} else {
config.Global.Capture[i].Engine = "regex"
}
}
}
}
// set defaults for global response rules
if config.Global != nil && config.Global.Response != nil {
for i := range config.Global.Response {
// set default status to 200 if not specified
if config.Global.Response[i].Status == 0 {
config.Global.Response[i].Status = 200
}
}
}
// set defaults for global access control
if config.Global != nil && config.Global.Access != nil {
// set default mode to private if not specified
if config.Global.Access.Mode == "" {
config.Global.Access.Mode = "private"
}
// set default deny action for private mode if not specified
if config.Global.Access.Mode == "private" && config.Global.Access.OnDeny == "" {
config.Global.Access.OnDeny = "404"
}
}
// clean up global rewrite rules based on engine type
if config.Global != nil && config.Global.Rewrite != nil {
for i := range config.Global.Rewrite {
m.cleanupRewriteRule(&config.Global.Rewrite[i])
}
}
}
// cleanupRewriteRule ensures only relevant fields are set based on engine type
func (m *Proxy) cleanupRewriteRule(rule *ProxyServiceReplaceRule) {
// set default engine to regex if not specified
if rule.Engine == "" {
rule.Engine = "regex"
}
// clean up fields based on engine type
if rule.Engine == "regex" {
// for regex engine, completely remove dom-specific fields
rule.Action = ""
rule.Target = ""
// set default 'from' to 'response_body' for regex if not specified
if rule.From == "" {
rule.From = "response_body"
}
} else if rule.Engine == "dom" {
// for dom engine, set default target if not specified
if rule.Target == "" {
rule.Target = "all"
}
// dom engine always uses response_body, force it
rule.From = "response_body"
}
}
// validateReplaceRules validates a slice of replace rules
func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) error {
for _, replace := range replaceRules {
// set default engine to regex if not specified
engine := replace.Engine
if engine == "" {
engine = "regex"
}
// validate engine type
if engine != "regex" && engine != "dom" {
return validate.WrapErrorWithField(
errors.New("invalid 'engine' value in replace rule, must be 'regex' or 'dom'"),
"proxyConfig",
)
}
// validate based on engine type
if engine == "regex" {
if replace.Find == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'find' is required for regex engine"), "proxyConfig")
}
if _, err := regexp.Compile(replace.Find); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in replace rule 'find': "+err.Error()),
"proxyConfig",
)
}
} else if engine == "dom" {
if replace.Find == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'find' is required for dom engine"), "proxyConfig")
}
if replace.Action == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'action' is required for dom engine"), "proxyConfig")
}
// validate dom actions
validActions := []string{"setText", "setHtml", "setAttr", "removeAttr", "addClass", "removeClass", "remove"}
validAction := false
for _, action := range validActions {
if replace.Action == action {
validAction = true
break
}
}
if !validAction {
return validate.WrapErrorWithField(
errors.New("invalid 'action' value in replace rule, must be one of: "+strings.Join(validActions, ", ")),
"proxyConfig",
)
}
// validate that actions requiring a replace value have one
if (replace.Action == "setText" || replace.Action == "setHtml" || replace.Action == "setAttr" || replace.Action == "addClass" || replace.Action == "removeClass") && replace.Replace == "" {
return validate.WrapErrorWithField(
errors.New("replace rule 'replace' is required for action '"+replace.Action+"'"),
"proxyConfig",
)
}
// validate target field if specified
if replace.Target != "" {
validTargets := []string{"first", "last", "all"}
isValidTarget := false
for _, target := range validTargets {
if replace.Target == target {
isValidTarget = true
break
}
}
// also check for numeric patterns like "1,3,5" or "2-4"
if !isValidTarget {
if matched, _ := regexp.MatchString(`^(\d+,)*\d+$`, replace.Target); matched {
isValidTarget = true
} else if matched, _ := regexp.MatchString(`^\d+-\d+$`, replace.Target); matched {
isValidTarget = true
}
}
if !isValidTarget {
return validate.WrapErrorWithField(
errors.New("invalid 'target' value in replace rule, must be 'first', 'last', 'all', numeric list (1,3,5), or range (2-4)"),
"proxyConfig",
)
}
}
// dom engine doesn't use 'from' field - it always works on response_body
// if 'from' is specified for dom engine, it's ignored (will be forced to response_body)
}
if replace.From != "" {
validFromValues := []string{"request_body", "request_header", "response_body", "response_header", "any"}
valid := false
for _, validFrom := range validFromValues {
if replace.From == validFrom {
valid = true
break
}
}
if !valid {
return validate.WrapErrorWithField(
errors.New("invalid 'from' value in replace rule, must be one of: "+strings.Join(validFromValues, ", ")),
"proxyConfig",
)
}
}
}
return nil
}
// validateAccessControl validates access control configuration
func (m *Proxy) validateTLSConfig(tlsConfig *ProxyServiceTLSConfig) error {
if tlsConfig == nil {
return nil
}
// validate TLS mode
if tlsConfig.Mode != "" && tlsConfig.Mode != "managed" && tlsConfig.Mode != "self-signed" {
return validate.WrapErrorWithField(
errors.New("tls.mode must be either 'managed' or 'self-signed'"),
"proxyConfig",
)
}
return nil
}
func (m *Proxy) validateAccessControl(accessControl *ProxyServiceAccessControl) error {
if accessControl == nil {
return nil // access control will be set to defaults
}
// set default mode if empty
if accessControl.Mode == "" {
accessControl.Mode = "private"
}
// validate mode
if accessControl.Mode != "public" && accessControl.Mode != "private" {
return validate.WrapErrorWithField(
errors.New("access control mode must be either 'public' or 'private' - private mode uses IP whitelisting like evilginx2"),
"proxyConfig",
)
}
// validate deny action (only required for private mode)
if accessControl.Mode == "private" {
// set default deny action if empty
if accessControl.OnDeny == "" {
accessControl.OnDeny = "404"
}
if err := m.validateDenyAction(accessControl.OnDeny); err != nil {
return err
}
}
return nil
}
// validateDenyAction validates a deny action string
func (m *Proxy) validateDenyAction(action string) error {
if action == "" {
return nil // action is optional, will use default
}
// check for allow action
if action == "allow" {
return nil
}
// check for redirect action (auto-detect URLs or old redirect: syntax)
if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") {
if len(action) < 10 { // minimum valid URL length
return validate.WrapErrorWithField(
errors.New("redirect URL is too short, must be a valid URL like 'https://example.com'"),
"proxyConfig",
)
}
return nil
}
// check for old redirect: syntax (backwards compatibility)
if strings.HasPrefix(action, "redirect:") {
url := strings.TrimPrefix(action, "redirect:")
if url == "" {
return validate.WrapErrorWithField(
errors.New("redirect action must include URL: 'redirect:https://example.com' or just 'https://example.com'"),
"proxyConfig",
)
}
// basic URL validation for old syntax
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return validate.WrapErrorWithField(
errors.New("redirect URL must start with http:// or https://"),
"proxyConfig",
)
}
return nil
}
// check for status code
if statusCode, err := strconv.Atoi(action); err == nil {
if statusCode < 100 || statusCode > 599 {
return validate.WrapErrorWithField(
errors.New("status code must be between 100 and 599"),
"proxyConfig",
)
}
return nil
}
return validate.WrapErrorWithField(
errors.New("deny action must be a valid HTTP status code (e.g., '404') or redirect URL (e.g., 'https://example.com')"),
"proxyConfig",
)
}
// validateProxyConfig validates Proxy configuration
func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) error {
// validate Proxy configuration YAML
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("Proxy configuration is required"), "proxyConfig")
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return validate.WrapErrorWithField(errors.New("invalid YAML format: "+err.Error()), "proxyConfig")
}
// set default values
m.setProxyConfigDefaults(&config)
// compile regex patterns for capture and response rules
if err := CompilePathPatterns(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate version (after defaults are applied)
if err := ValidateVersion(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate forward proxy configuration
if err := m.validateForwardProxy(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate that at least one domain mapping exists
if len(config.Hosts) == 0 {
return validate.WrapErrorWithField(errors.New("at least one domain mapping must be specified"), "proxyConfig")
}
// validate global uniqueness of capture names across all domains and global rules
if err := m.validateGlobalCaptureNameUniqueness(&config); err != nil {
return err
}
// ensure that the start URL domain is mentioned in the domain mappings
startURL, err := proxy.StartURL.Get()
if err == nil {
startURLStr := startURL.String()
var startDomain string
// extract domain from start URL
if strings.Contains(startURLStr, "://") {
// full URL like https://auth.example.com/login
parts := strings.Split(startURLStr, "://")
if len(parts) > 1 {
domainParts := strings.Split(parts[1], "/")
startDomain = domainParts[0]
}
} else if strings.Contains(startURLStr, "/") {
// domain/path format like auth.example.com/login
parts := strings.Split(startURLStr, "/")
startDomain = parts[0]
} else {
// just domain like auth.example.com
startDomain = startURLStr
}
// check if start domain is in the domain mappings
if startDomain != "" {
found := false
for originalDomain := range config.Hosts {
if originalDomain == startDomain {
found = true
break
}
}
if !found {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("start URL domain '%s' must be included in domain mappings", startDomain)),
"proxyConfig",
)
}
}
}
// validate each domain mapping
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain config for '%s' is nil", originalDomain)),
"proxyConfig",
)
}
// validate that 'to' is specified
if domainConfig.To == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("'to' field is required for domain mapping '%s'", originalDomain)),
"proxyConfig",
)
}
// validate that phishing domain doesn't already exist (unless it's managed by this proxy)
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", domainConfig.To)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
// check if this domain is managed by a different proxy or is a regular domain
if existingDomain.Type.MustGet().String() != "proxy" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", domainConfig.To)),
"proxyConfig",
)
} else {
// it's a proxy domain, check if it belongs to a different proxy
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil {
startURL, err := proxy.StartURL.Get()
if err == nil {
// extract domain from start URL for comparison
startURLParsed, err := url.Parse(startURL.String())
if err == nil && existingTarget.String() != startURLParsed.Host {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", domainConfig.To)),
"proxyConfig",
)
}
}
}
}
}
// validate domain-specific scheme
if domainConfig.Scheme != "" && domainConfig.Scheme != "http" && domainConfig.Scheme != "https" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("scheme for domain '%s' must be either 'http' or 'https'", originalDomain)),
"proxyConfig",
)
}
// validate domain-specific TLS config
if err := m.validateTLSConfig(domainConfig.TLS); err != nil {
return err
}
// validate domain-specific access control
if err := m.validateAccessControl(domainConfig.Access); err != nil {
return err
}
// validate domain-specific capture rules
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
return err
}
// validate domain-specific capture rules
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
return err
}
// validate rewrite rules
if err := m.validateReplaceRules(domainConfig.Rewrite); err != nil {
return err
}
// validate URL rewrite rules
if err := m.validateURLRewriteRules(domainConfig.RewriteURLs); err != nil {
return err
}
// validate response rules
if err := m.validateResponseRules(domainConfig.Response); err != nil {
return err
}
// validate that phishing domain is not used by another proxy
if err := m.validatePhishingDomainUniquenessByStartURL(ctx, domainConfig.To, proxy.StartURL.MustGet().String()); err != nil {
return err
}
}
// validate global rules
if config.Global != nil {
if err := m.validateTLSConfig(config.Global.TLS); err != nil {
return err
}
if err := m.validateAccessControl(config.Global.Access); err != nil {
return err
}
if err := m.validateCaptureRules(config.Global.Capture); err != nil {
return err
}
if err := m.validateReplaceRules(config.Global.Rewrite); err != nil {
return err
}
// validate global URL rewrite rules
if err := m.validateURLRewriteRules(config.Global.RewriteURLs); err != nil {
return err
}
// validate global response rules
if err := m.validateResponseRules(config.Global.Response); err != nil {
return err
}
}
return nil
}
// validateForwardProxy validates the forward proxy configuration
func (m *Proxy) validateForwardProxy(config *ProxyServiceConfigYAML) error {
if config.Proxy == "" {
return nil // proxy is optional
}
// check if it contains socks4 scheme
if strings.HasPrefix(config.Proxy, "socks4://") {
return errors.New("socks4 proxies are not supported. please use socks5:// instead")
}
// validate that it can be parsed as a URL if it has a scheme
if strings.Contains(config.Proxy, "://") {
parsedURL, err := url.Parse(config.Proxy)
if err != nil {
return errors.New("invalid proxy URL format: " + err.Error())
}
// validate supported schemes
scheme := parsedURL.Scheme
if scheme != "http" && scheme != "https" && scheme != "socks5" {
return errors.New(fmt.Sprintf("unsupported proxy scheme '%s'. supported schemes: http, https, socks5", scheme))
}
}
return nil
}
// validateGlobalCaptureNameUniqueness ensures all capture rule names are unique across the entire Proxy configuration
func (m *Proxy) validateGlobalCaptureNameUniqueness(config *ProxyServiceConfigYAML) error {
allCaptureNames := make(map[string]string) // name -> location
// collect all capture names from domain-specific rules
for domain, domainConfig := range config.Hosts {
if domainConfig != nil && domainConfig.Capture != nil {
for _, capture := range domainConfig.Capture {
if capture.Name == "" {
continue // this will be caught by other validation
}
if existingLocation, exists := allCaptureNames[capture.Name]; exists {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("duplicate capture rule name '%s' found in domain '%s' - already used in %s", capture.Name, domain, existingLocation)),
"proxyConfig",
)
}
allCaptureNames[capture.Name] = fmt.Sprintf("domain '%s'", domain)
}
}
}
// collect all capture names from global rules
if config.Global != nil && config.Global.Capture != nil {
for _, capture := range config.Global.Capture {
if capture.Name == "" {
continue // this will be caught by other validation
}
if existingLocation, exists := allCaptureNames[capture.Name]; exists {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("duplicate capture rule name '%s' found in global rules - already used in %s", capture.Name, existingLocation)),
"proxyConfig",
)
}
allCaptureNames[capture.Name] = "global rules"
}
}
return nil
}
// applyConfigurationDefaults applies default values to Proxy configuration for display
func (m *Proxy) applyConfigurationDefaults(proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return err
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return err
}
// apply defaults
m.setProxyConfigDefaults(&config)
// marshal back to YAML
updatedConfigBytes, err := yaml.Marshal(&config)
if err != nil {
return err
}
// update the Proxy configuration with defaults applied
updatedConfigVO := vo.NewString1MBMust(string(updatedConfigBytes))
proxy.ProxyConfig = nullable.NewNullableWithValue(*updatedConfigVO)
return nil
}
// deleteProxyDomains deletes all domains associated with a proxy
func (m *Proxy) deleteProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return err
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return err
}
// set default values
m.setProxyConfigDefaults(&config)
// delete domains for each mapping
for _, domainConfig := range config.Hosts {
if domainConfig == nil {
continue
}
// get domain by name and delete if it's a proxy domain
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
continue
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err == nil && existingDomain != nil {
// delete old domains that have proxy type
if existingDomain.Type.MustGet().String() == "proxy" {
domainID, err := existingDomain.ID.Get()
if err == nil {
err = m.DomainService.DeleteProxyDomain(ctx, session, &domainID)
if err != nil {
m.Logger.Warnw("failed to delete proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
} else {
m.Logger.Debugw("deleted proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
)
}
}
}
}
}
return nil
}
// validatePhishingDomainUniqueness checks if a phishing domain is already used by another proxy
func (m *Proxy) validatePhishingDomainUniqueness(ctx context.Context, phishingDomain string, excludeProxyID *uuid.UUID) error {
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
// check if this domain is managed by a different proxy
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it's managed by the same proxy we're updating (allowed)
if excludeProxyID != nil {
// this is a bit of a workaround - we'd need to track which proxy owns which domain
// for now, we'll allow updates to existing proxy domains
// TODO: add a proxy_id field to domains table for proper tracking
return nil
}
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another proxy", phishingDomain)),
"proxyConfig",
)
} else {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
}
return nil
}
// validatePhishingDomainUniquenessByStartURL checks if a phishing domain is already used by another proxy using start URL comparison
func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context, phishingDomain string, currentStartURL string) error {
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it belongs to a different proxy by comparing target domains
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil {
// extract domain from current start URL for comparison
currentStartURLParsed, err := url.Parse(currentStartURL)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid start URL format: %s", currentStartURL)),
"proxyConfig",
)
}
// normalize and extract domain for comparison
existingTargetStr := strings.ToLower(strings.TrimSpace(existingTarget.String()))
currentHostNormalized := strings.ToLower(strings.TrimSpace(currentStartURLParsed.Host))
// if existing target is a full URL, extract just the host part
var existingTargetNormalized string
if strings.Contains(existingTargetStr, "://") {
// it's a full URL, parse it to get the host
existingTargetParsed, err := url.Parse(existingTargetStr)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid existing target URL format: %s", existingTargetStr)),
"proxyConfig",
)
}
existingTargetNormalized = strings.ToLower(strings.TrimSpace(existingTargetParsed.Host))
} else {
// it's already just a domain
existingTargetNormalized = existingTargetStr
}
if existingTargetNormalized != currentHostNormalized {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
"proxyConfig",
)
}
}
} else {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
}
return nil
}
// validatePhishingDomainUniquenessForUpdate validates phishing domain uniqueness during proxy updates
func (m *Proxy) validatePhishingDomainUniquenessForUpdate(ctx context.Context, phishingDomain string, currentStartURL string, currentProxyID *uuid.UUID) error {
m.Logger.Debugw("validating phishing domain uniqueness for update",
"phishingDomain", phishingDomain,
"currentStartURL", currentStartURL,
"currentProxyID", currentProxyID.String(),
)
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
m.Logger.Errorw("invalid phishing domain format",
"phishingDomain", phishingDomain,
"error", err,
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Errorw("error getting existing domain",
"phishingDomain", phishingDomain,
"error", err,
)
return err
}
if existingDomain == nil {
m.Logger.Debugw("no existing domain found, validation passed",
"phishingDomain", phishingDomain,
)
return nil
}
m.Logger.Debugw("existing domain found",
"phishingDomain", phishingDomain,
"existingDomainType", existingDomain.Type.MustGet().String(),
)
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it belongs to a different proxy by comparing target domains
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err != nil {
m.Logger.Errorw("error getting existing domain proxy target",
"phishingDomain", phishingDomain,
"error", err,
)
return err
}
m.Logger.Debugw("existing domain proxy target found",
"phishingDomain", phishingDomain,
"existingTarget", existingTarget.String(),
)
// extract domain from current start URL for comparison
currentStartURLParsed, err := url.Parse(currentStartURL)
if err != nil {
m.Logger.Errorw("error parsing current start URL",
"currentStartURL", currentStartURL,
"error", err,
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid start URL format: %s", currentStartURL)),
"proxyConfig",
)
}
// normalize and extract domain for comparison
existingTargetStr := strings.ToLower(strings.TrimSpace(existingTarget.String()))
currentHostNormalized := strings.ToLower(strings.TrimSpace(currentStartURLParsed.Host))
// if existing target is a full URL, extract just the host part
var existingTargetNormalized string
if strings.Contains(existingTargetStr, "://") {
// it's a full URL, parse it to get the host
existingTargetParsed, err := url.Parse(existingTargetStr)
if err != nil {
m.Logger.Errorw("error parsing existing target URL",
"existingTarget", existingTargetStr,
"error", err,
)
return err
}
existingTargetNormalized = strings.ToLower(strings.TrimSpace(existingTargetParsed.Host))
} else {
// it's already just a domain
existingTargetNormalized = existingTargetStr
}
m.Logger.Debugw("comparing normalized domains",
"phishingDomain", phishingDomain,
"existingTargetStr", existingTargetStr,
"existingTargetNormalized", existingTargetNormalized,
"currentHostNormalized", currentHostNormalized,
)
// if target domains don't match, it belongs to a different proxy
if existingTargetNormalized != currentHostNormalized {
m.Logger.Warnw("phishing domain belongs to different proxy",
"phishingDomain", phishingDomain,
"existingTargetNormalized", existingTargetNormalized,
"currentHostNormalized", currentHostNormalized,
"currentProxyID", currentProxyID.String(),
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration (existing target: %s, current target: %s)", phishingDomain, existingTargetNormalized, currentHostNormalized)),
"proxyConfig",
)
}
// if target domains match, this domain belongs to the current proxy being updated, so it's allowed
m.Logger.Debugw("phishing domain belongs to current proxy, allowing reuse",
"domain", phishingDomain,
"proxyID", currentProxyID.String(),
"existingTarget", existingTargetNormalized,
"currentHost", currentHostNormalized,
)
} else {
m.Logger.Warnw("domain exists as regular domain, not proxy",
"phishingDomain", phishingDomain,
"existingDomainType", existingDomain.Type.MustGet().String(),
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
return nil
}
// createProxyDomains creates domains for the proxy based on the configuration
func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return errs.NewCustomError(fmt.Errorf("failed to get proxy config: %w", err))
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML: %w", err))
}
// set default values
m.setProxyConfigDefaults(&config)
var companyID *uuid.UUID
if cid, err := proxy.CompanyID.Get(); err == nil {
companyID = &cid
}
startURL := proxy.StartURL.MustGet()
createdDomains := make([]string, 0)
// create domains for each mapping
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
continue
}
if domainConfig.To == "" {
m.Logger.Warnw("empty 'to' field in domain config",
"proxyID", proxyID.String(),
"originalDomain", originalDomain,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain))
}
// check if domain already exists (might be from previous failed attempt)
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
m.Logger.Warnw("invalid phishing domain format",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err))
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err == nil && existingDomain != nil {
// domain already exists, check if it's compatible
if existingDomain.Type.MustGet().String() == "proxy" {
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil && existingTarget.String() == startURL.String() {
// compatible existing domain, skip creation
m.Logger.Debugw("proxy domain already exists, skipping creation",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
)
createdDomains = append(createdDomains, domainConfig.To)
continue
}
}
// incompatible domain exists
m.Logger.Warnw("incompatible domain already exists",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"existingType", existingDomain.Type.MustGet().String(),
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To))
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// database error
m.Logger.Errorw("failed to check existing domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to check existing domain %s: %w", domainConfig.To, err)
}
// create new domain
domain := &model.Domain{}
domain.Name.Set(*vo.NewString255Must(domainConfig.To))
domain.Type.Set(*vo.NewString32Must("proxy"))
domain.ProxyID.Set(*proxyID)
// set the target domain to the original domain from the YAML config
proxyTargetDomain, err := vo.NewOptionalString255(originalDomain)
if err != nil {
m.Logger.Errorw("failed to create proxy target domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"startURL", startURL.String(),
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err))
}
domain.ProxyTargetDomain.Set(*proxyTargetDomain)
// determine TLS mode from config (check domain-specific first, then global, then default to managed)
tlsMode := "managed" // default
if domainConfig.TLS != nil && domainConfig.TLS.Mode != "" {
tlsMode = domainConfig.TLS.Mode
} else if config.Global != nil && config.Global.TLS != nil && config.Global.TLS.Mode != "" {
tlsMode = config.Global.TLS.Mode
}
domain.HostWebsite.Set(false)
if tlsMode == "self-signed" {
domain.ManagedTLS.Set(false)
domain.OwnManagedTLS.Set(false)
domain.SelfSignedTLS.Set(true)
} else {
// default to managed
domain.ManagedTLS.Set(true)
domain.OwnManagedTLS.Set(false)
domain.SelfSignedTLS.Set(false)
}
pageContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Errorw("failed to create page content",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err))
}
domain.PageContent.Set(*pageContent)
pageNotFoundContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Errorw("failed to create page not found content",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err))
}
domain.PageNotFoundContent.Set(*pageNotFoundContent)
redirectURL, err := vo.NewOptionalString1024("")
if err != nil {
m.Logger.Errorw("failed to create redirect URL",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err))
}
domain.RedirectURL.Set(*redirectURL)
if companyID != nil {
domain.CompanyID.Set(*companyID)
}
_, err = m.DomainService.CreateProxyDomain(ctx, session, domain)
if err != nil {
m.Logger.Errorw("failed to create proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return errs.NewCustomError(fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err))
}
createdDomains = append(createdDomains, domainConfig.To)
m.Logger.Debugw("created proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
)
}
m.Logger.Infow("successfully created all proxy domains",
"proxyID", proxyID.String(),
"domainsCreated", len(createdDomains),
"domains", createdDomains,
)
return nil
}
// rollbackCreatedDomains attempts to delete domains that were created during a failed proxy creation
func (m *Proxy) rollbackCreatedDomains(ctx context.Context, session *model.Session, createdDomains []string) {
for _, domainName := range createdDomains {
phishingDomainVO, err := vo.NewString255(domainName)
if err != nil {
m.Logger.Warnw("failed to create domain VO for rollback",
"domain", domainName,
"error", err,
)
continue
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil {
m.Logger.Warnw("failed to get domain for rollback",
"domain", domainName,
"error", err,
)
continue
}
if existingDomain != nil && existingDomain.Type.MustGet().String() == "proxy" {
domainID, err := existingDomain.ID.Get()
if err == nil {
err = m.DomainService.DeleteProxyDomain(ctx, session, &domainID)
if err != nil {
m.Logger.Warnw("failed to rollback proxy domain",
"domain", domainName,
"error", err,
)
} else {
m.Logger.Debugw("rolled back proxy domain",
"domain", domainName,
)
}
}
}
}
}
// syncProxyDomains synchronizes domains for the proxy based on the configuration
func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return errs.NewCustomError(fmt.Errorf("failed to get proxy config for sync: %w", err))
}
// get current proxy domains by proxy ID
currentDomainsResult, err := m.DomainService.GetByProxyID(ctx, session, proxyID)
if err != nil {
return fmt.Errorf("failed to get current proxy domains: %w", err)
}
// collect specific errors for better reporting
var syncErrors []string
currentDomains := make(map[string]*model.Domain)
for _, domain := range currentDomainsResult.Rows {
currentDomains[domain.Name.MustGet().String()] = domain
}
m.Logger.Debugw("found existing proxy domains for sync",
"proxyID", proxyID.String(),
"currentDomainCount", len(currentDomains),
"currentDomains", func() []string {
domains := make([]string, 0, len(currentDomains))
for name := range currentDomains {
domains = append(domains, name)
}
return domains
}(),
)
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML for sync: %w", err))
}
// set default values
m.setProxyConfigDefaults(&config)
// get desired domains from config
desiredDomains := make(map[string]string) // phishing domain -> original domain
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
continue
}
if domainConfig.To != "" {
desiredDomains[domainConfig.To] = originalDomain
}
}
m.Logger.Debugw("parsed desired domains from config",
"proxyID", proxyID.String(),
"desiredDomainCount", len(desiredDomains),
"desiredDomains", func() []string {
domains := make([]string, 0, len(desiredDomains))
for name := range desiredDomains {
domains = append(domains, name)
}
return domains
}(),
)
// delete domains that are no longer needed
deletedCount := 0
for phishingDomain, domain := range currentDomains {
if _, exists := desiredDomains[phishingDomain]; !exists {
m.Logger.Debugw("domain marked for deletion",
"proxyID", proxyID.String(),
"domain", phishingDomain,
)
domainID, err := domain.ID.Get()
if err == nil {
err = m.DomainService.DeleteProxyDomain(ctx, session, &domainID)
if err != nil {
m.Logger.Warnw("failed to delete removed proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
} else {
m.Logger.Infow("deleted removed proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
)
deletedCount++
}
} else {
m.Logger.Warnw("failed to get domain ID for deletion",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
}
} else {
m.Logger.Debugw("domain still needed, keeping",
"proxyID", proxyID.String(),
"domain", phishingDomain,
)
}
}
// create or update domains that are needed
createdCount := 0
updatedCount := 0
errorCount := 0
for phishingDomain, originalDomain := range desiredDomains {
if existingDomain, exists := currentDomains[phishingDomain]; exists {
// domain already exists, check if target domain needs updating
needsUpdate := false
currentTarget, err := existingDomain.ProxyTargetDomain.Get()
if err != nil || currentTarget.String() != originalDomain {
// update the target domain
proxyTargetDomain, err := vo.NewOptionalString255(originalDomain)
if err == nil {
existingDomain.ProxyTargetDomain.Set(*proxyTargetDomain)
needsUpdate = true
} else {
m.Logger.Warnw("failed to create proxy target domain for update",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
errorCount++
continue
}
}
// check if TLS configuration needs updating
tlsMode := "managed" // default
if hostConfig, exists := config.Hosts[originalDomain]; exists && hostConfig != nil && hostConfig.TLS != nil && hostConfig.TLS.Mode != "" {
tlsMode = hostConfig.TLS.Mode
} else if config.Global != nil && config.Global.TLS != nil && config.Global.TLS.Mode != "" {
tlsMode = config.Global.TLS.Mode
}
// check current TLS settings
currentManagedTLS := existingDomain.ManagedTLS.MustGet()
currentSelfSignedTLS := existingDomain.SelfSignedTLS.MustGet()
// determine if TLS settings need updating
if tlsMode == "self-signed" && !currentSelfSignedTLS {
existingDomain.ManagedTLS.Set(false)
existingDomain.OwnManagedTLS.Set(false)
existingDomain.SelfSignedTLS.Set(true)
needsUpdate = true
} else if tlsMode == "managed" && !currentManagedTLS {
existingDomain.ManagedTLS.Set(true)
existingDomain.OwnManagedTLS.Set(false)
existingDomain.SelfSignedTLS.Set(false)
needsUpdate = true
}
if needsUpdate {
domainID, err := existingDomain.ID.Get()
if err == nil {
err = m.DomainService.UpdateProxyDomain(ctx, session, &domainID, existingDomain)
if err != nil {
m.Logger.Warnw("failed to update existing proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
errorCount++
} else {
m.Logger.Debugw("updated existing proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
)
updatedCount++
}
} else {
m.Logger.Warnw("failed to get domain ID for update",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
errorCount++
}
}
} else {
// create new domain
domain := &model.Domain{}
domain.Name.Set(*vo.NewString255Must(phishingDomain))
domain.Type.Set(*vo.NewString32Must("proxy"))
domain.ProxyID.Set(*proxyID)
proxyTargetDomain, err := vo.NewOptionalString255(originalDomain)
if err != nil {
m.Logger.Warnw("failed to create proxy target domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"originalDomain", originalDomain,
"error", err,
)
errorCount++
continue
}
domain.ProxyTargetDomain.Set(*proxyTargetDomain)
// determine TLS mode from config (check domain-specific first, then global, then default to managed)
tlsMode := "managed" // default
if hostConfig, exists := config.Hosts[originalDomain]; exists && hostConfig != nil && hostConfig.TLS != nil && hostConfig.TLS.Mode != "" {
tlsMode = hostConfig.TLS.Mode
} else if config.Global != nil && config.Global.TLS != nil && config.Global.TLS.Mode != "" {
tlsMode = config.Global.TLS.Mode
}
domain.HostWebsite.Set(false)
if tlsMode == "self-signed" {
domain.ManagedTLS.Set(false)
domain.OwnManagedTLS.Set(false)
domain.SelfSignedTLS.Set(true)
} else {
// default to managed
domain.ManagedTLS.Set(true)
domain.OwnManagedTLS.Set(false)
domain.SelfSignedTLS.Set(false)
}
pageContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Warnw("failed to create page content for proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
errorCount++
continue
}
domain.PageContent.Set(*pageContent)
pageNotFoundContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Warnw("failed to create page not found content for proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
errorCount++
continue
}
domain.PageNotFoundContent.Set(*pageNotFoundContent)
redirectURL, err := vo.NewOptionalString1024("")
if err != nil {
errMsg := fmt.Sprintf("failed to create redirect URL for domain '%s': %v", phishingDomain, err)
m.Logger.Warnw("failed to create redirect URL for proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
syncErrors = append(syncErrors, errMsg)
errorCount++
continue
}
domain.RedirectURL.Set(*redirectURL)
var companyID *uuid.UUID
if cid, err := proxy.CompanyID.Get(); err == nil {
companyID = &cid
domain.CompanyID.Set(*companyID)
}
_, err = m.DomainService.CreateProxyDomain(ctx, session, domain)
if err != nil {
errMsg := fmt.Sprintf("failed to create domain '%s': %v", phishingDomain, err)
m.Logger.Warnw("failed to create new proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
"error", err,
)
syncErrors = append(syncErrors, errMsg)
errorCount++
} else {
m.Logger.Debugw("created new proxy domain",
"proxyID", proxyID.String(),
"domain", phishingDomain,
)
createdCount++
}
}
}
m.Logger.Infow("completed proxy domain synchronization",
"proxyID", proxyID.String(),
"domainsDeleted", deletedCount,
"domainsCreated", createdCount,
"domainsUpdated", updatedCount,
"errors", errorCount,
)
if errorCount > 0 {
errorDetails := strings.Join(syncErrors, "; ")
return errs.NewCustomError(fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails))
}
return nil
}
// DeleteByID deletes a proxy by ID
func (m *Proxy) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Proxy.DeleteByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return err
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get current proxy before deletion to access its domains
current, err := m.ProxyRepository.GetByID(ctx, id, &repository.ProxyOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Errorw("failed to get proxy for domain cleanup", "error", err)
return err
}
// delete associated proxy domains
if current != nil {
err = m.deleteProxyDomains(ctx, session, id, current)
if err != nil {
m.Logger.Errorw("failed to delete proxy domains", "error", err)
// continue with proxy deletion even if domain cleanup fails
}
}
// remove the relation from campaign templates
err = m.CampaignTemplateService.RemoveProxiesByProxyID(
ctx,
session,
id,
)
if err != nil {
m.Logger.Errorw("failed to remove proxy ID relations from campaign templates", "error", err)
return err
}
// delete proxy
err = m.ProxyRepository.DeleteByID(
ctx,
id,
)
if err != nil {
m.Logger.Errorw("failed to delete proxy by ID", "error", err)
return err
}
m.AuditLogAuthorized(ae)
return nil
}