Files
phishingclub/backend/service/proxy.go
T
Ronni Skansing 6261848b5b clean out dead code path
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-05-28 09:18:01 +02:00

2754 lines
86 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"`
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
}