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