Files
phishingclub/backend/service/proxy.go
T
Ronni Skansing 93708cef17 Access control for proxy
Vim suggestions for proxy yaml

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-10-07 19:14:38 +02:00

1928 lines
57 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
}
// 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"`
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
}
// ProxyServiceRules represents capture and replace rules
// ProxyServiceRules represents global rules that apply to all hosts
type ProxyServiceRules struct {
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
}
// ProxyServiceAccessControl represents access control configuration
type ProxyServiceAccessControl struct {
Mode string `yaml:"mode"` // "allow" | "deny"
Paths []string `yaml:"paths"`
OnDeny ProxyServiceDenyResponse `yaml:"on_deny"`
}
// ProxyServiceDenyResponse represents response configuration when access is denied
type ProxyServiceDenyResponse struct {
WithSession string `yaml:"with_session"` // "allow" | "redirect:URL" | status code
WithoutSession string `yaml:"without_session"` // "allow" | "redirect:URL" | status code
}
// CompilePathPatterns compiles regex patterns for all capture rules
func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
// Compile global capture rule patterns
if config.Global != nil {
for i := range config.Global.Capture {
if err := compileCapturePath(&config.Global.Capture[i]); err != nil {
return err
}
}
}
// Compile host-specific capture rule patterns
for _, hostConfig := range config.Hosts {
if hostConfig != nil {
for i := range hostConfig.Capture {
if err := compileCapturePath(&hostConfig.Capture[i]); err != nil {
return err
}
}
}
}
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 string `yaml:"find,omitempty"`
From string `yaml:"from,omitempty"`
Required *bool `yaml:"required,omitempty"`
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
}
// ProxyServiceReplaceRule represents a replacement rule
type ProxyServiceReplaceRule struct {
Name string `yaml:"name,omitempty"`
Find string `yaml:"find"`
Replace string `yaml:"replace"`
From string `yaml:"from,omitempty"`
}
// ProxyServiceConfigYAML represents the complete YAML configuration structure that matches the actual YAML format
//
// Example YAML configuration with access control:
//
// version: "0.0"
// global:
//
// access:
// mode: "deny"
// paths:
// - "^/admin/"
// - "^/wp-admin/"
// - "^/\\.git/"
// on_deny:
// with_session: 403
// without_session: 404
// capture:
// - name: "global_navigation"
// path: "/important"
//
// example.com:
//
// to: "phishing-example.com"
// access:
// mode: "allow"
// paths:
// - "^/login"
// - "^/api/public/"
// - "^/assets/"
// on_deny:
// with_session: "redirect:https://phishing-example.com/"
// without_session: 503
// capture:
// - name: "login_capture"
// method: "POST"
// path: "/login"
// find: "password=(.*?)&"
// from: "request_body"
type ProxyServiceConfigYAML struct {
Version string `yaml:"version,omitempty"`
Proxy string `yaml:"proxy,omitempty"`
Global *ProxyServiceRules `yaml:"global,omitempty"`
Hosts map[string]*ProxyServiceDomainConfig `yaml:",inline"` // inline allows domain names as top-level keys
}
// ValidateVersion validates that the version is supported
func ValidateVersion(config *ProxyServiceConfigYAML) error {
if config.Version != "0.0" {
return errors.New("only version 0.0 is supported")
}
return nil
}
// Create creates a new Proxy
func (m *Proxy) Create(
ctx context.Context,
session *model.Session,
proxy *model.Proxy,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Proxy.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
var companyID *uuid.UUID
if cid, err := proxy.CompanyID.Get(); err == nil {
companyID = &cid
}
// validate data
if err := proxy.Validate(); err != nil {
m.Logger.Errorw("failed to validate proxy", "error", err)
return nil, errs.Wrap(err)
}
// validate Proxy configuration
if err := m.validateProxyConfig(ctx, proxy); err != nil {
return nil, err
}
// check uniqueness
name := proxy.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.ProxyRepository.DB,
"proxies",
name.String(),
companyID,
nil,
)
if err != nil {
m.Logger.Errorw("failed to check proxy uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
m.Logger.Debugw("proxy name is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// create proxy
id, err := m.ProxyRepository.Insert(
ctx,
proxy,
)
if err != nil {
m.Logger.Errorw("failed to create proxy", "error", err)
return nil, errs.Wrap(err)
}
// create associated domains
err = m.createProxyDomains(ctx, session, id, proxy)
if err != nil {
// rollback proxy creation
m.ProxyRepository.DeleteByID(ctx, id)
m.Logger.Errorw("failed to create proxy domains", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
m.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets proxies
func (m *Proxy) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.ProxyOption,
) (*model.Result[model.Proxy], error) {
result := model.NewEmptyResult[model.Proxy]()
ae := NewAuditEvent("Proxy.GetAll", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = m.ProxyRepository.GetAll(
ctx,
companyID,
options,
)
if err != nil {
m.Logger.Errorw("failed to get proxies", "error", err)
return result, errs.Wrap(err)
}
// no audit log on read
return result, nil
}
// GetAllOverview gets proxies with limited data
func (m *Proxy) GetAllOverview(
companyID *uuid.UUID, // can be null
ctx context.Context,
session *model.Session,
queryArgs *vo.QueryArgs,
) (*model.Result[model.ProxyOverview], error) {
result := model.NewEmptyResult[model.ProxyOverview]()
ae := NewAuditEvent("Proxy.GetAllOverview", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get proxies
result, err = m.ProxyRepository.GetAllSubset(
ctx,
companyID,
&repository.ProxyOption{
QueryArgs: queryArgs,
},
)
if err != nil {
m.Logger.Errorw("failed to get proxies subset", "error", err)
return result, errs.Wrap(err)
}
// no audit log on read
return result, nil
}
// GetByID gets a Proxy by ID
func (m *Proxy) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.ProxyOption,
) (*model.Proxy, error) {
ae := NewAuditEvent("Proxy.GetByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get proxy
proxy, err := m.ProxyRepository.GetByID(
ctx,
id,
options,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
// return early this is not a an error
return nil, errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to get proxy by ID", "error", err)
return nil, errs.Wrap(err)
}
// apply defaults to Proxy configuration for display
if err := m.applyConfigurationDefaults(proxy); err != nil {
m.Logger.Errorw("failed to apply configuration defaults", "error", err)
// don't fail the request, just log the error
}
// no audit log on read
return proxy, nil
}
// UpdateByID updates a Proxy by ID
func (m *Proxy) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
proxy *model.Proxy,
) error {
ae := NewAuditEvent("Proxy.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return err
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get current
current, err := m.ProxyRepository.GetByID(
ctx,
id,
&repository.ProxyOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Debugw("failed to update proxy by ID", "error", err)
return err
}
if err != nil {
m.Logger.Errorw("failed to update proxy by ID", "error", err)
return err
}
// update proxy - if a field is present and not null, update it
if v, err := proxy.Name.Get(); err == nil {
// check uniqueness
var companyID *uuid.UUID
if cid, err := current.CompanyID.Get(); err == nil {
companyID = &cid
}
name := proxy.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.ProxyRepository.DB,
"proxies",
name.String(),
companyID,
id,
)
if err != nil {
m.Logger.Errorw("failed to check proxy uniqueness", "error", err)
return err
}
if !isOK {
m.Logger.Debugw("proxy name is already taken", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(v)
}
if v, err := proxy.Description.Get(); err == nil {
current.Description.Set(v)
}
if v, err := proxy.StartURL.Get(); err == nil {
current.StartURL.Set(v)
}
if v, err := proxy.ProxyConfig.Get(); err == nil {
current.ProxyConfig.Set(v)
}
// validate updated Proxy configuration
if err := m.validateProxyConfigForUpdate(ctx, current, id); err != nil {
return err
}
// update proxy
err = m.ProxyRepository.UpdateByID(
ctx,
id,
current,
)
if err != nil {
m.Logger.Errorw("failed to update proxy by ID", "error", err)
return err
}
// update associated domains
err = m.syncProxyDomains(ctx, session, id, current)
if err != nil {
m.Logger.Errorw("failed to sync proxy domains", "error", err)
return err
}
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)
// validate version (after defaults are applied)
if err := ValidateVersion(&config); err != nil {
return validate.WrapErrorWithField(err, "proxyConfig")
}
// validate that at least one domain mapping exists
if len(config.Hosts) == 0 {
return validate.WrapErrorWithField(errors.New("at least one domain mapping must be specified"), "proxyConfig")
}
// validate global uniqueness of capture names across all domains and global rules
if err := m.validateGlobalCaptureNameUniqueness(&config); err != nil {
return err
}
// ensure that the start URL domain is mentioned in the domain mappings
startURL, err := proxy.StartURL.Get()
if err == nil {
startURLStr := startURL.String()
var startDomain string
// extract domain from start URL
if strings.Contains(startURLStr, "://") {
// full URL like https://auth.example.com/login
parts := strings.Split(startURLStr, "://")
if len(parts) > 1 {
domainParts := strings.Split(parts[1], "/")
startDomain = domainParts[0]
}
} else if strings.Contains(startURLStr, "/") {
// domain/path format like auth.example.com/login
parts := strings.Split(startURLStr, "/")
startDomain = parts[0]
} else {
// just domain like auth.example.com
startDomain = startURLStr
}
// check if start domain is in the domain mappings
if startDomain != "" {
found := false
for originalDomain := range config.Hosts {
if originalDomain == startDomain {
found = true
break
}
}
if !found {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("start URL domain '%s' must be included in domain mappings", startDomain)),
"proxyConfig",
)
}
}
}
// validate each domain mapping
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain config for '%s' is nil", originalDomain)),
"proxyConfig",
)
}
// validate that 'to' is specified
if domainConfig.To == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("'to' field is required for domain '%s'", originalDomain)),
"proxyConfig",
)
}
// validate domain-specific access control
if err := m.validateAccessControl(domainConfig.Access); err != nil {
return err
}
// validate domain-specific capture rules
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
return err
}
// validate domain-specific rewrite rules
if err := m.validateReplaceRules(domainConfig.Rewrite); err != nil {
return err
}
// note: domain uniqueness validation is skipped during updates
// the syncProxyDomains method will handle domain management properly
}
// validate global rules
if config.Global != nil {
if err := m.validateAccessControl(config.Global.Access); err != nil {
return err
}
if err := m.validateCaptureRules(config.Global.Capture); err != nil {
return err
}
if err := m.validateReplaceRules(config.Global.Rewrite); err != nil {
return err
}
}
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")
}
// allow empty find pattern for any method path-based navigation tracking
isNavigationTracking := capture.Path != "" && capture.Find == ""
if capture.Find == "" && !isNavigationTracking {
return validate.WrapErrorWithField(
errors.New("capture rule must have a find pattern, except for path-based navigation tracking"),
"proxyConfig",
)
}
if capture.Find != "" {
// for cookie captures, find field contains cookie name (literal string)
// for other captures, find field contains regex pattern
if capture.From != "cookie" {
if _, err := regexp.Compile(capture.Find); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in capture rule: "+err.Error()),
"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
if capture.From == "cookie" {
if capture.Find == "" {
return validate.WrapErrorWithField(
errors.New("capture rule with from='cookie' must specify cookie name in 'find' field"),
"proxyConfig",
)
}
// validate cookie name format (basic validation)
cookieName := capture.Find
if len(cookieName) == 0 {
return validate.WrapErrorWithField(
errors.New("cookie name cannot be empty"),
"proxyConfig",
)
}
// cookie names cannot contain certain characters
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",
)
}
}
// 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
}
// 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"
}
for domain, domainConfig := range config.Hosts {
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"
}
}
config.Hosts[domain] = domainConfig
}
// set defaults for global capture rules
if config.Global != 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"
}
}
}
}
// validateReplaceRules validates a slice of replace rules
func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) error {
for _, replace := range replaceRules {
if replace.Find == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'find' is required"), "proxyConfig")
}
if _, err := regexp.Compile(replace.Find); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in replace rule 'find': "+err.Error()),
"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) validateAccessControl(accessControl *ProxyServiceAccessControl) error {
if accessControl == nil {
return nil // access control is optional
}
// validate mode
if accessControl.Mode != "allow" && accessControl.Mode != "deny" {
return validate.WrapErrorWithField(
errors.New("access control mode must be either 'allow' or 'deny'"),
"proxyConfig",
)
}
// validate paths are valid regex patterns
for i, path := range accessControl.Paths {
if path == "" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("access control path %d cannot be empty", i)),
"proxyConfig",
)
}
if _, err := regexp.Compile(path); err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid regex pattern in access control path '%s': %s", path, err.Error())),
"proxyConfig",
)
}
}
// validate deny response actions
if err := m.validateDenyAction(accessControl.OnDeny.WithSession, true); err != nil {
return err
}
if err := m.validateDenyAction(accessControl.OnDeny.WithoutSession, false); err != nil {
return err
}
return nil
}
// validateDenyAction validates a deny action string
func (m *Proxy) validateDenyAction(action string, withSession bool) error {
if action == "" {
return nil // action is optional, will use default
}
// check for allow action
if action == "allow" {
return nil
}
// check for redirect action
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'"),
"proxyConfig",
)
}
// basic URL validation
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("invalid deny action: must be 'allow', 'redirect:URL', or a status code"),
"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)
// 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 mapping '%s'", originalDomain)),
"proxyConfig",
)
}
// validate that phishing domain doesn't already exist (unless it's managed by this proxy)
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", domainConfig.To)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
// check if this domain is managed by a different proxy or is a regular domain
if existingDomain.Type.MustGet().String() != "proxy" {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", domainConfig.To)),
"proxyConfig",
)
} else {
// it's a proxy domain, check if it belongs to a different proxy
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil {
startURL, err := proxy.StartURL.Get()
if err == nil {
// extract domain from start URL for comparison
startURLParsed, err := url.Parse(startURL.String())
if err == nil && existingTarget.String() != startURLParsed.Host {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", domainConfig.To)),
"proxyConfig",
)
}
}
}
}
}
// validate domain-specific 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 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.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
}
}
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 {
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 {
for _, capture := range config.Global.Capture {
if capture.Name == "" {
continue // this will be caught by other validation
}
if existingLocation, exists := allCaptureNames[capture.Name]; exists {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("duplicate capture rule name '%s' found in global rules - already used in %s", capture.Name, existingLocation)),
"proxyConfig",
)
}
allCaptureNames[capture.Name] = "global rules"
}
}
return nil
}
// applyConfigurationDefaults applies default values to Proxy configuration for display
func (m *Proxy) applyConfigurationDefaults(proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return err
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return err
}
// apply defaults
m.setProxyConfigDefaults(&config)
// marshal back to YAML
updatedConfigBytes, err := yaml.Marshal(&config)
if err != nil {
return err
}
// update the Proxy configuration with defaults applied
updatedConfigVO := vo.NewString1MBMust(string(updatedConfigBytes))
proxy.ProxyConfig = nullable.NewNullableWithValue(*updatedConfigVO)
return nil
}
// deleteProxyDomains deletes all domains associated with a proxy
func (m *Proxy) deleteProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return err
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return err
}
// set default values
m.setProxyConfigDefaults(&config)
// delete domains for each mapping
for _, domainConfig := range config.Hosts {
if domainConfig == nil {
continue
}
// get domain by name and delete if it's a proxy domain
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
continue
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err == nil && existingDomain != nil {
// delete old domains that have proxy type
if existingDomain.Type.MustGet().String() == "proxy" {
domainID, err := existingDomain.ID.Get()
if err == nil {
err = m.DomainService.DeleteProxyDomain(ctx, session, &domainID)
if err != nil {
m.Logger.Warnw("failed to delete proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
} else {
m.Logger.Debugw("deleted proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
)
}
}
}
}
}
return nil
}
// validatePhishingDomainUniqueness checks if a phishing domain is already used by another proxy
func (m *Proxy) validatePhishingDomainUniqueness(ctx context.Context, phishingDomain string, excludeProxyID *uuid.UUID) error {
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
// check if this domain is managed by a different proxy
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it's managed by the same proxy we're updating (allowed)
if excludeProxyID != nil {
// this is a bit of a workaround - we'd need to track which proxy owns which domain
// for now, we'll allow updates to existing proxy domains
// TODO: add a proxy_id field to domains table for proper tracking
return nil
}
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another proxy", phishingDomain)),
"proxyConfig",
)
} else {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
}
return nil
}
// validatePhishingDomainUniquenessByStartURL checks if a phishing domain is already used by another proxy using start URL comparison
func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context, phishingDomain string, currentStartURL string) error {
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existingDomain != nil {
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it belongs to a different proxy by comparing target domains
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil {
// extract domain from current start URL for comparison
currentStartURLParsed, err := url.Parse(currentStartURL)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid start URL format: %s", currentStartURL)),
"proxyConfig",
)
}
// normalize and extract domain for comparison
existingTargetStr := strings.ToLower(strings.TrimSpace(existingTarget.String()))
currentHostNormalized := strings.ToLower(strings.TrimSpace(currentStartURLParsed.Host))
// if existing target is a full URL, extract just the host part
var existingTargetNormalized string
if strings.Contains(existingTargetStr, "://") {
// it's a full URL, parse it to get the host
existingTargetParsed, err := url.Parse(existingTargetStr)
if err != nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid existing target URL format: %s", existingTargetStr)),
"proxyConfig",
)
}
existingTargetNormalized = strings.ToLower(strings.TrimSpace(existingTargetParsed.Host))
} else {
// it's already just a domain
existingTargetNormalized = existingTargetStr
}
if existingTargetNormalized != currentHostNormalized {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
"proxyConfig",
)
}
}
} else {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
}
return nil
}
// validatePhishingDomainUniquenessForUpdate validates phishing domain uniqueness during proxy updates
func (m *Proxy) validatePhishingDomainUniquenessForUpdate(ctx context.Context, phishingDomain string, currentStartURL string, currentProxyID *uuid.UUID) error {
m.Logger.Debugw("validating phishing domain uniqueness for update",
"phishingDomain", phishingDomain,
"currentStartURL", currentStartURL,
"currentProxyID", currentProxyID.String(),
)
phishingDomainVO, err := vo.NewString255(phishingDomain)
if err != nil {
m.Logger.Errorw("invalid phishing domain format",
"phishingDomain", phishingDomain,
"error", err,
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid phishing domain format: %s", phishingDomain)),
"proxyConfig",
)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Errorw("error getting existing domain",
"phishingDomain", phishingDomain,
"error", err,
)
return err
}
if existingDomain == nil {
m.Logger.Debugw("no existing domain found, validation passed",
"phishingDomain", phishingDomain,
)
return nil
}
m.Logger.Debugw("existing domain found",
"phishingDomain", phishingDomain,
"existingDomainType", existingDomain.Type.MustGet().String(),
)
if existingDomain.Type.MustGet().String() == "proxy" {
// check if it belongs to a different proxy by comparing target domains
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err != nil {
m.Logger.Errorw("error getting existing domain proxy target",
"phishingDomain", phishingDomain,
"error", err,
)
return err
}
m.Logger.Debugw("existing domain proxy target found",
"phishingDomain", phishingDomain,
"existingTarget", existingTarget.String(),
)
// extract domain from current start URL for comparison
currentStartURLParsed, err := url.Parse(currentStartURL)
if err != nil {
m.Logger.Errorw("error parsing current start URL",
"currentStartURL", currentStartURL,
"error", err,
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("invalid start URL format: %s", currentStartURL)),
"proxyConfig",
)
}
// normalize and extract domain for comparison
existingTargetStr := strings.ToLower(strings.TrimSpace(existingTarget.String()))
currentHostNormalized := strings.ToLower(strings.TrimSpace(currentStartURLParsed.Host))
// if existing target is a full URL, extract just the host part
var existingTargetNormalized string
if strings.Contains(existingTargetStr, "://") {
// it's a full URL, parse it to get the host
existingTargetParsed, err := url.Parse(existingTargetStr)
if err != nil {
m.Logger.Errorw("error parsing existing target URL",
"existingTarget", existingTargetStr,
"error", err,
)
return err
}
existingTargetNormalized = strings.ToLower(strings.TrimSpace(existingTargetParsed.Host))
} else {
// it's already just a domain
existingTargetNormalized = existingTargetStr
}
m.Logger.Debugw("comparing normalized domains",
"phishingDomain", phishingDomain,
"existingTargetStr", existingTargetStr,
"existingTargetNormalized", existingTargetNormalized,
"currentHostNormalized", currentHostNormalized,
)
// if target domains don't match, it belongs to a different proxy
if existingTargetNormalized != currentHostNormalized {
m.Logger.Warnw("phishing domain belongs to different proxy",
"phishingDomain", phishingDomain,
"existingTargetNormalized", existingTargetNormalized,
"currentHostNormalized", currentHostNormalized,
"currentProxyID", currentProxyID.String(),
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration (existing target: %s, current target: %s)", phishingDomain, existingTargetNormalized, currentHostNormalized)),
"proxyConfig",
)
}
// if target domains match, this domain belongs to the current proxy being updated, so it's allowed
m.Logger.Debugw("phishing domain belongs to current proxy, allowing reuse",
"domain", phishingDomain,
"proxyID", currentProxyID.String(),
"existingTarget", existingTargetNormalized,
"currentHost", currentHostNormalized,
)
} else {
m.Logger.Warnw("domain exists as regular domain, not proxy",
"phishingDomain", phishingDomain,
"existingDomainType", existingDomain.Type.MustGet().String(),
)
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("domain '%s' already exists as a regular domain", phishingDomain)),
"proxyConfig",
)
}
return nil
}
// createProxyDomains creates domains for the proxy based on the configuration
func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return 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 fmt.Errorf("failed to parse proxy config YAML: %w", err)
}
// set default values
m.setProxyConfigDefaults(&config)
var companyID *uuid.UUID
if cid, err := proxy.CompanyID.Get(); err == nil {
companyID = &cid
}
startURL := proxy.StartURL.MustGet()
createdDomains := make([]string, 0)
// create domains for each mapping
for originalDomain, domainConfig := range config.Hosts {
if domainConfig == nil {
continue
}
if domainConfig.To == "" {
m.Logger.Warnw("empty 'to' field in domain config",
"proxyID", proxyID.String(),
"originalDomain", originalDomain,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain)
}
// check if domain already exists (might be from previous failed attempt)
phishingDomainVO, err := vo.NewString255(domainConfig.To)
if err != nil {
m.Logger.Warnw("invalid phishing domain format",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err)
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
if err == nil && existingDomain != nil {
// domain already exists, check if it's compatible
if existingDomain.Type.MustGet().String() == "proxy" {
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err == nil && existingTarget.String() == startURL.String() {
// compatible existing domain, skip creation
m.Logger.Debugw("proxy domain already exists, skipping creation",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
)
createdDomains = append(createdDomains, domainConfig.To)
continue
}
}
// incompatible domain exists
m.Logger.Warnw("incompatible domain already exists",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"existingType", existingDomain.Type.MustGet().String(),
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To)
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// database error
m.Logger.Errorw("failed to check existing domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to check existing domain %s: %w", domainConfig.To, err)
}
// create new domain
domain := &model.Domain{}
domain.Name.Set(*vo.NewString255Must(domainConfig.To))
domain.Type.Set(*vo.NewString32Must("proxy"))
domain.ProxyID.Set(*proxyID)
// set the target domain to the original domain from the YAML config
proxyTargetDomain, err := vo.NewOptionalString255(originalDomain)
if err != nil {
m.Logger.Errorw("failed to create proxy target domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"startURL", startURL.String(),
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err)
}
domain.ProxyTargetDomain.Set(*proxyTargetDomain)
domain.HostWebsite.Set(false)
domain.ManagedTLS.Set(true)
domain.OwnManagedTLS.Set(false)
pageContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Errorw("failed to create page content",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err)
}
domain.PageContent.Set(*pageContent)
pageNotFoundContent, err := vo.NewOptionalString1MB("")
if err != nil {
m.Logger.Errorw("failed to create page not found content",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err)
}
domain.PageNotFoundContent.Set(*pageNotFoundContent)
redirectURL, err := vo.NewOptionalString1024("")
if err != nil {
m.Logger.Errorw("failed to create redirect URL",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err)
}
domain.RedirectURL.Set(*redirectURL)
if companyID != nil {
domain.CompanyID.Set(*companyID)
}
_, err = m.DomainService.CreateProxyDomain(ctx, session, domain)
if err != nil {
m.Logger.Errorw("failed to create proxy domain",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
"error", err,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
return 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 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 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
}
}
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)
domain.HostWebsite.Set(false)
domain.ManagedTLS.Set(true)
domain.OwnManagedTLS.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 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
}