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"` Variables *ProxyServiceVariablesConfig `yaml:"variables,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 // - "custom": Use a manually supplied certificate (key + pem) // // 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" | "custom" } // 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) } // ProxyServiceVariablesConfig represents template variable interpolation configuration // when enabled, template variables like {{.Email}} in rewrite rules will be replaced with recipient data // // available variables: // - recipient fields: rID, FirstName, LastName, Email, To, Phone, ExtraIdentifier, Position, Department, City, Country, Misc // - sender fields: From, FromName, FromEmail, Subject // - general fields: BaseURL, URL // - custom fields: CustomField1, CustomField2, CustomField3, CustomField4 // // example usage: // // global: // variables: // enabled: true // allowed: ["Email", "FirstName", "LastName"] # optional: restrict to specific variables // rewrite: // - find: "PLACEHOLDER_EMAIL" // replace: "{{.Email}}" type ProxyServiceVariablesConfig struct { Enabled bool `yaml:"enabled"` // enable template variable interpolation in rewrite rules Allowed []string `yaml:"allowed,omitempty"` // if empty and enabled, all variables are allowed; if set, only these variables can be used } // ValidProxyVariables defines all valid template variable names that can be used in proxy rewrite rules var ValidProxyVariables = map[string]bool{ // recipient fields "rID": true, "FirstName": true, "LastName": true, "Email": true, "To": true, "Phone": true, "ExtraIdentifier": true, "Position": true, "Department": true, "City": true, "Country": true, "Misc": true, // sender fields "From": true, "FromName": true, "FromEmail": true, "Subject": true, // general fields "BaseURL": true, "URL": true, // custom fields "CustomField1": true, "CustomField2": true, "CustomField3": true, "CustomField4": true, } // GetValidProxyVariableNames returns a slice of all valid proxy variable names func GetValidProxyVariableNames() []string { names := make([]string, 0, len(ValidProxyVariables)) for name := range ValidProxyVariables { names = append(names, name) } return names } // 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, response, and rewrite 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 global rewrite rule patterns if config.Global != nil && config.Global.Rewrite != nil { for i := range config.Global.Rewrite { if err := compileRewritePath(&config.Global.Rewrite[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 } } } } // Compile host-specific rewrite rule patterns for _, hostConfig := range config.Hosts { if hostConfig != nil && hostConfig.Rewrite != nil { for i := range hostConfig.Rewrite { if err := compileRewritePath(&hostConfig.Rewrite[i]); err != nil { return err } } } } return nil } // compileRewritePath compiles the path pattern for a rewrite rule func compileRewritePath(rule *ProxyServiceReplaceRule) error { if rule.Path != "" { pathRe, err := regexp.Compile(rule.Path) if err != nil { return fmt.Errorf("invalid regex pattern for rewrite path '%s': %w", rule.Path, err) } rule.PathRe = pathRe } 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"` Event string `yaml:"event,omitempty"` // "submit" (default) or "info" 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), "dom", or "header" Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine) Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions) Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4" From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths Method string `yaml:"method,omitempty"` // restrict rule to this HTTP method (e.g. GET, POST) PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching } // 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) } // 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) } // copy transient cert fields — not persisted to db, but needed by syncProxyDomains if v, err := proxy.GlobalTLSKey.Get(); err == nil { current.GlobalTLSKey.Set(v) } if v, err := proxy.GlobalTLSPem.Get(); err == nil { current.GlobalTLSPem.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 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 rewrite rules if err := m.validateReplaceRules(domainConfig.Rewrite); err != nil { return err } // validate that the phishing domain is not owned by a different proxy if err := m.validatePhishingDomainOwnership(ctx, domainConfig.To, proxyID); 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 } } // validate custom TLS domains have a cert — either a new one is supplied or the domain already has one newCertProvided := false if globalKey, keyErr := proxy.GlobalTLSKey.Get(); keyErr == nil && len(globalKey) > 0 { if globalPem, pemErr := proxy.GlobalTLSPem.Get(); pemErr == nil && len(globalPem) > 0 { newCertProvided = true } } if !newCertProvided { // resolve the effective TLS mode for each host and check if any require a cert we don't have for _, domainConfig := range config.Hosts { if domainConfig == nil { continue } // determine effective TLS mode for this host effectiveTLS := "" if domainConfig.TLS != nil && domainConfig.TLS.Mode != "" { effectiveTLS = domainConfig.TLS.Mode } else if config.Global != nil && config.Global.TLS != nil { effectiveTLS = config.Global.TLS.Mode } if effectiveTLS != "custom" { continue } // check if the phishing domain already has a custom cert in the db phishingDomain, err := vo.NewString255(domainConfig.To) if err != nil { continue } existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomain, &repository.DomainOption{}) if err != nil || existingDomain == nil { // new domain — no existing cert possible return validate.WrapErrorWithField( fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To), "proxyConfig", ) } if !existingDomain.OwnManagedTLS.MustGet() { // existing domain but no custom cert yet return validate.WrapErrorWithField( fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To), "proxyConfig", ) } } } 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", ) } } } // validate 'event' field if specified if capture.Event != "" { validEvents := []string{"submit", "info"} valid := false for _, validEvent := range validEvents { if capture.Event == validEvent { valid = true break } } if !valid { return validate.WrapErrorWithField( errors.New("invalid 'event' value in capture rule, must be one of: "+strings.Join(validEvents, ", ")), "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" } else if rule.Engine == "header" { // for header engine, remove dom/regex-specific fields rule.Target = "" // set default action to 'set' if not specified if rule.Action == "" { rule.Action = "set" } // set default 'from' to 'response_header' if not specified if rule.From == "" { rule.From = "response_header" } } } // 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" && engine != "header" { return validate.WrapErrorWithField( errors.New("invalid 'engine' value in replace rule, must be one of: regex, dom, header"), "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 // removeAttr needs the attribute name to remove in the replace field if (replace.Action == "setText" || replace.Action == "setHtml" || replace.Action == "setAttr" || replace.Action == "removeAttr" || 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) } else if engine == "header" { if replace.Find == "" { return validate.WrapErrorWithField(errors.New("replace rule 'find' (header name) is required for header engine"), "proxyConfig") } // validate header action validActions := []string{"set", "add", "remove"} validAction := false for _, action := range validActions { if replace.Action == action || replace.Action == "" { validAction = true break } } if !validAction { return validate.WrapErrorWithField( errors.New("invalid 'action' value for header engine, must be one of: "+strings.Join(validActions, ", ")), "proxyConfig", ) } // 'replace' (new value) is required for set and add, not for remove effectiveAction := replace.Action if effectiveAction == "" { effectiveAction = "set" } if (effectiveAction == "set" || effectiveAction == "add") && replace.Replace == "" { return validate.WrapErrorWithField( errors.New("replace rule 'replace' (new value) is required for header engine action '"+effectiveAction+"'"), "proxyConfig", ) } // validate that 'from' is a header context when explicitly set if replace.From != "" && replace.From != "request_header" && replace.From != "response_header" && replace.From != "any" { return validate.WrapErrorWithField( errors.New("header engine 'from' must be one of: request_header, response_header, any"), "proxyConfig", ) } } 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" && tlsConfig.Mode != "custom" { return validate.WrapErrorWithField( errors.New("tls.mode must be either 'managed', 'self-signed', or 'custom'"), "proxyConfig", ) } return nil } // validateVariablesConfig validates the variables configuration func (m *Proxy) validateVariablesConfig(variablesConfig *ProxyServiceVariablesConfig) error { if variablesConfig == nil { return nil } // validate allowed variable names if specified if len(variablesConfig.Allowed) > 0 { for _, varName := range variablesConfig.Allowed { if !ValidProxyVariables[varName] { return validate.WrapErrorWithField( fmt.Errorf("invalid variable name '%s' in allowed list. valid variables: %v", varName, GetValidProxyVariableNames()), "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 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.validateVariablesConfig(config.Global.Variables); 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 } // validatePhishingDomainOwnership checks that a phishing domain is either unclaimed or owned by the given proxy. // Used during updates where the proxy already has an ID. func (m *Proxy) validatePhishingDomainOwnership(ctx context.Context, phishingDomain string, proxyID *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 { return nil } if existingDomain.Type.MustGet().String() != "proxy" { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)), "proxyConfig", ) } existingProxyID, err := existingDomain.ProxyID.Get() if err != nil { return nil // no owner — allow } if existingProxyID == *proxyID { return nil // owned by this proxy — allow } return validate.WrapErrorWithField( errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)), "proxyConfig", ) } // validatePhishingDomainUniquenessByStartURL checks if a phishing domain is already claimed by another proxy. // Uses proxy_id for owned domains; falls back to target domain comparison for legacy rows with no proxy_id. 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 { return nil } if existingDomain.Type.MustGet().String() != "proxy" { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)), "proxyConfig", ) } // proxy_id is set — domain is owned by another proxy if _, err := existingDomain.ProxyID.Get(); err == nil { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)), "proxyConfig", ) } // proxy_id is null (legacy row) — fall back to target domain comparison existingTarget, err := existingDomain.ProxyTargetDomain.Get() if err != nil { return nil } currentStartURLParsed, err := url.Parse(currentStartURL) if err != nil { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("invalid start URL format: %s", currentStartURL)), "proxyConfig", ) } existingTargetStr := strings.ToLower(strings.TrimSpace(existingTarget.String())) if strings.Contains(existingTargetStr, "://") { if parsed, err := url.Parse(existingTargetStr); err == nil { existingTargetStr = strings.ToLower(strings.TrimSpace(parsed.Host)) } } if existingTargetStr != strings.ToLower(strings.TrimSpace(currentStartURLParsed.Host)) { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", 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 } 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, ) m.rollbackCreatedDomains(ctx, session, createdDomains) return errs.NewCustomError(fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain)) } // 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, "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 if tlsMode == "custom" { // check if a global cert is provided on the proxy model globalKey, keyErr := proxy.GlobalTLSKey.Get() globalPem, pemErr := proxy.GlobalTLSPem.Get() if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 { // apply the global cert to this domain domain.ManagedTLS.Set(false) domain.OwnManagedTLS.Set(true) domain.SelfSignedTLS.Set(false) domain.OwnManagedTLSKey.Set(globalKey) domain.OwnManagedTLSPem.Set(globalPem) } else { // no cert provided for a new domain — cannot configure custom TLS without a certificate m.Logger.Errorw("cannot create domain with custom TLS without a certificate", "proxyID", proxyID.String(), "domain", domainConfig.To, ) m.rollbackCreatedDomains(ctx, session, createdDomains) return errs.NewValidationError( fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To), ) } } else { // default to managed domain.ManagedTLS.Set(true) domain.OwnManagedTLS.Set(false) domain.SelfSignedTLS.Set(false) } domain.PageContent.Set(*vo.NewOptionalString1MBMust("")) domain.PageNotFoundContent.Set(*vo.NewOptionalString1MBMust("")) domain.RedirectURL.Set(*vo.NewOptionalString1024Must("")) 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() currentOwnManagedTLS := existingDomain.OwnManagedTLS.MustGet() // determine if TLS settings need updating if tlsMode == "self-signed" && !currentSelfSignedTLS { // switching to self-signed — clear any existing custom cert flag so updateDomain cleans it up existingDomain.ManagedTLS.Set(false) existingDomain.OwnManagedTLS.Set(false) existingDomain.SelfSignedTLS.Set(true) needsUpdate = true } else if tlsMode == "managed" && !currentManagedTLS { // switching to managed — clear any existing custom cert flag so updateDomain cleans it up existingDomain.ManagedTLS.Set(true) existingDomain.OwnManagedTLS.Set(false) existingDomain.SelfSignedTLS.Set(false) needsUpdate = true } else if tlsMode == "custom" { // check if a new global cert is being pushed globalKey, keyErr := proxy.GlobalTLSKey.Get() globalPem, pemErr := proxy.GlobalTLSPem.Get() if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 { // apply the new global cert — always update when a cert is explicitly provided existingDomain.ManagedTLS.Set(false) existingDomain.OwnManagedTLS.Set(true) existingDomain.SelfSignedTLS.Set(false) existingDomain.OwnManagedTLSKey.Set(globalKey) existingDomain.OwnManagedTLSPem.Set(globalPem) needsUpdate = true } else if !currentOwnManagedTLS { // no new cert provided and domain doesn't already have a custom cert — cannot switch to custom m.Logger.Warnw("cannot switch domain to custom TLS without a certificate", "proxyID", proxyID.String(), "domain", phishingDomain, ) syncErrors = append(syncErrors, fmt.Sprintf("cannot switch domain '%s' to custom TLS without providing a certificate", phishingDomain)) errorCount++ continue } // if currentOwnManagedTLS is true and no new cert provided — preserve existing cert, no update needed } 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 if tlsMode == "custom" { // check if a global cert is provided on the proxy model globalKey, keyErr := proxy.GlobalTLSKey.Get() globalPem, pemErr := proxy.GlobalTLSPem.Get() if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 { // apply the global cert to this domain domain.ManagedTLS.Set(false) domain.OwnManagedTLS.Set(true) domain.SelfSignedTLS.Set(false) domain.OwnManagedTLSKey.Set(globalKey) domain.OwnManagedTLSPem.Set(globalPem) } else { // no cert provided for a new domain — cannot configure custom TLS without a certificate m.Logger.Warnw("cannot add domain with custom TLS without a certificate", "proxyID", proxyID.String(), "domain", phishingDomain, ) syncErrors = append(syncErrors, fmt.Sprintf("cannot add domain '%s' with custom TLS without providing a certificate", phishingDomain)) errorCount++ continue } } 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 }